From f4b62c0c5857f3d5729548049fe26128773289ca Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 17 Oct 2026 01:28:43 +0800 Subject: [PATCH 1/7] feat: forks, switches and rewinds continue natively on Claude and Codex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 26 + CLAUDE.md | 3 +- docs/COMPARISON.md | 2 +- docs/CONFIGURATION.md | 3 + docs/FEATURES.md | 16 + src/config.ts | 11 + src/daemon/providers/canonical.ts | 91 +++- src/daemon/providers/claude/index.ts | 89 +++- src/daemon/providers/claude/native-history.ts | 166 ++++++ src/daemon/providers/codex/index.ts | 119 ++++- src/daemon/providers/codex/native-history.ts | 57 ++ src/daemon/providers/interface.ts | 58 ++ src/daemon/providers/mock/session-provider.ts | 21 + src/daemon/session.ts | 103 +++- src/tests/fixtures/fake-codex.ts | 20 + src/tests/native-continuation.test.ts | 502 ++++++++++++++++++ 16 files changed, 1257 insertions(+), 30 deletions(-) create mode 100644 src/daemon/providers/claude/native-history.ts create mode 100644 src/daemon/providers/codex/native-history.ts create mode 100644 src/tests/native-continuation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e293e584..a4a5ccec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **Forks, backend switches and rewinds continue the conversation natively on + Claude and Codex** (#387). Until now a fork onto Claude, Codex or pi got a + text transcript in its first prompt, with each tool's input and output cut + to 2,000 characters and reasoning dropped, even when the fork stayed on the + same backend. Now: + - On the same backend, the backend forks its own conversation at the turn, + so nothing is converted or cut and reasoning is kept. Claude uses the + Agent SDK's `forkSession`; Codex uses `thread/fork`. + - From another backend, codeoid hands the target native history: a Claude + session file with real `tool_use`/`tool_result` blocks, verified with the + SDK's own reader before use, or Codex history items through + `thread/inject_items`. + + The transcript remains the fallback, for pi, the Gemini CLI, and any native + path that fails. The session says which way it went, and + `session.nativeContinuation` / `CODEOID_NATIVE_CONTINUATION` turns this off. + Each turn now records where it ends in the backend's own conversation + (`NormalizedTurnResult.nativeRef`, stamped on the canonical turn). + - **Routines: agents that run on their own** (#54). `codeoid routine add` saves an agent task that runs on a cron or interval schedule (IANA timezones, phase-anchored intervals, 60-second floor), once at a set time, from @@ -144,6 +163,13 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **Codex sessions keep their conversation across a daemon restart.** The + Codex provider never saved the thread id it created, so after any restart it + tried to resume a stale id and silently started an empty thread. It now + saves the id the moment it adopts a thread. A backend switch now also marks + the new backend fresh until its first prompt, so a restart in between + re-seeds it instead of resuming nothing. + - **The context window is no longer guessed from the model id.** codeoid inferred every window from a substring table over model ids, and that table was wrong the moment a model shipped: `claude-opus-5-5` inferred to the 200k fallback while every turn result stated 1,000,000. The percent-of-window display and the history seed sized for a fork or a provider switch were both built on that guess. diff --git a/CLAUDE.md b/CLAUDE.md index 5373c258..80bbbf74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,7 +124,8 @@ Every prompt, and everything the agent does in reply, belongs to one turn with a A message sent mid-turn joins the running turn. At the start of each turn the working directory is snapshotted into a daemon-owned shadow git repo (`/checkpoints/.git`, self-contained, never the user's repo; secrets and dependency dirs excluded; works without git). The backend-neutral canonical history is persisted per session (`.canonical.jsonl`) so forks, backend switches and stateless backends keep the conversation across a daemon restart. -All of it is backend-agnostic: nothing depends on a provider's native session. +It is the source of truth, and it doesn't depend on a provider's native session. +What a backend is handed on a fork, switch or rewind is chosen by `Session.#seedProviderFromHistory` (#387): `continueNatively` first — Claude and Codex fork their own conversation at the turn's recorded `native` point (stamped on each canonical assistant turn from `NormalizedTurnResult.nativeRef`), or take native history built from the canonical turns — then the transcript seed as the fallback. ### Device Handoff Sessions live in the daemon. Clients are stateless. diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index 85869684..d27e5007 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -25,7 +25,7 @@ Codeoid is the latter, which is why the rows below weigh memory, attribution, an | **Multi-harness** (multiple agent backends) | ❌ Claude only | ❌ | ❌ | ❌ | ✅ swap/combine harnesses in one session (also Cursor, OpenCode, Hermes) | ✅ 12 built-in + any CLI agent, as terminal processes | ✅ Claude, Codex, Gemini, OpenAI, pi, Gemini CLI — per session + fork across backends | | **Cost of adding a backend** | — | — | — | ~ model config | PTY wrapper per harness (tmux) | ~8-line declarative manifest (command + prompt transport) | 300–1,300 LOC native provider, or a thin ACP adapter | | **Structured agent event model** (tool calls, subagent lineage, task graph) | ✅ native SDK | — | — | ❌ | ❌ PTY bytes | ~ terminal path is opaque bytes; ACP path is structured but drops subagent identity, task dependencies, and workflows | ✅ canonical `ProviderEvent` — tool calls, `parentToolUseId` lineage, normalized usage | -| **Cross-backend session fork** (switch harness mid-session, keep context) | ❌ | — | — | ❌ | ✅ swap/combine within one session | ❌ a session is bound to the agent that started it | ✅ `session.set_provider` / `session.fork` with history seed | +| **Cross-backend session fork** (switch harness mid-session, keep context) | ❌ | — | — | ❌ | ✅ swap/combine within one session | ❌ a session is bound to the agent that started it | ✅ `session.set_provider` / `session.fork`; Claude and Codex continue natively (their own fork, or native history codeoid builds), others from a transcript | | **Built-in diff review + PR workflow** | ❌ | ✅ | ✅ | ~ | ❌ | ✅ editable diff viewer, inline comments, commit/push, PR status | ❌ not built — roadmap | | **Remote multi-machine reach** | ~ hosted Remote Control (Max plan) | ❌ | ❌ | ❌ | ~ server deploy | ✅ host registry + cloud relay routes by host key; wakes offline hosts | ~ one daemon, reached directly or through Telegram; no relay | | **Scheduled / unattended automations** | ~ routines (research preview): Anthropic cloud only, 1-hour minimum interval | ❌ | ~ Automations, in Cursor's cloud | ❌ | ~ | ✅ cron-scheduled agent sessions, versioned | ✅ [routines](routines.md): cron / interval / API trigger, a zero-cost check gate, `[SILENT]`, per-run + daily spend caps, guarded or budgeted approvals, failure auto-pause — on your own machine, any backend | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 76632b2d..334f7419 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -102,6 +102,9 @@ CODEOID_MCP_CONTROL_ACTIONS=false # also allow create / send / interrupt / CODEOID_ROUTINES_ENABLED=true # run the scheduler and accept routine commands CODEOID_ROUTINES_MAX_CONCURRENT=2 # routine runs executing at once, across all routines +# Forks, backend switches and rewinds (docs/FEATURES.md#how-a-fork-switch-or-rewind-carries-the-conversation) +CODEOID_NATIVE_CONTINUATION=true # hand Claude/Codex the conversation in their own form; false = text transcript + # Memory CODEOID_MEMORY=1 # default: on; set to 0 to disable CODEOID_MEMORY_DB_PATH=~/.codeoid/memory.db diff --git a/docs/FEATURES.md b/docs/FEATURES.md index c1baeb09..bb829e97 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -17,6 +17,22 @@ A session's *harness* (the agent backend it drives) is chosen when the session i Pick a harness when you create a session — the New Session modal in the web UI or TUI shows the choice whenever more than one backend is registered — or **fork across backends**: fork a Claude session onto Codex and it resumes with the parent's full conversation, so one backend can pick up exactly where another left off. A backend that isn't authenticated or installed simply doesn't appear as an option (no cryptic first-turn 401s). +#### How a fork, switch or rewind carries the conversation + +The files come back exactly: a fork gets a git worktree at the turn's checkpoint. +The conversation is handed over in the backend's own form wherever the backend allows it, and as a text transcript only where it doesn't: + +| 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 included | codeoid writes the conversation as a Claude session, real tool calls and results, 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` | the full history, natively, every turn | the full history, natively, every turn | +| `pi`, `gemini-cli` | a text transcript in the first prompt | a text transcript in the first prompt | + +Reasoning never crosses vendors (Claude's is signed, OpenAI's encrypted), and history that doesn't fit the target's window loses its oldest turns, with a notice. +The session says which way it went (`↪ … continues its own conversation`, `↪ … was given … as its own history`, or `↪ … carried as a text transcript`), and if a native path fails it falls back to the transcript and says so. +`session.nativeContinuation: false` (or `CODEOID_NATIVE_CONTINUATION=false`) keeps everything on the transcript. + Memory, identity, attachments, the multi-frontend clients, and device handoff are all provider-agnostic. A few features lean on Claude Code internals and so apply only to the `claude` harness: backing-session [auto-rotation](#context-reduction-stack), Claude Code slash-command [pass-through](#terminal-client), and `.claude/commands` workspace commands. ### Terminal client diff --git a/src/config.ts b/src/config.ts index 8f2e4c55..2d70bd1a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -446,6 +446,13 @@ const SessionSchema = z seedBudgetChars: z.number().int().min(0).default(0), seedCharsPerToken: z.number().positive().default(2), seedWindowFraction: z.number().gt(0).lt(1).default(0.7), + /** + * Hand the conversation to backends that can take it in their own form + * (#387) — Claude and Codex fork their own conversation at the turn, or + * take native history codeoid builds — instead of the transcript above. + * The transcript is still the fallback. Off = transcript everywhere. + */ + nativeContinuation: z.boolean().default(true), }) .default({ turnStallTimeoutMs: 300_000, @@ -455,6 +462,7 @@ const SessionSchema = z seedBudgetChars: 0, seedCharsPerToken: 2, seedWindowFraction: 0.7, + nativeContinuation: true, }) // Enforce the "SDK signals first" contract across BOTH fields — not just the // defaults. An env override / config file could otherwise set the MCP timeout @@ -1275,6 +1283,8 @@ export interface CodeoidConfig { seedCharsPerToken?: number; /** Fraction (0..1) of the target window the history seed may fill. Defaults to 0.7 when omitted. */ seedWindowFraction?: number; + /** Continue forks / switches / rewinds in the backend's own conversation where it can (#387). Default true. */ + nativeContinuation?: boolean; }; /** * The per-tenant conductor session (fleet supervisor). Optional in the @@ -1537,6 +1547,7 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [ { env: "CODEOID_SEED_BUDGET_CHARS", path: "session.seedBudgetChars", kind: "int" }, { env: "CODEOID_SEED_CHARS_PER_TOKEN", path: "session.seedCharsPerToken", kind: "float" }, { env: "CODEOID_SEED_WINDOW_FRACTION", path: "session.seedWindowFraction", kind: "float" }, + { env: "CODEOID_NATIVE_CONTINUATION", path: "session.nativeContinuation", kind: "boolean" }, // Embed-SSO trusted framing origins (comma-separated). Each is an exact // origin (scheme://host[:port]) permitted to frame the web UI and hand it a // credential via the URL hash. Empty ⇒ hash handoff disabled (safe default). diff --git a/src/daemon/providers/canonical.ts b/src/daemon/providers/canonical.ts index 75c52ff8..586bb45d 100644 --- a/src/daemon/providers/canonical.ts +++ b/src/daemon/providers/canonical.ts @@ -23,7 +23,7 @@ * reconstructed. */ -import { type ProviderEvent, isSubagentEvent } from "./interface.js"; +import { type NativeTurnRef, type ProviderEvent, isSubagentEvent } from "./interface.js"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -77,6 +77,11 @@ export type CanonicalTurn = /** Which provider produced this turn. */ providerId: string; model: string; + /** + * Where this turn ends in `providerId`'s own conversation (#387): a fork + * or rewind to here can continue that conversation natively. + */ + native?: NativeTurnRef; }; // ── Tool name normalization ─────────────────────────────────────────────────── @@ -464,6 +469,7 @@ export class CanonicalHistoryAccumulator { ...(this.#currentThinking ? { thinking: this.#currentThinking } : {}), providerId: event.result.providerId, model: event.result.model, + ...(event.result.nativeRef ? { native: event.result.nativeRef } : {}), }; this.#history.push(turn); this.#emit({ op: "append", turn }); @@ -573,11 +579,13 @@ function toolCallToSeedText(tc: CanonicalToolCall): string { * * Fidelity contract: this is a faithful TRANSCRIPT, not a native * continuation. Tool calls are rendered as structured text blocks (name, - * JSON input, fenced output) rather than native tool_use structures — - * neither the Claude Agent SDK nor pi's RPC accepts synthesized native - * history injection, so a prompt-prefix transcript is the warm-backend - * ceiling. Oldest turns are dropped first when the budget is exceeded - * (recent context matters most), with an elision note. + * JSON input, fenced output) rather than native tool_use structures. It is + * the FALLBACK: a backend that can take the conversation in its own form does + * so through `continueNatively` (#387) — Claude and Codex fork their own + * conversation, or take native history codeoid builds — and this transcript + * is used only where that isn't available (pi, the Gemini CLI) or fails. + * Oldest turns are dropped first when the budget is exceeded (recent context + * matters most), with an elision note. * * `maxChars` should be sized to the TARGET model's context window by the * caller (see seedBudgetChars in context-windows.ts) so a fork only truncates @@ -655,3 +663,74 @@ export function renderHistorySeed( return { text, totalTurns: rendered.length, keptTurns, omittedTurns, newestTurnSliced }; } + +/** Rough size of a turn as native history (text, tool inputs and outputs), for budgets. */ +export function canonicalTurnChars(turn: CanonicalTurn): number { + if (turn.role === "user") return turn.content.length; + let n = turn.content.length; + for (const tc of turn.toolCalls ?? []) n += JSON.stringify(tc.input).length + tc.output.length + tc.name.length + 64; + return n; +} + +/** + * Keep the newest turns of `history` that fit `maxChars`, as measured by + * `size`, for a backend that takes the conversation as native history (#387). + * Whole turns are dropped oldest first; when the newest turn alone exceeds the + * budget, its tool inputs and outputs are cut to fit and it is kept. The + * result reports what was dropped, in the same shape a transcript seed does, + * so the session surfaces it the same way. + */ +export function fitTurnsToBudget( + history: readonly CanonicalTurn[], + maxChars: number, + size: (turn: CanonicalTurn) => number, +): { turns: CanonicalTurn[]; truncation: HistorySeedResult } { + const kept: CanonicalTurn[] = []; + let used = 0; + let newestTurnSliced = false; + for (let i = history.length - 1; i >= 0; i--) { + let turn = history[i]!; + const n = size(turn); + if (used + n > maxChars) { + if (kept.length > 0) break; + turn = shrinkTurn(turn, maxChars); + newestTurnSliced = true; + } + kept.unshift(turn); + used += size(turn); + } + return { + turns: kept, + truncation: { + text: "", + totalTurns: history.length, + keptTurns: kept.length, + omittedTurns: history.length - kept.length, + newestTurnSliced, + }, + }; +} + +/** One turn cut down to roughly `maxChars`: text and each tool payload get an equal share. */ +function shrinkTurn(turn: CanonicalTurn, maxChars: number): CanonicalTurn { + const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}\n…truncated to fit the context window…` : s); + if (turn.role === "user") return { ...turn, content: cut(turn.content, maxChars) }; + const parts = 1 + 2 * (turn.toolCalls?.length ?? 0); + const share = Math.max(200, Math.floor(maxChars / parts)); + return { + ...turn, + content: cut(turn.content, share), + ...(turn.toolCalls + ? { + toolCalls: turn.toolCalls.map((tc) => { + const input = JSON.stringify(tc.input); + return { + ...tc, + input: input.length > share ? { truncated: cut(input, share) } : tc.input, + output: cut(tc.output, share), + }; + }), + } + : {}), + }; +} diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index 262910e1..3547b7c3 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -12,6 +12,8 @@ import type { McpTenant } from "../../mcp/oauth.js"; import { + forkSession, + getSessionMessages, query, type Query, type SDKMessage, @@ -42,7 +44,20 @@ import { FLEET_TOOL_NAMES } from "../../fleet.js"; import { rewriteBashToolInput } from "../../compress/index.js"; import type { CodeoidConfig } from "../../../config.js"; import type { AuthContext } from "../../../protocol/types.js"; -import type { SessionProvider, ModelInfo, NormalizedTurnResult, ProviderEvent, SessionScopedEvent, TurnOpts, TurnRun, CatalogEntry, UiResponse } from "../interface.js"; +import type { + SessionProvider, + ModelInfo, + NativeContinuationRequest, + NativeContinuationResult, + NormalizedTurnResult, + ProviderEvent, + SessionScopedEvent, + TurnOpts, + TurnRun, + CatalogEntry, + UiResponse, +} from "../interface.js"; +import { removeClaudeSession, toClaudeSessionEntries, writeClaudeSession } from "./native-history.js"; import { BUDGET_STOP_MESSAGE, isBackgroundLifecycleEvent } from "../interface.js"; import { isUngrantableSkillCommand, redactCommand } from "../../skill-command.js"; import { renderHistorySeed, type CanonicalTurn, type HistorySeedResult } from "../canonical.js"; @@ -98,9 +113,17 @@ export function packPluginsOption(dirs?: readonly string[]): { plugins?: { type: // ── Initialisation options ──────────────────────────────────────────────────── +/** The SDK's session-file functions native continuation uses; injectable for tests. */ +export interface ClaudeNativeSdk { + forkSession(sessionId: string, opts: { upToMessageId: string }): Promise<{ sessionId: string }>; + getSessionMessages(sessionId: string, opts: { dir: string }): Promise>; +} + export interface ClaudeProviderInit { /** codeoid session id — used as the initial backing id and for audit/logging. */ sessionId: string; + /** Test seam for native continuation (#387); defaults to the real SDK. */ + nativeSdk?: ClaudeNativeSdk; /** Persisted backing id from Store, or the session id itself on first run. */ initialBackingId: string; /** Tenant-scoped memory workspace id (computed once by Session). Used to bind @@ -440,6 +463,55 @@ export class ClaudeProvider implements SessionProvider { this.#pendingHistorySeed = block.length > 0 ? block : null; } + /** + * Continue a conversation natively (#387). Same backend with a recorded + * fork point: fork Claude's own session there — nothing is lost, reasoning + * included. Otherwise: write codeoid's history as a Claude session and + * resume it, so tool calls arrive as real tool_use/tool_result blocks. + * + * A written session is read back with the SDK's own reader before it is + * used; anything short of an exact read-back is removed and reported as + * null, so the session falls back to the transcript seed rather than + * resuming something Claude may misread. + */ + async continueNatively(req: NativeContinuationRequest): Promise { + const sdk = this.#init.nativeSdk ?? { forkSession, getSessionMessages }; + const tag = `[claude-provider ${this.#init.sessionId.slice(0, 8)}]`; + if (req.forkFrom) { + try { + const { sessionId } = await sdk.forkSession(req.forkFrom.backingId, { upToMessageId: req.forkFrom.ref }); + this.#adoptBacking(sessionId); + return { via: "fork", backingId: sessionId }; + } catch (err) { + // The source session may be gone (cleaned up, another machine): build it instead. + console.error(`${tag} native fork of ${req.forkFrom.backingId.slice(0, 8)} failed, building history instead: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (req.history.length === 0) return null; + const id = randomUUID(); + const { entries, truncation } = toClaudeSessionEntries(req.history, { sessionId: id, cwd: req.workdir, maxChars: req.maxChars }); + if (entries.length === 0) return null; + const path = await writeClaudeSession(entries, { sessionId: id, cwd: req.workdir }); + try { + const read = await sdk.getSessionMessages(id, { dir: req.workdir }); + if (read.length !== entries.length || read.at(-1)?.uuid !== entries.at(-1)!.uuid) { + throw new Error(`the SDK read back ${read.length} of ${entries.length} entries`); + } + } catch (err) { + await removeClaudeSession(path).catch(() => {}); + console.error(`${tag} native history didn't verify, using the transcript: ${err instanceof Error ? err.message : String(err)}`); + return null; + } + this.#adoptBacking(id); + return { via: "history", backingId: id, ...(truncation.omittedTurns > 0 || truncation.newestTurnSliced ? { truncation } : {}) }; + } + + /** Make `id` this provider's backing session, resumed (not created) on the next turn. */ + #adoptBacking(id: string): void { + this.resetToNewSession(id); + this.#hasQueried = true; + } + /** * Claude has the memory recall tools (recall/recall_file/timeline/get_episode) * mounted via the in-process MCP server whenever the session has memory, so it @@ -1281,6 +1353,12 @@ export interface TranslateState { * place. The SDK already says which model is primary; ask it instead. */ primaryModel: string | null; + /** + * The uuid of the last main-conversation entry (assistant message or tool + * result, not a subagent's, not a replay) seen this turn: where the turn + * ends in Claude's own session, so a fork can be cut there (#387). + */ + lastChainUuid?: string | null; } /** @@ -1299,6 +1377,10 @@ export function translateSDKMessage( * reader and would drop them unread. */ emitSession?: (event: SessionScopedEvent) => void, ): void { + if (msg.type === "assistant" || msg.type === "user") { + const m = msg as { uuid?: string; parent_tool_use_id?: string | null; isReplay?: boolean }; + if (m.uuid && !m.parent_tool_use_id && !m.isReplay) state.lastChainUuid = m.uuid; + } switch (msg.type) { case "assistant": { // Per-call LLM usage — split primary vs subagent for accurate ctx tracking. @@ -1431,6 +1513,9 @@ export function translateSDKMessage( }` : undefined; state.lastLocalCommandStderr = null; + const sessionId = (msg as { session_id?: string }).session_id; + const chainEnd = state.lastChainUuid; + state.lastChainUuid = null; const normalized: NormalizedTurnResult = { providerId, model, @@ -1451,6 +1536,8 @@ export function translateSDKMessage( : r.subtype !== "success" && r.result ? r.result : undefined), + // A zero-turn run left nothing in the session to fork at. + ...(sessionId && chainEnd && !zeroTurn ? { nativeRef: { backingId: sessionId, ref: chainEnd } } : {}), }; emit({ type: "turn_done", result: normalized }); // Stay warm — keep the queue open for the next turn. diff --git a/src/daemon/providers/claude/native-history.ts b/src/daemon/providers/claude/native-history.ts new file mode 100644 index 00000000..8182a7cf --- /dev/null +++ b/src/daemon/providers/claude/native-history.ts @@ -0,0 +1,166 @@ +/** + * Native history for the Claude backend (#387): codeoid's canonical + * conversation written as a Claude Code session, so a fork, rewind or backend + * switch onto Claude resumes a real conversation — tool calls as `tool_use` / + * `tool_result` blocks — instead of reading a transcript in its first prompt. + * + * Claude Code keeps each session as JSONL under + * `/projects//.jsonl`: one entry per line, + * chained by `uuid` / `parentUuid`. The writer below produces the subset of + * that format the CLI needs to resume, and the caller verifies the result + * with the SDK's own reader before trusting it, so a format change in a future + * CLI fails closed (back to the transcript) instead of corrupting a session. + * + * What is not carried: reasoning. Claude's thinking blocks are signed and + * other vendors' reasoning is opaque, so neither can be replayed; the text and + * tool calls the reasoning produced are. + */ + +import { randomBytes, randomUUID } from "node:crypto"; +import { mkdirSync } from "node:fs"; +import { rename, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { type CanonicalTurn, canonicalTurnChars, fitTurnsToBudget, type HistorySeedResult } from "../canonical.js"; + +/** One line of a Claude Code session file. */ +export interface ClaudeSessionEntry { + type: "user" | "assistant"; + uuid: string; + parentUuid: string | null; + sessionId: string; + cwd: string; + timestamp: string; + isSidechain: false; + userType: "external"; + message: Record; +} + +/** Tool names the API accepts in a `tool_use` block. */ +const toolName = (name: string): string => name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 128) || "tool"; + +/** A `toolu_` id in the API's shape. Tool ids from other backends (`call_…`) are not reused. */ +const toolUseId = (): string => `toolu_${randomBytes(12).toString("hex")}`; + +/** + * Canonical history → Claude session entries, chained, oldest first. + * + * - A user turn is a user message. Consecutive user turns (a turn that got + * no reply) are merged, since the conversation must alternate. + * - An assistant turn is, per tool call, an assistant `tool_use` message + * followed by a user `tool_result` message, then an assistant message with + * its text. The order of text against tool calls inside one turn isn't + * recorded, so the text comes last — it is the turn's answer. + * - Tool names: the backend's own name when the turn ran on Claude, else the + * neutral canonical name; the API accepts history naming tools that aren't + * defined now (verified live). + * - Dropped turns are announced in a leading user note, and the history + * always ends on an assistant message so the next prompt follows a reply. + */ +export function toClaudeSessionEntries( + history: readonly CanonicalTurn[], + opts: { sessionId: string; cwd: string; maxChars: number; now?: Date }, +): { entries: ClaudeSessionEntry[]; truncation: HistorySeedResult } { + const { turns, truncation } = fitTurnsToBudget(history, opts.maxChars, canonicalTurnChars); + const at = (opts.now ?? new Date()).toISOString(); + const entries: ClaudeSessionEntry[] = []; + let parent: string | null = null; + const push = (type: ClaudeSessionEntry["type"], message: Record) => { + const uuid = randomUUID(); + entries.push({ type, uuid, parentUuid: parent, sessionId: opts.sessionId, cwd: opts.cwd, timestamp: at, isSidechain: false, userType: "external", message }); + parent = uuid; + }; + const assistant = (content: unknown[], model: string, stop: "end_turn" | "tool_use") => + push("assistant", { + id: `msg_${randomBytes(12).toString("hex")}`, + type: "message", + role: "assistant", + model, + content, + stop_reason: stop, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }); + + // Pending user text, so consecutive user turns merge into one message. + let userText: string[] = []; + if (truncation.omittedTurns > 0) { + userText.push(`[${truncation.omittedTurns} earlier turn(s) of this conversation were left out to fit the context window.]`); + } + const flushUser = () => { + if (userText.length === 0) return; + push("user", { role: "user", content: userText.join("\n\n") }); + userText = []; + }; + let lastWasAssistant = false; + + for (const turn of turns) { + if (turn.role === "user") { + userText.push(turn.content); + lastWasAssistant = false; + continue; + } + const calls = turn.toolCalls ?? []; + if (!turn.content && calls.length === 0) continue; + // An assistant message must follow a user message. + if (userText.length === 0 && (entries.length === 0 || lastWasAssistant)) { + userText.push(entries.length === 0 ? "(continuing the conversation)" : "(continue)"); + } + flushUser(); + const own = turn.providerId === "claude"; + for (const tc of calls) { + const id = toolUseId(); + assistant([{ type: "tool_use", id, name: toolName(own && tc.originalName ? tc.originalName : tc.name), input: tc.input }], turn.model, "tool_use"); + push("user", { role: "user", content: [{ type: "tool_result", tool_use_id: id, content: tc.output, ...(tc.success ? {} : { is_error: true }) }] }); + } + assistant([{ type: "text", text: turn.content || "(done)" }], turn.model, "end_turn"); + lastWasAssistant = true; + } + // A last user turn with no reply (an interrupted turn) still belongs to the + // conversation; close it so the next prompt follows an assistant message. + if (userText.length > 0) { + flushUser(); + assistant([{ type: "text", text: "(This turn ended before a reply was recorded.)" }], "unknown", "end_turn"); + } + return { entries, truncation }; +} + +/** Claude Code's config dir, as the backend subprocess will see it. */ +export function claudeConfigDir(env: Record = process.env): string { + return env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); +} + +/** + * The project directory Claude Code files a cwd's sessions under: every + * character outside [a-zA-Z0-9] becomes "-". Resume finds a session in any + * project directory, so this only keeps the file where the CLI would put it. + */ +export function claudeProjectDirName(cwd: string): string { + const name = cwd.replace(/[^a-zA-Z0-9]/g, "-"); + return name.length <= 200 ? name : `${name.slice(0, 180)}-${djb2(cwd).toString(36)}`; +} + +function djb2(s: string): number { + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; + return h; +} + +/** Write a session file atomically (tmp + rename). Returns its path. */ +export async function writeClaudeSession( + entries: readonly ClaudeSessionEntry[], + opts: { sessionId: string; cwd: string; configDir?: string }, +): Promise { + const dir = join(opts.configDir ?? claudeConfigDir(), "projects", claudeProjectDirName(opts.cwd)); + mkdirSync(dir, { recursive: true }); + const path = join(dir, `${opts.sessionId}.jsonl`); + const tmp = `${path}.${process.pid}.tmp`; + await writeFile(tmp, `${entries.map((e) => JSON.stringify(e)).join("\n")}\n`, { mode: 0o600 }); + await rename(tmp, path); + return path; +} + +/** Remove a session file written by writeClaudeSession (after a failed verification). */ +export async function removeClaudeSession(path: string): Promise { + await rm(path, { force: true }); +} diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index 8895ed31..d7539ac0 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -49,6 +49,8 @@ import { AsyncQueue } from "../../async-queue.js"; import type { Store } from "../../store.js"; import type { ModelInfo, + NativeContinuationRequest, + NativeContinuationResult, NormalizedTurnResult, ProviderEvent, SessionProvider, @@ -59,6 +61,7 @@ import type { CatalogEntry, } from "../interface.js"; import { renderHistorySeed, type CanonicalTurn, type HistorySeedResult } from "../canonical.js"; +import { type ResponsesItem, toResponsesItems } from "./native-history.js"; import { buildCodexEnv } from "../env.js"; import { CodexRpcProcess } from "./rpc.js"; import type { SessionMode } from "../../../protocol/types.js"; @@ -264,6 +267,14 @@ export class CodexProvider implements SessionProvider { #hasQueried = false; #modelsReported = false; #pendingHistorySeed: string | null = null; + readonly #store: Store; + /** + * A native continuation to apply when the thread is first created (#387): + * fork the backend's own thread at a turn, or start one and inject history. + * Deferred to the first turn because the thread takes that turn's + * instructions. `fallback` is the transcript seed used if it fails. + */ + #pendingNative: { kind: "fork"; threadId: string; turnId: string; fallback: string | null } | { kind: "inject"; items: ResponsesItem[]; fallback: string | null } | null = null; // Per-turn wiring. #turnQueue: AsyncQueue | null = null; @@ -317,6 +328,7 @@ export class CodexProvider implements SessionProvider { #pendingMcpCalls = new Map }>>(); constructor(init: CodexProviderInit) { + this.#store = init.store; this.#sessionId = init.sessionId; this.#backingSessionId = init.initialBackingId; this.#command = init.command; @@ -352,6 +364,7 @@ export class CodexProvider implements SessionProvider { // A fresh backing session starts from nothing: a seed queued for the // previous one (a backend switch, a rewind) must not ride along (#355). this.#pendingHistorySeed = null; + this.#pendingNative = null; this.#backingSessionId = newBackingId; this.#threadId = null; this.#hasQueried = false; @@ -376,6 +389,64 @@ export class CodexProvider implements SessionProvider { this.#pendingHistorySeed = block.length > 0 ? block : null; } + /** + * Continue a conversation natively (#387): fork this backend's own thread + * at the recorded turn when there is one, else inject codeoid's history as + * Responses items into a fresh thread. Applied when the thread is created + * on the first turn (it takes that turn's instructions); if it fails then, + * the turn says so and falls back to the transcript seed rendered here. + */ + async continueNatively(req: NativeContinuationRequest): Promise { + const fallback = renderHistorySeed(req.history, { maxChars: req.maxChars }).text || null; + this.#pendingHistorySeed = null; + if (req.forkFrom) { + this.#pendingNative = { kind: "fork", threadId: req.forkFrom.backingId, turnId: req.forkFrom.ref, fallback }; + return { via: "fork" }; + } + const { items, truncation } = toResponsesItems(req.history, { maxChars: req.maxChars }); + if (items.length === 0) return null; + this.#pendingNative = { kind: "inject", items, fallback }; + return { via: "history", ...(truncation.omittedTurns > 0 || truncation.newestTurnSliced ? { truncation } : {}) }; + } + + /** + * Create the thread a pending native continuation asks for. Returns its id, + * or null after falling back to the transcript (and telling the turn why). + */ + async #applyPendingNative(opts: TurnOpts): Promise { + const pending = this.#pendingNative; + this.#pendingNative = null; + if (!pending || !this.#proc) return null; + const { approvalPolicy, sandboxMode } = codexPolicies(opts.mode); + const config = { + cwd: opts.workdir, + approvalPolicy, + sandboxPolicy: sandboxPolicyWire(sandboxMode, opts.workdir), + ...(opts.systemPromptAppend ? { developerInstructions: opts.systemPromptAppend } : {}), + }; + try { + if (pending.kind === "fork") { + const forked = (await this.#proc.request("thread/fork", { threadId: pending.threadId, lastTurnId: pending.turnId, ...config })) as { thread?: { id?: string } }; + if (!forked.thread?.id) throw new Error("thread/fork returned no thread id"); + return forked.thread.id; + } + const started = (await this.#proc.request("thread/start", config)) as { thread?: { id?: string } }; + if (!started.thread?.id) throw new Error("thread/start returned no thread id"); + await this.#proc.request("thread/inject_items", { threadId: started.thread.id, items: pending.items }); + return started.thread.id; + } catch (err) { + const why = err instanceof Error ? err.message : String(err); + console.error(`[codex-provider ${this.#sessionId.slice(0, 8)}] native ${pending.kind} failed, using the transcript: ${why}`); + this.#pendingHistorySeed = pending.fallback; + this.#push({ + type: "custom_message", + content: `⚠️ Codex couldn't take this conversation as its own history (${why.slice(0, 200)}), so it continues from a text transcript of it instead.`, + metadata: { event: "history.native_fallback", providerId: this.id }, + }); + return null; + } + } + /** * `-c` config overrides + token env that mount the shared memory endpoint on * the codex app-server (`--url` streamable-HTTP MCP server, bearer token read @@ -599,10 +670,20 @@ export class CodexProvider implements SessionProvider { this.#proc.notify("initialized"); } + // A native continuation (#387) creates the thread itself. + if (this.#pendingNative) { + const native = await this.#applyPendingNative(opts); + if (native) { + this.#adoptThread(native); + await this.#reportModelsOnce(); + return; + } + } + // Resume the prior thread when the backing id looks like one codex // minted; otherwise (first run: backing id is the codeoid session id) // start fresh. Resume failure degrades to a fresh thread — never wedge. - if (this.#backingSessionId !== this.#sessionId) { + else if (this.#backingSessionId !== this.#sessionId) { try { const resumed = (await this.#proc.request("thread/resume", { threadId: this.#backingSessionId, @@ -623,15 +704,33 @@ export class CodexProvider implements SessionProvider { ...(opts.systemPromptAppend ? { developerInstructions: opts.systemPromptAppend } : {}), })) as { thread?: { id?: string } }; if (!started.thread?.id) throw new Error("codex thread/start returned no thread id"); - this.#threadId = started.thread.id; - this.#backingSessionId = started.thread.id; + this.#adoptThread(started.thread.id); + await this.#reportModelsOnce(); + } - if (!this.#modelsReported && this.#onModels) { - const models = await this.listModels(); - if (models.length > 0) { - this.#modelsReported = true; - this.#onModels(models.map((m) => ({ value: m.id, displayName: m.displayName, description: m.description }))); - } + /** + * Make `id` the session's thread, and remember it: a restart resumes the + * thread only if its id was saved. (It wasn't, before #387 — every restart + * quietly started an empty thread.) + */ + #adoptThread(id: string): void { + this.#threadId = id; + if (this.#backingSessionId === id) return; + this.#backingSessionId = id; + try { + // The column predates multi-provider naming: "the backend's session id". + this.#store.setClaudeCodeSessionId(this.#sessionId, id); + } catch (err) { + console.error(`[codex-provider ${this.#sessionId.slice(0, 8)}] failed to persist thread id: ${err instanceof Error ? err.message : String(err)}`); + } + } + + async #reportModelsOnce(): Promise { + if (this.#modelsReported || !this.#onModels) return; + const models = await this.listModels(); + if (models.length > 0) { + this.#modelsReported = true; + this.#onModels(models.map((m) => ({ value: m.id, displayName: m.displayName, description: m.description }))); } } @@ -824,6 +923,8 @@ export class CodexProvider implements SessionProvider { ? { contextWindow: this.#modelContextWindow } : {}), stopReason: (turn?.status as string | undefined) ?? undefined, + // Where this turn ends in the thread, for a native fork there (#387). + ...(this.#threadId && typeof turn?.id === "string" ? { nativeRef: { backingId: this.#threadId, ref: turn.id } } : {}), }; this.#push({ type: "turn_done", result }); this.#turnQueue?.close(); diff --git a/src/daemon/providers/codex/native-history.ts b/src/daemon/providers/codex/native-history.ts new file mode 100644 index 00000000..c679a78d --- /dev/null +++ b/src/daemon/providers/codex/native-history.ts @@ -0,0 +1,57 @@ +/** + * Native history for the Codex backend (#387): codeoid's canonical + * conversation as Responses API items, appended to a fresh thread with + * `thread/inject_items`, so a fork, rewind or backend switch onto Codex + * continues a real conversation — tool calls as `function_call` / + * `function_call_output` items — instead of reading a transcript in its first + * prompt. Codex persists injected items like its own (verified live across an + * app-server restart). + * + * Reasoning is not carried: Codex's own is encrypted, other vendors' can't be + * replayed as Codex reasoning. + */ + +import { randomBytes } from "node:crypto"; +import { type CanonicalTurn, canonicalTurnChars, fitTurnsToBudget, type HistorySeedResult } from "../canonical.js"; + +export type ResponsesItem = + | { type: "message"; role: "user"; content: Array<{ type: "input_text"; text: string }> } + | { type: "message"; role: "assistant"; content: Array<{ type: "output_text"; text: string }> } + | { type: "function_call"; name: string; call_id: string; arguments: string } + | { type: "function_call_output"; call_id: string; output: string }; + +/** Function names the Responses API accepts. */ +const functionName = (name: string): string => name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "tool"; + +/** + * Canonical history → Responses items, oldest first. Per assistant turn: each + * tool call as a `function_call` with its `function_call_output`, then the + * turn's text. Tool names are the backend's own when the turn ran on Codex, + * else the neutral canonical name (Codex accepts history naming tools it + * doesn't have — verified live). Dropped turns are announced in a leading note. + */ +export function toResponsesItems( + history: readonly CanonicalTurn[], + opts: { maxChars: number }, +): { items: ResponsesItem[]; truncation: HistorySeedResult } { + const { turns, truncation } = fitTurnsToBudget(history, opts.maxChars, canonicalTurnChars); + const items: ResponsesItem[] = []; + const user = (text: string) => items.push({ type: "message", role: "user", content: [{ type: "input_text", text }] }); + if (truncation.omittedTurns > 0) { + user(`[${truncation.omittedTurns} earlier turn(s) of this conversation were left out to fit the context window.]`); + } + for (const turn of turns) { + if (turn.role === "user") { + user(turn.content); + continue; + } + const own = turn.providerId === "codex"; + for (const tc of turn.toolCalls ?? []) { + const call_id = `call_${randomBytes(12).toString("hex")}`; + items.push({ type: "function_call", name: functionName(own && tc.originalName ? tc.originalName : tc.name), call_id, arguments: JSON.stringify(tc.input) }); + items.push({ type: "function_call_output", call_id, output: tc.success ? tc.output : `Error: ${tc.output}` }); + } + if (turn.content) items.push({ type: "message", role: "assistant", content: [{ type: "output_text", text: turn.content }] }); + } + return { items, truncation }; +} diff --git a/src/daemon/providers/interface.ts b/src/daemon/providers/interface.ts index 262ba9bc..9a735e27 100644 --- a/src/daemon/providers/interface.ts +++ b/src/daemon/providers/interface.ts @@ -200,6 +200,55 @@ export interface NormalizedTurnResult { stopReason?: string; isError?: boolean; errorMessage?: string; + /** + * Where this turn ends in the backend's OWN record of the conversation, for + * a backend that can later fork or resume from that point natively (#387). + * Stamped on the canonical turn, so a fork, rewind or restart can continue + * the backend's own conversation instead of a rendered transcript. + */ + nativeRef?: NativeTurnRef; +} + +/** + * A point in a backend's native conversation: the backing session/thread and + * the backend's id for the last entry of a turn in it (a Claude transcript + * entry uuid, a Codex turn id). Opaque to everything but the provider that + * minted it. + */ +export interface NativeTurnRef { + backingId: string; + ref: string; +} + +/** What a session asks a provider when it hands it an existing conversation. */ +export interface NativeContinuationRequest { + /** The conversation to continue, oldest first. */ + history: readonly CanonicalTurn[]; + /** + * Set when the history's last turn ran on THIS backend and it recorded where + * that turn ends: the provider may fork its own conversation there, which + * loses nothing (reasoning included). + */ + forkFrom?: NativeTurnRef; + /** Char budget for history the provider builds itself (sized to the target window). */ + maxChars: number; + /** Where the continued session runs. */ + workdir: string; +} + +/** A native continuation that took. */ +export interface NativeContinuationResult { + /** `fork`: the backend's own conversation, forked. `history`: native history codeoid built. */ + via: "fork" | "history"; + /** + * The backend's new backing session/thread id, when it exists already: the + * session persists it and treats the backend as holding the conversation. + * Absent for a backend that builds it on its first turn (Codex needs that + * turn's instructions); until then a restart re-runs the continuation. + */ + backingId?: string; + /** Set when building the history dropped older turns to fit the budget. */ + truncation?: HistorySeedResult; } // ── Provider event stream ───────────────────────────────────────────────────── @@ -506,6 +555,15 @@ export interface AgentProvider { * full history natively every turn). */ seedText?(block: string): void; + /** + * Hand this (fresh) backend an existing conversation in its OWN native form, + * instead of a transcript in the first prompt (#387): fork its own + * conversation at `forkFrom` when given, else build native history from + * `history`. Resolves null when the backend can't, and the session falls + * back to the transcript seed; a throw is treated the same. On success the + * backend's backing id is `result.backingId` and its next turn resumes it. + */ + continueNatively?(req: NativeContinuationRequest): Promise; /** * True when codeoid's memory recall tools (recall/recall_file/timeline/ * get_episode) are mounted for this provider+session, so the model can page diff --git a/src/daemon/providers/mock/session-provider.ts b/src/daemon/providers/mock/session-provider.ts index 758a257a..b6a2e2d4 100644 --- a/src/daemon/providers/mock/session-provider.ts +++ b/src/daemon/providers/mock/session-provider.ts @@ -25,6 +25,8 @@ import { AsyncQueue } from "../../async-queue.js"; import type { ModelInfo, + NativeContinuationRequest, + NativeContinuationResult, NormalizedTurnResult, ProviderEvent, SessionProvider, @@ -112,6 +114,25 @@ export class MockSessionProvider implements SessionProvider { supportsMemoryTools = false; /** Blocks captured by seedText() — the compact session-map seed path. */ readonly seededText: string[] = []; + /** + * Native continuation (#387), off unless a test installs it: set this to a + * function and the session tries it before the transcript seed. Requests + * are recorded in `nativeRequests`; a returned `backingId` is adopted. + */ + continueNatively?: (req: NativeContinuationRequest) => Promise; + readonly nativeRequests: NativeContinuationRequest[] = []; + /** Install a scripted native continuation that records each request. */ + scriptNative(answer: (req: NativeContinuationRequest) => NativeContinuationResult | null | Promise): void { + this.continueNatively = async (req) => { + this.nativeRequests.push(req); + const r = await answer(req); + if (r?.backingId) { + this.#backingSessionId = r.backingId; + this.#hasQueried = true; + } + return r; + }; + } /** When true, TurnRuns expose pushMidTurn (recorded in `midTurnPushes`). */ #midTurn: boolean; diff --git a/src/daemon/session.ts b/src/daemon/session.ts index e11ff952..beaec759 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -19,6 +19,7 @@ import { CONDUCTOR_SYSTEM_PROMPT_APPEND, isFleetSendTool } from "./fleet.js"; import type { McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk"; import { type ProviderEvent, + type NativeContinuationResult, type NormalizedTurnResult, type TurnRun, type ToolApprovalFn, @@ -739,6 +740,12 @@ export class Session { #endSnapshotTurn: string | null = null; /** The backend was reset and holds no conversation yet (see TranscriptMeta.backingFresh). */ #backingFresh = false; + /** + * The last history seed left the backend holding the conversation already + * (a native fork or written native history, #387) — so it isn't "fresh": + * a restart resumes it instead of re-seeding. + */ + #nativeBackingReady = false; /** A rewind is in progress: nothing may wake the agent meanwhile. */ #rewinding = false; /** @@ -1437,7 +1444,10 @@ export class Session { // Offer the canonical history to the incoming provider. Best-effort by // contract: a seed failure degrades to an unseeded switch, never a // wedged session. - const seeded = await this.#seedProviderFromHistory(); + const seeded = await this.#seedProviderFromHistory(undefined, { announce: true }); + // Until the first prompt, a backend that took a transcript (or will build + // its thread lazily) holds nothing: a restart must re-seed, not resume. + if (this.#accumulator.history.length > 0) this.#markBackingFresh(!this.#nativeBackingReady); this.#store.audit( sender.sub, @@ -1447,11 +1457,7 @@ export class Session { ); const infoMsg = this.#makeMessage( "info", - `Backend switched: ${previous} → ${requested}.${ - seeded - ? " The conversation so far is carried over as a transcript." - : "" - } Model reset to the ${requested} default.`, + `Backend switched: ${previous} → ${requested}. Model reset to the ${requested} default.`, SYSTEM_IDENTITY, undefined, undefined, @@ -3180,11 +3186,16 @@ export class Session { * by contract: a throw degrades to an unseeded start, never a wedge. * Returns whether a seed was actually applied. */ - async #seedProviderFromHistory(retry?: { historyLength: number; budgetChars: number; attempts: number }): Promise { + async #seedProviderFromHistory( + retry?: { historyLength: number; budgetChars: number; attempts: number }, + /** Tell the session's clients which way the conversation was carried (fork, switch). */ + opts: { announce?: boolean } = {}, + ): Promise { const history = retry ? this.#accumulator.history.slice(0, retry.historyLength) : this.#accumulator.history; this.#pendingSeed = null; + this.#nativeBackingReady = false; if (history.length === 0) return false; const s = this.#config?.session; const budgetChars = @@ -3194,6 +3205,15 @@ export class Session { charsPerToken: s?.seedCharsPerToken, windowFraction: s?.seedWindowFraction, }); + // The backend's own form first (#387). An overflow retry stays on the + // transcript: that is what overflowed, and what shrinks. + if (!retry) { + const native = await this.#continueNatively(history, budgetChars); + if (native) { + if (opts.announce) this.#announceContinuation(native.via, history.length); + return true; + } + } // Delegate to the active context strategy (default `transcript` = the // legacy rendered-history seed; `vws` = compact session map + on-demand // paging, which falls back to transcript for backends without the recall @@ -3214,6 +3234,9 @@ export class Session { // only it can be retried smaller if the first prompt overflows. if (outcome.applied && outcome.via === "transcript" && outcome.truncation) { this.#pendingSeed = { historyLength: history.length, budgetChars, attempts: retry?.attempts ?? 0 }; + // Only a warm backend that actually took the transcript: a stateless + // one already reads the whole history natively every turn. + if (opts.announce) this.#announceContinuation("transcript", history.length); } return outcome.applied; } catch (err) { @@ -3224,6 +3247,59 @@ export class Session { } } + /** + * Hand the history to the backend in its own form (#387), when it can take + * it and native continuation is on. The last turn's recorded native point + * lets a same-backend fork, rewind or reseed fork the backend's own + * conversation; otherwise the backend builds native history from the + * canonical turns. Null means "use the transcript": not supported, refused, + * or failed — failures are logged, never thrown. + */ + async #continueNatively(history: readonly CanonicalTurn[], maxChars: number): Promise { + if (this.#config?.session?.nativeContinuation === false) return null; + const provider = this.#provider; + if (!provider.continueNatively) return null; + const last = history.at(-1); + const forkFrom = last?.role === "assistant" && last.providerId === provider.id ? last.native : undefined; + let result: NativeContinuationResult | null; + try { + result = await provider.continueNatively({ history, ...(forkFrom ? { forkFrom } : {}), maxChars, workdir: this.workdir }); + } catch (err) { + console.error(`[codeoid/session ${this.id}] native continuation on ${provider.id} failed, using the transcript: ${err instanceof Error ? err.message : String(err)}`); + return null; + } + if (!result) return null; + if (result.backingId) { + try { + this.#store.setClaudeCodeSessionId(this.id, result.backingId); + } catch (err) { + console.error(`[codeoid/session ${this.id}] failed to persist native backing id: ${err instanceof Error ? err.message : String(err)}`); + } + this.#nativeBackingReady = true; + } + if (result.truncation && result.truncation.omittedTurns > 0) this.#surfaceSeedTruncation(result.truncation); + return result; + } + + /** Say how a fork or switch carried the conversation to this backend. */ + #announceContinuation(via: "fork" | "history" | "transcript", turns: number): void { + const p = this.#provider.id; + const text = + via === "fork" + ? `↪ ${p} continues its own conversation from this point: nothing was converted or cut.` + : via === "history" + ? `↪ The conversation (${turns} turn${turns === 1 ? "" : "s"}) was given to ${p} as its own history, tool calls included. Reasoning from another backend isn't carried.` + : `↪ ${p} can't take the conversation in its own form, so it was carried as a text transcript of ${turns} turn${turns === 1 ? "" : "s"}.`; + const msg = this.#makeMessage("info", text, SYSTEM_IDENTITY, undefined, undefined, { + event: "history.continuation", + via, + provider: p, + turns, + }); + this.#persistAndBuffer(msg); + this.#broadcastRaw(msg); + } + /** * Retry a first turn whose history seed overflowed the model's window: start * a fresh backing session (the failed one holds the oversized prompt, so @@ -3461,10 +3537,11 @@ export class Session { ); }); } - await this.#seedProviderFromHistory(); + await this.#seedProviderFromHistory(undefined, { announce: true }); // Brand-new backend: if the daemon restarts before the fork's first - // prompt, resume must re-seed it, not try to resume nothing. - if (history.length > 0) this.#markBackingFresh(true); + // prompt, resume must re-seed it, not try to resume nothing — unless the + // backend already holds the conversation natively. + if (history.length > 0) this.#markBackingFresh(!this.#nativeBackingReady); if (note) { const msg = this.#makeMessage("info", note, SYSTEM_IDENTITY, undefined, undefined, { event: "fork.workdir", @@ -6393,8 +6470,9 @@ export class Session { this.#justRotated = false; if (plan_.keptHistory.length > 0) await this.#seedProviderFromHistory(); // Until the next prompt, the backend holds nothing: a restart before then - // must re-seed from the kept history rather than resume an empty backend. - this.#markBackingFresh(true); + // must re-seed from the kept history rather than resume an empty backend — + // unless it was forked natively, and so already holds what was kept. + this.#markBackingFresh(!this.#nativeBackingReady); // What everyone sees: a durable marker hides the taken-back rows (on // replay, restart and paging alike), then every client gets a fresh view. @@ -6444,6 +6522,7 @@ export class Session { this.#backingFresh = true; this.#provider.setHasQueried(false); if (this.#accumulator.history.length > 0) await this.#seedProviderFromHistory(); + if (this.#nativeBackingReady) this.#markBackingFresh(false); } #auditRewind(sender: AuthContext, turnId: string, plan: RewindOutcome, outcome: string, force?: boolean): void { diff --git a/src/tests/fixtures/fake-codex.ts b/src/tests/fixtures/fake-codex.ts index 5f946b6d..fcc7f206 100644 --- a/src/tests/fixtures/fake-codex.ts +++ b/src/tests/fixtures/fake-codex.ts @@ -12,6 +12,7 @@ * "echo-policy"→ agentMessage with the approval/sandbox policies received * "echo-mcp" → agentMessage with the `-c mcp_servers.*` args + token env * "echo-prompt"→ agentMessage containing the full received prompt + * "echo-native"→ agentMessage with the thread/fork + thread/inject_items calls received (#387) * default → two agentMessage deltas + completed message * * Every turn ends with turn/completed carrying usage. @@ -36,6 +37,8 @@ const pendingServerReqs = new Map) => v * the "echo-policy" prompt so the provider's wire params can be asserted. */ let lastThreadPolicies: Record = {}; let lastTurnPolicies: Record = {}; +/** thread/fork and thread/inject_items calls, in order — echoed by "echo-native" (#387). */ +const nativeCalls: Array<{ method: string; params: Record }> = []; function serverRequest(method: string, params: Record): Promise> { const id = nextServerReqId++; @@ -316,6 +319,8 @@ async function runTurn(threadId: string, prompt: string): Promise { const mcpArgs = process.argv.filter((a) => a.includes("mcp_servers")); const text = JSON.stringify({ mcpArgs, token: process.env.CODEOID_MEMORY_TOKEN ?? null }); send({ method: "item/completed", params: { item: { id: "m1", type: "agentMessage", text } } }); + } else if (prompt.includes("echo-native")) { + send({ method: "item/completed", params: { item: { id: "m1", type: "agentMessage", text: JSON.stringify(nativeCalls) } } }); } else if (prompt.includes("echo-prompt")) { send({ method: "item/completed", params: { item: { id: "m1", type: "agentMessage", text: `PROMPT:${prompt}` } } }); } else { @@ -383,6 +388,21 @@ async function handle(frame: Record): Promise { send({ method: "thread/started", params: { thread: { id: "codex-thread-1" } } }); break; } + case "thread/fork": + nativeCalls.push({ method, params }); + // A thread that no longer exists on this machine. + if (params.threadId === "gone-thread") return void send({ id, error: { code: -32600, message: "no rollout found for thread gone-thread" } }); + send({ id, result: { thread: { id: "codex-fork-1" } } }); + break; + case "thread/inject_items": { + nativeCalls.push({ method, params }); + const items = params.items as Array<{ type?: string }> | undefined; + if (!Array.isArray(items) || items.some((i) => typeof i?.type !== "string")) { + return void send({ id, error: { code: -32602, message: "invalid items" } }); + } + send({ id, result: {} }); + break; + } case "thread/resume": send({ id, result: { thread: { id: String(params.threadId ?? "codex-thread-1") } } }); break; diff --git a/src/tests/native-continuation.test.ts b/src/tests/native-continuation.test.ts new file mode 100644 index 00000000..79461844 --- /dev/null +++ b/src/tests/native-continuation.test.ts @@ -0,0 +1,502 @@ +/** + * Native continuation (#387): forks, rewinds and backend switches hand the + * conversation to Claude and Codex in their OWN form — a fork of the + * backend's conversation at the turn, or native history codeoid builds — + * with the transcript seed as the fallback. + * + * The provider tests here run offline (a scripted SDK seam for Claude, the + * fake codex app-server for Codex). The behaviours they encode were verified + * against the real CLIs in the #387 spike; the live end-to-end run is in the + * PR's Verification section. + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CodeoidConfig } from "../config.js"; +import { type CanonicalTurn, CanonicalHistoryAccumulator, fitTurnsToBudget, canonicalTurnChars } from "../daemon/providers/canonical.js"; +import { ClaudeProvider, translateSDKMessage, type ClaudeNativeSdk, type TranslateState } from "../daemon/providers/claude/index.js"; +import { claudeProjectDirName, toClaudeSessionEntries, type ClaudeSessionEntry } from "../daemon/providers/claude/native-history.js"; +import { CodexProvider } from "../daemon/providers/codex/index.js"; +import { toResponsesItems } from "../daemon/providers/codex/native-history.js"; +import type { NormalizedTurnResult, ProviderEvent, TurnOpts, TurnRun } from "../daemon/providers/interface.js"; +import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; +import { ProviderRegistry } from "../daemon/providers/registry.js"; +import { SessionManager } from "../daemon/session-manager.js"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import { ALL_SCOPES } from "../protocol/scopes.js"; +import type { AuthContext, SessionInfo, SessionMessage } from "../protocol/types.js"; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const user = (content: string): CanonicalTurn => ({ role: "user", content }); +const assistant = (content: string, over: Partial> = {}): CanonicalTurn => ({ + role: "assistant", + content, + providerId: "claude", + model: "claude-test", + ...over, +}); +const tool = (name: string, input: Record, output: string, over: { originalName?: string; success?: boolean } = {}) => ({ + id: `t-${name}`, + name, + input, + output, + success: over.success ?? true, + ...(over.originalName ? { originalName: over.originalName } : {}), +}); + +// ── Converters ─────────────────────────────────────────────────────────────── + +describe("canonical history as a Claude session", () => { + const history: CanonicalTurn[] = [ + user("What's in config.yml?"), + assistant("It sets the port to 7400.", { + toolCalls: [tool("read_file", { file_path: "config.yml" }, "port: 7400", { originalName: "Read" })], + }), + user("And from codex?"), + assistant("Codex ran ls.", { providerId: "codex", toolCalls: [tool("run_shell", { command: ["ls"] }, "a.txt", { originalName: "shell" })] }), + ]; + + it("is one chained conversation, alternating, with every tool_use answered by its tool_result", () => { + const { entries } = toClaudeSessionEntries(history, { sessionId: "s1", cwd: "/w", maxChars: 1e6 }); + // Chained oldest first: each entry's parent is the one before it. + entries.forEach((e, i) => expect(e.parentUuid).toBe(i === 0 ? null : entries[i - 1]!.uuid)); + expect(new Set(entries.map((e) => e.uuid)).size).toBe(entries.length); + expect(entries.every((e) => e.sessionId === "s1" && e.cwd === "/w" && !e.isSidechain)).toBe(true); + // user → tool_use → tool_result → text → user → tool_use → tool_result → text + expect(entries.map((e) => e.type)).toEqual(["user", "assistant", "user", "assistant", "user", "assistant", "user", "assistant"]); + const content = (e: ClaudeSessionEntry) => (e.message as { content: unknown }).content as Array>; + const use = content(entries[1]!)[0]!; + const result = content(entries[2]!)[0]!; + expect(use.type).toBe("tool_use"); + expect(String(use.id)).toMatch(/^toolu_[0-9a-f]{24}$/); + expect(result).toMatchObject({ type: "tool_result", tool_use_id: use.id, content: "port: 7400" }); + expect(content(entries[3]!)[0]).toEqual({ type: "text", text: "It sets the port to 7400." }); + }); + + it("names a tool by Claude's own name when Claude ran it, by the neutral name when another backend did", () => { + const { entries } = toClaudeSessionEntries(history, { sessionId: "s1", cwd: "/w", maxChars: 1e6 }); + const names = entries + .filter((e) => e.type === "assistant") + .flatMap((e) => ((e.message as { content: Array<{ type: string; name?: string }> }).content).filter((b) => b.type === "tool_use").map((b) => b.name)); + expect(names).toEqual(["Read", "run_shell"]); + }); + + it("merges user turns that got no reply, closes an unanswered last turn, marks failed tools, and makes names API-safe", () => { + const { entries } = toClaudeSessionEntries( + [user("first"), user("second"), assistant("", { toolCalls: [tool("mcp.server/do it", {}, "boom", { success: false })] }), user("third")], + { sessionId: "s", cwd: "/w", maxChars: 1e6 }, + ); + expect(entries.map((e) => e.type)).toEqual(["user", "assistant", "user", "assistant", "user", "assistant"]); + expect((entries[0]!.message as { content: string }).content).toBe("first\n\nsecond"); + const use = ((entries[1]!.message as { content: Array<{ name: string }> }).content)[0]!; + expect(use.name).toBe("mcp_server_do_it"); + expect(((entries[2]!.message as { content: Array<{ is_error?: boolean }> }).content)[0]!.is_error).toBe(true); + expect(((entries.at(-1)!.message as { content: Array<{ text: string }> }).content)[0]!.text).toContain("ended before a reply"); + }); + + it("drops the oldest turns past the budget and says so first", () => { + const long = Array.from({ length: 10 }, (_, i) => [user(`q${i} ${"x".repeat(500)}`), assistant(`a${i} ${"y".repeat(500)}`)]).flat(); + const { entries, truncation } = toClaudeSessionEntries(long, { sessionId: "s", cwd: "/w", maxChars: 3_000 }); + expect(truncation.omittedTurns).toBeGreaterThan(0); + expect(truncation.keptTurns + truncation.omittedTurns).toBe(20); + expect((entries[0]!.message as { content: string }).content).toContain(`${truncation.omittedTurns} earlier turn(s)`); + expect(entries[0]!.type).toBe("user"); + }); + + it("files the session where Claude Code would: every non-alphanumeric becomes '-'", () => { + expect(claudeProjectDirName("/tmp/spikeB-zI29Li")).toBe("-tmp-spikeB-zI29Li"); + const deep = `/${"a".repeat(300)}`; + expect(claudeProjectDirName(deep).length).toBeLessThanOrEqual(200); + }); +}); + +describe("canonical history as Codex items", () => { + it("pairs each call with its output, names tools honestly, and marks failures", () => { + const { items } = toResponsesItems( + [ + user("list it"), + assistant("done", { providerId: "codex", toolCalls: [tool("run_shell", { command: ["ls"] }, "a.txt", { originalName: "shell" })] }), + assistant("read", { toolCalls: [tool("read_file", { file_path: "x" }, "denied", { originalName: "Read", success: false })] }), + ], + { maxChars: 1e6 }, + ); + expect(items.map((i) => i.type)).toEqual(["message", "function_call", "function_call_output", "message", "function_call", "function_call_output", "message"]); + const calls = items.filter((i) => i.type === "function_call") as Array<{ name: string; call_id: string; arguments: string }>; + expect(calls.map((c) => c.name)).toEqual(["shell", "read_file"]); + expect(JSON.parse(calls[0]!.arguments)).toEqual({ command: ["ls"] }); + const outs = items.filter((i) => i.type === "function_call_output") as Array<{ call_id: string; output: string }>; + expect(outs.map((o) => o.call_id)).toEqual(calls.map((c) => c.call_id)); + expect(outs[1]!.output).toBe("Error: denied"); + }); +}); + +describe("fitting history to a budget", () => { + it("keeps the newest whole turns, and cuts the newest down when it alone is too big", () => { + const h = [user(`old ${"o".repeat(4_000)}`), assistant("big", { toolCalls: [tool("read_file", {}, "z".repeat(50_000))] })]; + const { turns, truncation } = fitTurnsToBudget(h, 5_000, canonicalTurnChars); + expect(truncation).toMatchObject({ omittedTurns: 1, keptTurns: 1, newestTurnSliced: true }); + expect(canonicalTurnChars(turns[0]!)).toBeLessThan(6_000); + expect((turns[0] as { toolCalls: Array<{ output: string }> }).toolCalls[0]!.output).toContain("truncated to fit"); + }); +}); + +// ── Where a turn ends, natively ────────────────────────────────────────────── + +describe("Claude records where each turn ends in its own session", () => { + it("is the last main-conversation entry — not a subagent's, not a replayed one", () => { + const events: ProviderEvent[] = []; + const state: TranslateState = { lastLocalCommandStderr: null, primaryModel: "claude-test" }; + const feed = (m: Record) => translateSDKMessage(m as never, (e) => events.push(e), "claude", state); + feed({ type: "user", uuid: "old", isReplay: true, parent_tool_use_id: null, message: { content: "replayed" } }); + feed({ type: "assistant", uuid: "a1", parent_tool_use_id: null, message: { content: [{ type: "text", text: "hi" }] } }); + feed({ type: "user", uuid: "u1", parent_tool_use_id: null, message: { content: [] } }); + feed({ type: "assistant", uuid: "a2", parent_tool_use_id: null, message: { content: [{ type: "text", text: "done" }] } }); + feed({ type: "assistant", uuid: "sub", parent_tool_use_id: "toolu_x", message: { content: [{ type: "text", text: "sub" }] } }); + feed({ type: "result", subtype: "success", session_id: "backing-1", num_turns: 2, usage: {} }); + const done = events.find((e) => e.type === "turn_done") as { result: NormalizedTurnResult }; + expect(done.result.nativeRef).toEqual({ backingId: "backing-1", ref: "a2" }); + // ...and the next turn starts clean. + feed({ type: "result", subtype: "success", session_id: "backing-1", num_turns: 1, usage: {} }); + expect((events.filter((e) => e.type === "turn_done")[1] as { result: NormalizedTurnResult }).result.nativeRef).toBeUndefined(); + }); + + it("is stamped on the canonical turn, so forks and restarts can find it", () => { + const acc = new CanonicalHistoryAccumulator(); + acc.pushUserTurn("go", "turn-1"); + acc.handleEvent({ type: "text_done", content: "ok" } as ProviderEvent); + acc.handleEvent({ + type: "turn_done", + result: { providerId: "claude", model: "m", inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 0, nativeRef: { backingId: "b", ref: "r" } }, + }); + expect(acc.history.at(-1)).toMatchObject({ role: "assistant", native: { backingId: "b", ref: "r" } }); + }); +}); + +// ── Claude: continueNatively ───────────────────────────────────────────────── + +describe("ClaudeProvider.continueNatively", () => { + let configDir: string; + const prevConfig = process.env.CLAUDE_CONFIG_DIR; + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), "claude-cfg-")); + process.env.CLAUDE_CONFIG_DIR = configDir; + }); + afterEach(() => { + if (prevConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = prevConfig; + rmSync(configDir, { recursive: true, force: true }); + }); + + /** A seam that reads back what was written, like the SDK's own reader. */ + const sdk = (over: Partial = {}): ClaudeNativeSdk & { calls: string[] } => { + const calls: string[] = []; + return { + calls, + forkSession: async (id, opts) => { + calls.push(`fork ${id} ${opts.upToMessageId}`); + return { sessionId: "forked-1" }; + }, + getSessionMessages: async (id, opts) => { + calls.push(`read ${id} ${opts.dir}`); + const file = join(configDir, "projects", claudeProjectDirName(opts.dir), `${id}.jsonl`); + return readFileSync(file, "utf8").trim().split("\n").map((l) => JSON.parse(l) as { uuid: string }); + }, + ...over, + }; + }; + const provider = (nativeSdk: ClaudeNativeSdk) => + new ClaudeProvider({ sessionId: "sess", initialBackingId: "fresh-backing", workspaceId: "ws", store: {} as never, nativeSdk }); + const history = [user("hello"), assistant("hi there")]; + + it("same backend: forks Claude's own session at the turn and resumes it", async () => { + const s = sdk(); + const p = provider(s); + const r = await p.continueNatively({ history, forkFrom: { backingId: "parent-b", ref: "uuid-9" }, maxChars: 1e6, workdir: "/w" }); + expect(r).toEqual({ via: "fork", backingId: "forked-1" }); + expect(s.calls).toEqual(["fork parent-b uuid-9"]); + expect(p.backingSessionId).toBe("forked-1"); + expect(p.hasQueried).toBe(true); // resume, not create + }); + + it("when the fork source is gone, builds the session from history instead — and verifies it with the SDK's reader", async () => { + const s = sdk({ forkSession: async () => Promise.reject(new Error("Session parent-b not found")) }); + const p = provider(s); + const workdir = join(configDir, "work"); + mkdirSync(workdir); + const r = await p.continueNatively({ history, forkFrom: { backingId: "parent-b", ref: "uuid-9" }, maxChars: 1e6, workdir }); + expect(r?.via).toBe("history"); + const file = join(configDir, "projects", claudeProjectDirName(workdir), `${r!.backingId}.jsonl`); + expect(existsSync(file)).toBe(true); + const lines = readFileSync(file, "utf8").trim().split("\n").map((l) => JSON.parse(l)); + expect(lines.map((l) => l.type)).toEqual(["user", "assistant"]); + expect(p.backingSessionId).toBe(r?.backingId as string); + expect(p.hasQueried).toBe(true); + }); + + it("a session the SDK can't read back exactly is removed, and the transcript is used instead", async () => { + const s = sdk({ getSessionMessages: async () => [{ uuid: "only-one" }] }); + const p = provider(s); + const workdir = join(configDir, "work2"); + mkdirSync(workdir); + expect(await p.continueNatively({ history, maxChars: 1e6, workdir })).toBeNull(); + const dir = join(configDir, "projects", claudeProjectDirName(workdir)); + expect(existsSync(dir) ? require("node:fs").readdirSync(dir) : []).toEqual([]); + expect(p.backingSessionId).toBe("fresh-backing"); + expect(p.hasQueried).toBe(false); + }); +}); + +// ── Codex: continueNatively over the fake app-server ───────────────────────── + +describe("CodexProvider.continueNatively", () => { + const FIXTURE = join(import.meta.dir, "fixtures", "fake-codex.ts"); + const saved: Array<[string, string]> = []; + const codex = () => + new CodexProvider({ + sessionId: "sess-1", + initialBackingId: "fresh-uuid", + command: process.execPath, + argsPrefix: [FIXTURE], + store: { setClaudeCodeSessionId: (s: string, id: string) => saved.push([s, id]) } as never, + }); + const opts = (userMessage: string): TurnOpts => ({ history: [], userMessage, workdir: "/tmp", canUseTool: async () => ({ behavior: "allow" as const }) }); + async function collect(run: TurnRun): Promise { + const events: ProviderEvent[] = []; + for await (const e of run.events) { + events.push(e); + if (e.type === "turn_done" || e.type === "error") break; + } + return events; + } + const text = (events: ProviderEvent[]) => events.filter((e) => e.type === "text_done").map((e) => (e as { content: string }).content).join(""); + beforeEach(() => { + saved.length = 0; + }); + + it("same backend: forks the thread through the turn, adopts and saves it, and anchors its turns", async () => { + const p = codex(); + const r = await p.continueNatively({ history: [user("x"), assistant("y", { providerId: "codex" })], forkFrom: { backingId: "codex-thread-9", ref: "turn-7" }, maxChars: 1e6, workdir: "/tmp" }); + expect(r).toEqual({ via: "fork" }); + const events = await collect(p.runTurn(opts("echo-native"))); + const calls = JSON.parse(text(events)) as Array<{ method: string; params: Record }>; + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ method: "thread/fork", params: { threadId: "codex-thread-9", lastTurnId: "turn-7", cwd: "/tmp" } }); + expect(p.backingSessionId).toBe("codex-fork-1"); + expect(saved).toEqual([["sess-1", "codex-fork-1"]]); + const done = events.find((e) => e.type === "turn_done") as { result: NormalizedTurnResult }; + expect(done.result.nativeRef).toEqual({ backingId: "codex-fork-1", ref: "turn-1" }); + await p.teardown(); + }); + + it("another backend's history goes in as native items on a fresh thread", async () => { + const p = codex(); + const r = await p.continueNatively({ + history: [user("read it"), assistant("it says hi", { toolCalls: [tool("read_file", { file_path: "a" }, "hi", { originalName: "Read" })] })], + maxChars: 1e6, + workdir: "/tmp", + }); + expect(r).toEqual({ via: "history" }); + const calls = JSON.parse(text(await collect(p.runTurn(opts("echo-native"))))) as Array<{ method: string; params: { threadId: string; items: Array<{ type: string; name?: string }> } }>; + expect(calls.map((c) => c.method)).toEqual(["thread/inject_items"]); + expect(calls[0]!.params.threadId).toBe("codex-thread-1"); + expect(calls[0]!.params.items.map((i) => i.type)).toEqual(["message", "function_call", "function_call_output", "message"]); + expect(calls[0]!.params.items[1]!.name).toBe("read_file"); + expect(saved).toEqual([["sess-1", "codex-thread-1"]]); + await p.teardown(); + }); + + it("if the native fork fails, the turn says so and carries the transcript instead", async () => { + const p = codex(); + await p.continueNatively({ history: [user("remember PELICAN"), assistant("noted", { providerId: "codex" })], forkFrom: { backingId: "gone-thread", ref: "t" }, maxChars: 1e6, workdir: "/tmp" }); + const events = await collect(p.runTurn(opts("echo-prompt"))); + const notice = events.find((e) => e.type === "custom_message") as { content: string; metadata: { event: string } } | undefined; + expect(notice?.metadata.event).toBe("history.native_fallback"); + expect(notice?.content).toContain("text transcript"); + expect(text(events)).toContain(""); + expect(text(events)).toContain("remember PELICAN"); + expect(p.backingSessionId).toBe("codex-thread-1"); + await p.teardown(); + }); +}); + +// ── The session picks the path ─────────────────────────────────────────────── + +const AUTH: AuthContext = { sub: "user:native", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc-n", projectId: "proj-n" }; +const CLIENT = { id: "cli", auth: AUTH, send: () => {} }; +const say = (text: string, providerId = "claude", nativeRef?: { backingId: string; ref: string }): ProviderEvent[] => [ + { type: "text_done", content: text } as ProviderEvent, + { + type: "turn_done", + result: { providerId, model: "mock", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1, ...(nativeRef ? { nativeRef } : {}) }, + } as ProviderEvent, +]; + +describe("Session: native first, transcript as the fallback", () => { + let tmp: string; + let store: Store; + let transcript: TranscriptStore; + let managers: SessionManager[]; + let made: MockSessionProvider[]; + /** How each new provider answers continueNatively; undefined = it can't. */ + let nativeAnswer: ((req: Parameters>[0], p: MockSessionProvider) => Awaited>>) | undefined; + + const mkConfig = (session: Record = {}): CodeoidConfig => + ({ + daemonUrl: "ws://127.0.0.1:7400", + dbPath: join(tmp, "codeoid.db"), + transcriptDir: join(tmp, "transcripts"), + auth: { baseUrl: "http://localhost:8899" }, + zeroidUrl: "http://localhost:8899", + workspaceIndex: { enabled: false, episodeThreshold: 5, timeThresholdMs: 60_000, debounceMs: 15_000 }, + compress: { enabled: false, excludeCommands: [], excludePatterns: [], compressPipes: false, minBytes: 1024 }, + labeling: {}, + telemetry: { osc8: "auto" }, + autoRotate: { enabled: false, warnPct: 0.6, rotatePct: 0.8, hardRotatePct: 0.9, minTurnsBeforeRotate: 3, strategy: "task-anchor" }, + session, + conductor: { enabled: false, name: "conductor", provider: "claude" }, + }) as CodeoidConfig; + + function manager(session: Record = {}): SessionManager { + const factory = (id: string) => ({ + id, + displayName: id, + create: () => { + const p = new MockSessionProvider(id, [say("a1", id, { backingId: `${id}-b1`, ref: "end-1" }), say("a2", id, { backingId: `${id}-b1`, ref: "end-2" }), say("a3", id)]); + // Like a real warm backend (claude, codex, pi), report the transcript it stashed. + p.seedResultOverride = { text: "…", totalTurns: 0, keptTurns: 0, omittedTurns: 0, newestTurnSliced: false }; + if (nativeAnswer) { + const answer = nativeAnswer; + p.scriptNative((req) => answer(req, p)); + } + made.push(p); + return p; + }, + }); + const registry = new ProviderRegistry("claude"); + registry.register(factory("claude")); + registry.register(factory("codex")); + const m = new SessionManager(store, transcript, undefined, undefined, undefined, { config: mkConfig(session), providers: registry }); + managers.push(m); + return m; + } + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "codeoid-native-")); + store = new Store(join(tmp, "codeoid.db")); + transcript = new TranscriptStore(join(tmp, "transcripts")); + managers = []; + made = []; + nativeAnswer = undefined; + }); + afterEach(async () => { + for (const m of managers) await m.drain(2_000).catch(() => {}); + await transcript.flush(); + rmSync(tmp, { recursive: true, force: true }); + }); + + async function newSession(m: SessionManager): Promise { + const r = await m.handle({ type: "session.create", id: "c", name: `n-${Math.random()}`, workdir: tmp }, AUTH, CLIENT); + if (r.type !== "response.ok") throw new Error(JSON.stringify(r)); + return (r.data as { id: string }).id; + } + async function send(m: SessionManager, id: string, text: string): Promise { + const s = m._sessionForTest(id)!; + await s.send(text, AUTH); + for (let i = 0; i < 300 && s.status !== "idle"; i++) await Bun.sleep(10); + } + async function fork(m: SessionManager, id: string, extra: Record = {}): Promise { + const r = await m.handle({ type: "session.fork", id: "f", sessionId: id, isolate: false, ...extra } as never, AUTH, CLIENT); + if (r.type !== "response.ok") throw new Error(JSON.stringify(r)); + return r.data as SessionInfo; + } + const notes = (m: SessionManager, id: string) => { + const got: SessionMessage[] = []; + m._sessionForTest(id)!.attach({ id: `w-${Math.random()}`, auth: AUTH, send: (msg) => { + if (msg.type === "scrollback.replay") got.push(...(msg as { messages: SessionMessage[] }).messages); + } }); + return got.filter((x) => x.role === "info").map((x) => x.content); + }; + + it("a same-backend fork forks natively from the parent's last turn, and the backend counts as holding it", async () => { + nativeAnswer = (req) => ({ via: req.forkFrom ? "fork" : "history", backingId: "native-backing" }); + const m = manager(); + const id = await newSession(m); + await send(m, id, "one"); + await send(m, id, "two"); + const info = await fork(m, id); + const req = made.at(-1)!.nativeRequests[0]!; + expect(req.forkFrom).toEqual({ backingId: "claude-b1", ref: "end-2" }); + expect(req.history.map((t) => t.content)).toEqual(["one", "a1", "two", "a2"]); + expect(store.getClaudeCodeSessionId(info.id)).toBe("native-backing"); + // It holds the conversation already, so a restart resumes it rather than re-seeding. + await Bun.sleep(20); + expect((JSON.parse(readFileSync(transcript.metaPath(info.id), "utf8")) as { backingFresh?: boolean }).backingFresh).toBeUndefined(); + expect(notes(m, info.id).some((n) => n.includes("continues its own conversation"))).toBe(true); + // No transcript was stashed alongside. + expect(made.at(-1)!.seededHistory).toBeNull(); + }); + + it("a fork onto another backend asks for native history — no fork point to borrow", async () => { + nativeAnswer = () => ({ via: "history" }); + const m = manager(); + const id = await newSession(m); + await send(m, id, "one"); + const info = await fork(m, id, { providerId: "codex" }); + const req = made.at(-1)!.nativeRequests[0]!; + expect(req.forkFrom).toBeUndefined(); + expect(notes(m, info.id).some((n) => n.includes("given to codex as its own history"))).toBe(true); + }); + + it("falls back to the transcript when the backend declines or throws, and says which way it went", async () => { + for (const answer of [() => null, () => Promise.reject(new Error("boom"))]) { + nativeAnswer = answer as never; + const m = manager(); + const id = await newSession(m); + await send(m, id, "one"); + const info = await fork(m, id); + expect(made.at(-1)!.seededHistory?.map((t) => t.content)).toEqual(["one", "a1"]); + expect(notes(m, info.id).some((n) => n.includes("carried as a text transcript"))).toBe(true); + } + }); + + it("session.nativeContinuation: false keeps everything on the transcript", async () => { + nativeAnswer = () => ({ via: "fork", backingId: "nope" }); + const m = manager({ nativeContinuation: false }); + const id = await newSession(m); + await send(m, id, "one"); + await fork(m, id); + expect(made.at(-1)!.nativeRequests).toEqual([]); + expect(made.at(-1)!.seededHistory).not.toBeNull(); + }); + + it("a backend switch tries native history, and a backend that builds lazily stays 'fresh' for a restart", async () => { + nativeAnswer = () => ({ via: "history" }); // no backingId yet, like Codex + const m = manager(); + const id = await newSession(m); + await send(m, id, "one"); + const r = await m.handle({ type: "session.set_provider", id: "p", sessionId: id, providerId: "codex" } as never, AUTH, CLIENT); + expect(r.type).toBe("response.ok"); + expect(made.at(-1)!.nativeRequests).toHaveLength(1); + await Bun.sleep(20); + const meta = JSON.parse(readFileSync(transcript.metaPath(id), "utf8")) as { backingFresh?: boolean }; + expect(meta.backingFresh).toBe(true); + }); + + it("a rewind on the same backend forks natively at the kept turn", async () => { + nativeAnswer = (req) => (req.forkFrom ? { via: "fork", backingId: "rewound-backing" } : null); + const m = manager(); + const id = await newSession(m); + await send(m, id, "one"); + await send(m, id, "two"); + const s = m._sessionForTest(id)!; + const second = s.turnIndex[1]!.turnId; + await s.rewind(second, { restoreFiles: false }, AUTH); + const req = made.at(-1)!.nativeRequests.at(-1)!; + expect(req.forkFrom).toEqual({ backingId: "claude-b1", ref: "end-1" }); + expect(store.getClaudeCodeSessionId(id)).toBe("rewound-backing"); + }); +}); From 96c7350bd53d135d3c7b1a7ee5995c126c960c80 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 17 Oct 2026 01:35:45 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20native=20continuation=20round=201=20?= =?UTF-8?q?=E2=80=94=20never=20fork=20past=20a=20redaction,=20keep=20resto?= =?UTF-8?q?red=20turns=20quoted,=20pin=20and=20validate=20fork=20points?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/FEATURES.md | 2 + src/daemon/canonical-restore.ts | 1 + src/daemon/providers/canonical.ts | 16 ++++- src/daemon/providers/claude/index.ts | 16 ++++- src/daemon/providers/claude/native-history.ts | 13 +++- src/daemon/providers/codex/index.ts | 4 +- src/daemon/providers/interface.ts | 7 +++ src/daemon/session.ts | 24 +++++++- src/tests/native-continuation.test.ts | 59 ++++++++++++++++++- 9 files changed, 129 insertions(+), 13 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index bb829e97..0bff56d4 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -31,6 +31,8 @@ The conversation is handed over in the backend's own form wherever the backend a Reasoning never crosses vendors (Claude's is signed, OpenAI's encrypted), and history that doesn't fit the target's window loses its oldest turns, with a notice. The session says which way it went (`↪ … continues its own conversation`, `↪ … was given … as its own history`, or `↪ … carried as a text transcript`), and if a native path fails it falls back to the transcript and says so. +Two cases always use codeoid's own record rather than the backend's: history that a `tool_result` [hook](hooks.md) redacted (a native fork would copy the backend's unredacted log), and turns rebuilt from a displayed transcript such as an imported session (they aren't the model's own words, so they stay quoted). +Like every Claude session codeoid runs, a session it writes or forks for Claude lives under `CLAUDE_CONFIG_DIR` (default `~/.claude/projects/`), owner-only, and is subject to Claude Code's own `cleanupPeriodDays`. `session.nativeContinuation: false` (or `CODEOID_NATIVE_CONTINUATION=false`) keeps everything on the transcript. Memory, identity, attachments, the multi-frontend clients, and device handoff are all provider-agnostic. A few features lean on Claude Code internals and so apply only to the `claude` harness: backing-session [auto-rotation](#context-reduction-stack), Claude Code slash-command [pass-through](#terminal-client), and `.claude/commands` workspace commands. diff --git a/src/daemon/canonical-restore.ts b/src/daemon/canonical-restore.ts index bd9328eb..11cf4a4c 100644 --- a/src/daemon/canonical-restore.ts +++ b/src/daemon/canonical-restore.ts @@ -65,6 +65,7 @@ export function canonicalFromTranscript(messages: readonly DaemonMessage[], prov ...(thinking ? { thinking } : {}), providerId, model: "unknown", + restored: true, }); } text = []; diff --git a/src/daemon/providers/canonical.ts b/src/daemon/providers/canonical.ts index 586bb45d..b787c72a 100644 --- a/src/daemon/providers/canonical.ts +++ b/src/daemon/providers/canonical.ts @@ -43,6 +43,12 @@ export interface CanonicalToolCall { success: boolean; /** Original provider name before normalization ("Read", "Bash"). */ originalName?: string; + /** + * A tool_result hook replaced the output (redaction): `output` is what + * codeoid keeps, and the backend's own record still holds the original — so + * a native fork, which copies that record, must not be used (#387). + */ + redacted?: boolean; } export type CanonicalTurn = @@ -82,6 +88,13 @@ export type CanonicalTurn = * or rewind to here can continue that conversation natively. */ native?: NativeTurnRef; + /** + * Rebuilt from a displayed transcript (an imported bundle, or a session + * from before the canonical log), not recorded from the backend: its + * text and tool calls aren't trusted to be the model's own, so they are + * never replayed to a backend in the assistant's voice (#387). + */ + restored?: boolean; }; // ── Tool name normalization ─────────────────────────────────────────────────── @@ -357,7 +370,7 @@ export class CanonicalHistoryAccumulator { #currentThinking = ""; #currentTools = new Map< string, // sdkToolUseId - { id: string; name: string; originalName?: string; input: Record; output?: string; success?: boolean } + { id: string; name: string; originalName?: string; input: Record; output?: string; success?: boolean; redacted?: boolean } >(); /** @@ -442,6 +455,7 @@ export class CanonicalHistoryAccumulator { if (partial) { partial.output = limitToolOutput(partial.name, event.output); partial.success = event.success; + if (event.redacted) partial.redacted = true; } break; } diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index 3547b7c3..317436d7 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -115,7 +115,7 @@ export function packPluginsOption(dirs?: readonly string[]): { plugins?: { type: /** The SDK's session-file functions native continuation uses; injectable for tests. */ export interface ClaudeNativeSdk { - forkSession(sessionId: string, opts: { upToMessageId: string }): Promise<{ sessionId: string }>; + forkSession(sessionId: string, opts: { upToMessageId: string; dir?: string }): Promise<{ sessionId: string }>; getSessionMessages(sessionId: string, opts: { dir: string }): Promise>; } @@ -479,7 +479,12 @@ export class ClaudeProvider implements SessionProvider { const tag = `[claude-provider ${this.#init.sessionId.slice(0, 8)}]`; if (req.forkFrom) { try { - const { sessionId } = await sdk.forkSession(req.forkFrom.backingId, { upToMessageId: req.forkFrom.ref }); + // With the source's project dir, the SDK looks there only, never across + // every project on the machine. + const { sessionId } = await sdk.forkSession(req.forkFrom.backingId, { + upToMessageId: req.forkFrom.ref, + ...(req.forkFrom.dir ? { dir: req.forkFrom.dir } : {}), + }); this.#adoptBacking(sessionId); return { via: "fork", backingId: sessionId }; } catch (err) { @@ -682,6 +687,7 @@ export class ClaudeProvider implements SessionProvider { const mcpServers = Object.keys(merged).length > 0 ? merged : undefined; // skillAllowRules computed above (used in the rebuild guard). + this.#translateState.cwd = opts.workdir; this.#query = query({ prompt: this.#inputQueue, options: { @@ -1359,6 +1365,8 @@ export interface TranslateState { * ends in Claude's own session, so a fork can be cut there (#387). */ lastChainUuid?: string | null; + /** The cwd the query runs in — where Claude files the session (#387). */ + cwd?: string; } /** @@ -1537,7 +1545,9 @@ export function translateSDKMessage( ? r.result : undefined), // A zero-turn run left nothing in the session to fork at. - ...(sessionId && chainEnd && !zeroTurn ? { nativeRef: { backingId: sessionId, ref: chainEnd } } : {}), + ...(sessionId && chainEnd && !zeroTurn + ? { nativeRef: { backingId: sessionId, ref: chainEnd, ...(state.cwd ? { dir: state.cwd } : {}) } } + : {}), }; emit({ type: "turn_done", result: normalized }); // Stay warm — keep the queue open for the next turn. diff --git a/src/daemon/providers/claude/native-history.ts b/src/daemon/providers/claude/native-history.ts index 8182a7cf..5dddb830 100644 --- a/src/daemon/providers/claude/native-history.ts +++ b/src/daemon/providers/claude/native-history.ts @@ -152,11 +152,18 @@ export async function writeClaudeSession( opts: { sessionId: string; cwd: string; configDir?: string }, ): Promise { const dir = join(opts.configDir ?? claudeConfigDir(), "projects", claudeProjectDirName(opts.cwd)); - mkdirSync(dir, { recursive: true }); + // Owner-only when we create it: project dir names reveal workdir paths. + mkdirSync(dir, { recursive: true, mode: 0o700 }); const path = join(dir, `${opts.sessionId}.jsonl`); const tmp = `${path}.${process.pid}.tmp`; - await writeFile(tmp, `${entries.map((e) => JSON.stringify(e)).join("\n")}\n`, { mode: 0o600 }); - await rename(tmp, path); + try { + // `wx`: never through a file someone placed there. + await writeFile(tmp, `${entries.map((e) => JSON.stringify(e)).join("\n")}\n`, { mode: 0o600, flag: "wx" }); + await rename(tmp, path); + } catch (err) { + await rm(tmp, { force: true }).catch(() => {}); + throw err; + } return path; } diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index d7539ac0..11af78c2 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -438,9 +438,11 @@ export class CodexProvider implements SessionProvider { const why = err instanceof Error ? err.message : String(err); console.error(`[codex-provider ${this.#sessionId.slice(0, 8)}] native ${pending.kind} failed, using the transcript: ${why}`); this.#pendingHistorySeed = pending.fallback; + // The cause stays in the daemon log: backend errors name rollout paths + // and thread ids, and every attached client (watchers too) sees this. this.#push({ type: "custom_message", - content: `⚠️ Codex couldn't take this conversation as its own history (${why.slice(0, 200)}), so it continues from a text transcript of it instead.`, + content: "⚠️ Codex couldn't take this conversation as its own history, so it continues from a text transcript of it instead.", metadata: { event: "history.native_fallback", providerId: this.id }, }); return null; diff --git a/src/daemon/providers/interface.ts b/src/daemon/providers/interface.ts index 9a735e27..185e728f 100644 --- a/src/daemon/providers/interface.ts +++ b/src/daemon/providers/interface.ts @@ -218,6 +218,11 @@ export interface NormalizedTurnResult { export interface NativeTurnRef { backingId: string; ref: string; + /** + * The working directory the backing session ran in, when the backend files + * sessions by it (Claude): a fork looks the source up there and nowhere else. + */ + dir?: string; } /** What a session asks a provider when it hands it an existing conversation. */ @@ -292,6 +297,8 @@ export type ProviderEvent = /** The backend refused this call itself, never asking codeoid's gate * (codex declining an approval it can't show, #369) — audited. */ declinedByBackend?: boolean; + /** A tool_result hook replaced `output`: the backend's own record still holds the original. */ + redacted?: boolean; } | { type: "subagent_start"; agentId: string; agentType: string } | { type: "subagent_stop"; agentId: string } diff --git a/src/daemon/session.ts b/src/daemon/session.ts index beaec759..bcac1fa1 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -3259,8 +3259,20 @@ export class Session { if (this.#config?.session?.nativeContinuation === false) return null; const provider = this.#provider; if (!provider.continueNatively) return null; + // Turns rebuilt from a displayed transcript (an import, a pre-canonical + // session) aren't the model's own words: they stay a quoted transcript, + // never native history in the assistant's voice. + if (history.some((t) => t.role === "assistant" && t.restored)) return null; const last = history.at(-1); - const forkFrom = last?.role === "assistant" && last.providerId === provider.id ? last.native : undefined; + const forkFrom = + last?.role === "assistant" && + last.providerId === provider.id && + isNativeRef(last.native) && + // A fork copies the backend's own record, which still holds what a + // tool_result hook redacted from codeoid's: rebuild from codeoid's instead. + !history.some((t) => t.role === "assistant" && t.toolCalls?.some((tc) => tc.redacted)) + ? last.native + : undefined; let result: NativeContinuationResult | null; try { result = await provider.continueNatively({ history, ...(forkFrom ? { forkFrom } : {}), maxChars, workdir: this.workdir }); @@ -5287,7 +5299,7 @@ export class Session { }); if (patched.updatedOutput !== undefined) output = patched.updatedOutput; } - this.#accumulator.handleEvent(output === event.output ? event : { ...event, output }); + this.#accumulator.handleEvent(output === event.output ? event : { ...event, output, redacted: true }); if (msgId) { const toolMsg = this.#toolCallMessages.get(msgId); if (toolMsg?.tool) { @@ -6889,3 +6901,11 @@ function sanitizeApprovalPatch( // time. The user reviewed the original input; that's what runs. return undefined; } + +/** A recorded native point codeoid will act on: two plain ids, nothing else. */ +function isNativeRef(ref: unknown): ref is NonNullable["native"]> { + if (!ref || typeof ref !== "object") return false; + const r = ref as Record; + const id = (v: unknown) => typeof v === "string" && /^[A-Za-z0-9-]{8,128}$/.test(v); + return id(r.backingId) && id(r.ref) && (r.dir === undefined || (typeof r.dir === "string" && r.dir.startsWith("/"))); +} diff --git a/src/tests/native-continuation.test.ts b/src/tests/native-continuation.test.ts index 79461844..09fb4ca4 100644 --- a/src/tests/native-continuation.test.ts +++ b/src/tests/native-continuation.test.ts @@ -164,6 +164,14 @@ describe("Claude records where each turn ends in its own session", () => { expect((events.filter((e) => e.type === "turn_done")[1] as { result: NormalizedTurnResult }).result.nativeRef).toBeUndefined(); }); + it("names the project dir the session is filed under, so a fork looks only there", () => { + const events: ProviderEvent[] = []; + const state: TranslateState = { lastLocalCommandStderr: null, primaryModel: "m", cwd: "/work/repo" }; + translateSDKMessage({ type: "assistant", uuid: "a1", parent_tool_use_id: null, message: { content: [] } } as never, (e) => events.push(e), "claude", state); + translateSDKMessage({ type: "result", subtype: "success", session_id: "b", num_turns: 1, usage: {} } as never, (e) => events.push(e), "claude", state); + expect((events.find((e) => e.type === "turn_done") as { result: NormalizedTurnResult }).result.nativeRef).toEqual({ backingId: "b", ref: "a1", dir: "/work/repo" }); + }); + it("is stamped on the canonical turn, so forks and restarts can find it", () => { const acc = new CanonicalHistoryAccumulator(); acc.pushUserTurn("go", "turn-1"); @@ -218,6 +226,11 @@ describe("ClaudeProvider.continueNatively", () => { const r = await p.continueNatively({ history, forkFrom: { backingId: "parent-b", ref: "uuid-9" }, maxChars: 1e6, workdir: "/w" }); expect(r).toEqual({ via: "fork", backingId: "forked-1" }); expect(s.calls).toEqual(["fork parent-b uuid-9"]); + // With the source's dir, the lookup is pinned there. + let seen: unknown; + const pinned = provider(sdk({ forkSession: async (_id, o) => ((seen = o), { sessionId: "f2" }) })); + await pinned.continueNatively({ history, forkFrom: { backingId: "parent-b", ref: "uuid-9", dir: "/src/repo" }, maxChars: 1e6, workdir: "/w" }); + expect(seen).toEqual({ upToMessageId: "uuid-9", dir: "/src/repo" }); expect(p.backingSessionId).toBe("forked-1"); expect(p.hasQueried).toBe(true); // resume, not create }); @@ -316,6 +329,8 @@ describe("CodexProvider.continueNatively", () => { const notice = events.find((e) => e.type === "custom_message") as { content: string; metadata: { event: string } } | undefined; expect(notice?.metadata.event).toBe("history.native_fallback"); expect(notice?.content).toContain("text transcript"); + // Backend errors name rollout paths and thread ids: they stay in the daemon log. + expect(notice?.content).not.toContain("gone-thread"); expect(text(events)).toContain(""); expect(text(events)).toContain("remember PELICAN"); expect(p.backingSessionId).toBe("codex-thread-1"); @@ -365,7 +380,7 @@ describe("Session: native first, transcript as the fallback", () => { id, displayName: id, create: () => { - const p = new MockSessionProvider(id, [say("a1", id, { backingId: `${id}-b1`, ref: "end-1" }), say("a2", id, { backingId: `${id}-b1`, ref: "end-2" }), say("a3", id)]); + const p = new MockSessionProvider(id, [say("a1", id, { backingId: `${id}-b1`, ref: "00000000-0000-0000-0000-0000000000e1" }), say("a2", id, { backingId: `${id}-b1`, ref: "00000000-0000-0000-0000-0000000000e2" }), say("a3", id)]); // Like a real warm backend (claude, codex, pi), report the transcript it stashed. p.seedResultOverride = { text: "…", totalTurns: 0, keptTurns: 0, omittedTurns: 0, newestTurnSliced: false }; if (nativeAnswer) { @@ -429,7 +444,7 @@ describe("Session: native first, transcript as the fallback", () => { await send(m, id, "two"); const info = await fork(m, id); const req = made.at(-1)!.nativeRequests[0]!; - expect(req.forkFrom).toEqual({ backingId: "claude-b1", ref: "end-2" }); + expect(req.forkFrom).toEqual({ backingId: "claude-b1", ref: "00000000-0000-0000-0000-0000000000e2" }); expect(req.history.map((t) => t.content)).toEqual(["one", "a1", "two", "a2"]); expect(store.getClaudeCodeSessionId(info.id)).toBe("native-backing"); // It holds the conversation already, so a restart resumes it rather than re-seeding. @@ -440,6 +455,44 @@ describe("Session: native first, transcript as the fallback", () => { expect(made.at(-1)!.seededHistory).toBeNull(); }); + it("never forks natively past a hook's redaction — the backend's record still has the original", async () => { + nativeAnswer = (req) => ({ via: req.forkFrom ? "fork" : "history", backingId: "nb" }); + const m = manager(); + const id = await newSession(m); + await send(m, id, "one"); + const s = m._sessionForTest(id)!; + const h = s.canonicalHistory as CanonicalTurn[]; + const last = h.at(-1) as Extract; + last.toolCalls = [{ id: "t", name: "run_shell", input: {}, output: "[redacted]", success: true, redacted: true }]; + await fork(m, id); + const req = made.at(-1)!.nativeRequests[0]!; + expect(req.forkFrom).toBeUndefined(); // rebuilt from codeoid's redacted record instead + expect(req.history.at(-1)).toMatchObject({ role: "assistant" }); + }); + + it("turns rebuilt from a displayed transcript stay a transcript — never native history in the assistant's voice", async () => { + nativeAnswer = () => ({ via: "history", backingId: "nb" }); + const m = manager(); + const id = await newSession(m); + await send(m, id, "one"); + const last = m._sessionForTest(id)!.canonicalHistory.at(-1) as Extract; + last.restored = true; + await fork(m, id); + expect(made.at(-1)!.nativeRequests).toEqual([]); + expect(made.at(-1)!.seededHistory).not.toBeNull(); + }); + + it("a malformed native point is not acted on", async () => { + nativeAnswer = (req) => ({ via: req.forkFrom ? "fork" : "history", backingId: "nb" }); + const m = manager(); + const id = await newSession(m); + await send(m, id, "one"); + const last = m._sessionForTest(id)!.canonicalHistory.at(-1) as Extract; + last.native = { backingId: "../../etc", ref: "x" }; + await fork(m, id); + expect(made.at(-1)!.nativeRequests[0]!.forkFrom).toBeUndefined(); + }); + it("a fork onto another backend asks for native history — no fork point to borrow", async () => { nativeAnswer = () => ({ via: "history" }); const m = manager(); @@ -496,7 +549,7 @@ describe("Session: native first, transcript as the fallback", () => { const second = s.turnIndex[1]!.turnId; await s.rewind(second, { restoreFiles: false }, AUTH); const req = made.at(-1)!.nativeRequests.at(-1)!; - expect(req.forkFrom).toEqual({ backingId: "claude-b1", ref: "end-1" }); + expect(req.forkFrom).toEqual({ backingId: "claude-b1", ref: "00000000-0000-0000-0000-0000000000e1" }); expect(store.getClaudeCodeSessionId(id)).toBe("rewound-backing"); }); }); From cf6b9d976b43e387c0e86d211d1245fb77631354 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 17 Oct 2026 01:58:14 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20native=20continuation=20round=201=20?= =?UTF-8?q?=E2=80=94=20identity=20on=20native=20forks,=20no=20fork=20into?= =?UTF-8?q?=20an=20overflow,=20recovery=20reseeds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/FEATURES.md | 6 +- src/daemon/providers/claude/index.ts | 17 ++- src/daemon/providers/claude/native-history.ts | 17 +-- src/daemon/providers/codex/index.ts | 16 ++- src/daemon/session-manager.ts | 2 +- src/daemon/session.ts | 52 ++++++++- src/daemon/settings/manifest.ts | 6 + src/tests/native-continuation.test.ts | 107 ++++++++++++++++-- src/tests/turns.test.ts | 4 +- 9 files changed, 199 insertions(+), 28 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 0bff56d4..a6497894 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -27,13 +27,15 @@ The conversation is handed over in the backend's own form wherever the backend a | `claude` | Claude forks its own session at that turn: nothing converted or cut, reasoning included | codeoid writes the conversation as a Claude session, real tool calls and results, 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` | the full history, natively, every turn | the full history, natively, every turn | -| `pi`, `gemini-cli` | a text transcript in the first prompt | a text transcript in the first prompt | +| `pi`, `gemini-cli`, `qwen` | a text transcript in the first prompt | a text transcript in the first prompt | -Reasoning never crosses vendors (Claude's is signed, OpenAI's encrypted), and history that doesn't fit the target's window loses its oldest turns, with a notice. +Reasoning never crosses vendors (Claude's is signed, OpenAI's encrypted). +History codeoid builds that doesn't fit the target's window loses its oldest turns, with a notice; a native fork isn't used when the source was already running near the target's window, nor from a turn that ended in an error, so a fork is the way out of an overflowed session rather than a copy of it. The session says which way it went (`↪ … continues its own conversation`, `↪ … was given … as its own history`, or `↪ … carried as a text transcript`), and if a native path fails it falls back to the transcript and says so. Two cases always use codeoid's own record rather than the backend's: history that a `tool_result` [hook](hooks.md) redacted (a native fork would copy the backend's unredacted log), and turns rebuilt from a displayed transcript such as an imported session (they aren't the model's own words, so they stay quoted). Like every Claude session codeoid runs, a session it writes or forks for Claude lives under `CLAUDE_CONFIG_DIR` (default `~/.claude/projects/`), owner-only, and is subject to Claude Code's own `cleanupPeriodDays`. `session.nativeContinuation: false` (or `CODEOID_NATIVE_CONTINUATION=false`) keeps everything on the transcript. +Native continuation is tried before the `CODEOID_CONTEXT_STRATEGY` seed (`transcript` or `vws`), which still applies to every backend it doesn't cover. Memory, identity, attachments, the multi-frontend clients, and device handoff are all provider-agnostic. A few features lean on Claude Code internals and so apply only to the `claude` harness: backing-session [auto-rotation](#context-reduction-stack), Claude Code slash-command [pass-through](#terminal-client), and `.claude/commands` workspace commands. diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index 317436d7..2020e2a9 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -116,7 +116,7 @@ export function packPluginsOption(dirs?: readonly string[]): { plugins?: { type: /** The SDK's session-file functions native continuation uses; injectable for tests. */ export interface ClaudeNativeSdk { forkSession(sessionId: string, opts: { upToMessageId: string; dir?: string }): Promise<{ sessionId: string }>; - getSessionMessages(sessionId: string, opts: { dir: string }): Promise>; + getSessionMessages(sessionId: string, opts?: { dir?: string }): Promise>; } export interface ClaudeProviderInit { @@ -320,6 +320,8 @@ export class ClaudeProvider implements SessionProvider { this.#pendingHistorySeed = null; this.#claudeCodeSessionId = newBackingId; this.#hasQueried = false; + // A turn anchor belongs to the session it was seen in. + this.#translateState.lastChainUuid = null; this.#backingRecoveryAttempted = false; this.#zeroTurnRecoveryAttempted = false; this.#lastPushedContent = null; @@ -485,6 +487,10 @@ export class ClaudeProvider implements SessionProvider { upToMessageId: req.forkFrom.ref, ...(req.forkFrom.dir ? { dir: req.forkFrom.dir } : {}), }); + // Read it back the way the CLI will look for it (any project dir): + // adopting a session resume can't find would lose the conversation. + const read = await sdk.getSessionMessages(sessionId); + if (read.length === 0) throw new Error(`the forked session ${sessionId.slice(0, 8)} reads back empty`); this.#adoptBacking(sessionId); return { via: "fork", backingId: sessionId }; } catch (err) { @@ -688,6 +694,9 @@ export class ClaudeProvider implements SessionProvider { // skillAllowRules computed above (used in the rebuild guard). this.#translateState.cwd = opts.workdir; + // A rebuilt loop starts a new stream; an anchor from the old one (a turn + // that ended without a result) must not be paired with the next result. + this.#translateState.lastChainUuid = null; this.#query = query({ prompt: this.#inputQueue, options: { @@ -1544,8 +1553,10 @@ export function translateSDKMessage( : r.subtype !== "success" && r.result ? r.result : undefined), - // A zero-turn run left nothing in the session to fork at. - ...(sessionId && chainEnd && !zeroTurn + // Only a turn that completed is a point to fork at: an errored one + // (a context overflow) would carry the failure into the fork, and an + // interrupted one can end mid tool call. + ...(sessionId && chainEnd && !zeroTurn && r.subtype === "success" && !r.is_error ? { nativeRef: { backingId: sessionId, ref: chainEnd, ...(state.cwd ? { dir: state.cwd } : {}) } } : {}), }; diff --git a/src/daemon/providers/claude/native-history.ts b/src/daemon/providers/claude/native-history.ts index 5dddb830..4e71dc0c 100644 --- a/src/daemon/providers/claude/native-history.ts +++ b/src/daemon/providers/claude/native-history.ts @@ -131,18 +131,21 @@ export function claudeConfigDir(env: Record = proces } /** - * The project directory Claude Code files a cwd's sessions under: every - * character outside [a-zA-Z0-9] becomes "-". Resume finds a session in any - * project directory, so this only keeps the file where the CLI would put it. + * The project directory Claude Code files a cwd's sessions under, exactly as + * the SDK names it: every character outside [a-zA-Z0-9] becomes "-", and a + * name over 200 characters is cut to 200 plus "-" and a base-36 hash of the + * path (the SDK's 32-bit string hash). Must match, or the SDK's reader can't + * find the file and every long-path session falls back to the transcript. */ export function claudeProjectDirName(cwd: string): string { const name = cwd.replace(/[^a-zA-Z0-9]/g, "-"); - return name.length <= 200 ? name : `${name.slice(0, 180)}-${djb2(cwd).toString(36)}`; + return name.length <= 200 ? name : `${name.slice(0, 200)}-${Math.abs(stringHash(cwd)).toString(36)}`; } -function djb2(s: string): number { - let h = 5381; - for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; +/** `h = 31·h + c`, wrapped to int32 — the SDK's hash. */ +function stringHash(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; return h; } diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index 11af78c2..ea572a68 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -426,7 +426,13 @@ export class CodexProvider implements SessionProvider { }; try { if (pending.kind === "fork") { - const forked = (await this.#proc.request("thread/fork", { threadId: pending.threadId, lastTurnId: pending.turnId, ...config })) as { thread?: { id?: string } }; + const forked = (await this.#proc.request("thread/fork", { + threadId: pending.threadId, + lastTurnId: pending.turnId, + // Don't stream the whole thread back over stdio; only its id is used. + excludeTurns: true, + ...config, + })) as { thread?: { id?: string } }; if (!forked.thread?.id) throw new Error("thread/fork returned no thread id"); return forked.thread.id; } @@ -716,6 +722,9 @@ export class CodexProvider implements SessionProvider { * quietly started an empty thread.) */ #adoptThread(id: string): void { + // Torn down while the reply was in flight (a backend switch): this thread + // is no longer the session's, and must not overwrite its backing id. + if (!this.#proc) return; this.#threadId = id; if (this.#backingSessionId === id) return; this.#backingSessionId = id; @@ -926,7 +935,10 @@ export class CodexProvider implements SessionProvider { : {}), stopReason: (turn?.status as string | undefined) ?? undefined, // Where this turn ends in the thread, for a native fork there (#387). - ...(this.#threadId && typeof turn?.id === "string" ? { nativeRef: { backingId: this.#threadId, ref: turn.id } } : {}), + // Only a completed turn: a failed one would carry the failure over. + ...(this.#threadId && typeof turn?.id === "string" && turn.status === "completed" + ? { nativeRef: { backingId: this.#threadId, ref: turn.id } } + : {}), }; this.#push({ type: "turn_done", result }); this.#turnQueue?.close(); diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 1288a425..fdf06492 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -3755,7 +3755,7 @@ mcpHub: this.#mcpHub, // what sizes the fork's history to the real window instead of the 200k // floor — forks never carry a model id, so the cache alone can't key it. fork.inheritObservedLimits(parent); - await fork.primeFromFork(history, transcriptRows, sizeHints, workdirNote); + await fork.primeFromFork(history, transcriptRows, sizeHints, workdirNote, { parentContextTokens: parent.lastContextTokens }); // The inherited turns keep their ids, list entries and file snapshots // (#354) — and the snapshots survive the parent being destroyed. // Only turns the fork's history actually contains: the parent may have diff --git a/src/daemon/session.ts b/src/daemon/session.ts index bcac1fa1..7b41bc2e 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -746,6 +746,14 @@ export class Session { * a restart resumes it instead of re-seeding. */ #nativeBackingReady = false; + /** + * Register the agent identity on the next send even though the backend + * already counts as having queried: a native continuation adopts a + * resumable backing session before this session has ever sent. + */ + #identityOnNextSend = false; + /** Context size (tokens) the source of the next fork was last running at — see #continueNatively. */ + #forkSourceContextTokens = 0; /** A rewind is in progress: nothing may wake the agent meanwhile. */ #rewinding = false; /** @@ -2409,6 +2417,12 @@ export class Session { `[codeoid/session ${this.id}] failed to persist recovered backing id: ${e instanceof Error ? e.message : String(e)}`, ); } + // The new session starts empty: hand it the conversation before this + // prompt (natively where the backend can take it) — before, recovery + // silently dropped everything but the prompt. + const history = this.#accumulator.history; + const before = history.at(-1)?.role === "user" ? history.length - 1 : history.length; + if (before > 0) await this.#seedProviderFromHistory(undefined, { upTo: before }); // Do NOT pushUserTurn(content) here — the original send() already // pushed it before runTurn(). Pushing again duplicates the user turn. const recoveryRun = this.#provider.runTurn({ @@ -3189,11 +3203,13 @@ export class Session { async #seedProviderFromHistory( retry?: { historyLength: number; budgetChars: number; attempts: number }, /** Tell the session's clients which way the conversation was carried (fork, switch). */ - opts: { announce?: boolean } = {}, + opts: { announce?: boolean; upTo?: number } = {}, ): Promise { const history = retry ? this.#accumulator.history.slice(0, retry.historyLength) - : this.#accumulator.history; + : opts.upTo !== undefined + ? this.#accumulator.history.slice(0, opts.upTo) + : this.#accumulator.history; this.#pendingSeed = null; this.#nativeBackingReady = false; if (history.length === 0) return false; @@ -3270,7 +3286,10 @@ export class Session { isNativeRef(last.native) && // A fork copies the backend's own record, which still holds what a // tool_result hook redacted from codeoid's: rebuild from codeoid's instead. - !history.some((t) => t.role === "assistant" && t.toolCalls?.some((tc) => tc.redacted)) + !history.some((t) => t.role === "assistant" && t.toolCalls?.some((tc) => tc.redacted)) && + // A fork carries the source's whole context: when that was already near + // the target's window, build history (budgeted, oldest dropped) instead. + this.#forkFits() ? last.native : undefined; let result: NativeContinuationResult | null; @@ -3282,6 +3301,7 @@ export class Session { } if (!result) return null; if (result.backingId) { + this.#identityOnNextSend = true; try { this.#store.setClaudeCodeSessionId(this.id, result.backingId); } catch (err) { @@ -3293,6 +3313,23 @@ export class Session { return result; } + /** + * Whether a native fork's context fits this backend's window, judged from + * the size the source was last running at (a fork's parent, or this session + * before a rewind or restart). Unknown counts as fitting. + */ + #forkFits(): boolean { + const tokens = this.#forkSourceContextTokens || this.#usage.lastTurnInputTokens || 0; + if (tokens <= 0) return true; + const window = targetContextWindow(this.#provider.id, this.#model, this.#knownWindow(this.#provider.id)); + return tokens <= window * 0.85; + } + + /** How full the context was on this session's last turn (tokens) — what a fork of it starts at. */ + get lastContextTokens(): number { + return this.#usage.lastTurnInputTokens ?? 0; + } + /** Say how a fork or switch carried the conversation to this backend. */ #announceContinuation(via: "fork" | "history" | "transcript", turns: number): void { const p = this.#provider.id; @@ -3524,7 +3561,10 @@ export class Session { /** Optional system note to surface in the fork's scrollback (e.g. a * "shares the parent's workdir — no isolation" warning). */ note?: string, + /** The parent's last context size, so a native fork isn't tried when it wouldn't fit. */ + opts: { parentContextTokens?: number } = {}, ): Promise { + this.#forkSourceContextTokens = opts.parentContextTokens ?? 0; this.#accumulator.seed(history); // Replay the parent's transcript into the fork for visibility (reusing // restoreScrollback's frozen-tool reconciliation), but DON'T let its @@ -3550,6 +3590,7 @@ export class Session { }); } await this.#seedProviderFromHistory(undefined, { announce: true }); + this.#forkSourceContextTokens = 0; // the parent's size judged this seed only // Brand-new backend: if the daemon restarts before the fork's first // prompt, resume must re-seed it, not try to resume nothing — unless the // backend already holds the conversation natively. @@ -4563,7 +4604,9 @@ export class Session { async #ensureAgentIdentity(sender: AuthContext): Promise { const im = this.#identityManager; - if (im && !this.#provider.hasQueried && this.#agentIdentity.sub.startsWith("agent:")) { + const firstSend = !this.#provider.hasQueried || this.#identityOnNextSend; + this.#identityOnNextSend = false; + if (im && firstSend && this.#agentIdentity.sub.startsWith("agent:")) { try { // Dispatch-spawned workers get a shape-capped LEAF identity created // under the conductor's lineage; everything else registers the @@ -6480,6 +6523,7 @@ export class Session { console.error(`[codeoid/session ${this.id}] failed to persist rewind backing id: ${err instanceof Error ? err.message : String(err)}`); } this.#justRotated = false; + this.#nativeBackingReady = false; if (plan_.keptHistory.length > 0) await this.#seedProviderFromHistory(); // Until the next prompt, the backend holds nothing: a restart before then // must re-seed from the kept history rather than resume an empty backend — diff --git a/src/daemon/settings/manifest.ts b/src/daemon/settings/manifest.ts index 9a113606..91c03f58 100644 --- a/src/daemon/settings/manifest.ts +++ b/src/daemon/settings/manifest.ts @@ -278,6 +278,12 @@ const memory: SettingsTab = { default: 0.7, advanced: true, }), + cfg("session.nativeContinuation", "Continue natively", "On a fork, backend switch or rewind, hand Claude and Codex the conversation in their own form — their own fork, or native history — instead of a text transcript. The transcript is still the fallback.", { + kind: "boolean", + envVar: "CODEOID_NATIVE_CONTINUATION", + default: true, + advanced: true, + }), ], }, { diff --git a/src/tests/native-continuation.test.ts b/src/tests/native-continuation.test.ts index 09fb4ca4..75ca1fbb 100644 --- a/src/tests/native-continuation.test.ts +++ b/src/tests/native-continuation.test.ts @@ -107,10 +107,13 @@ describe("canonical history as a Claude session", () => { expect(entries[0]!.type).toBe("user"); }); - it("files the session where Claude Code would: every non-alphanumeric becomes '-'", () => { + it("files the session exactly where Claude Code would, long paths included", () => { expect(claudeProjectDirName("/tmp/spikeB-zI29Li")).toBe("-tmp-spikeB-zI29Li"); + // The SDK: over 200 chars → the first 200, "-", then |31-hash| in base 36. const deep = `/${"a".repeat(300)}`; - expect(claudeProjectDirName(deep).length).toBeLessThanOrEqual(200); + let h = 0; + for (const ch of deep) h = ((h << 5) - h + ch.charCodeAt(0)) | 0; + expect(claudeProjectDirName(deep)).toBe(`-${"a".repeat(199)}-${Math.abs(h).toString(36)}`); }); }); @@ -164,6 +167,20 @@ describe("Claude records where each turn ends in its own session", () => { expect((events.filter((e) => e.type === "turn_done")[1] as { result: NormalizedTurnResult }).result.nativeRef).toBeUndefined(); }); + it("an errored or budget-stopped turn is no point to fork at", () => { + for (const result of [ + { subtype: "error_during_execution", is_error: true }, + { subtype: "success", is_error: true, result: "Prompt is too long" }, + { subtype: "error_max_budget_usd" }, + ]) { + const events: ProviderEvent[] = []; + const state: TranslateState = { lastLocalCommandStderr: null, primaryModel: "m" }; + translateSDKMessage({ type: "assistant", uuid: "a1", parent_tool_use_id: null, message: { content: [] } } as never, (e) => events.push(e), "claude", state); + translateSDKMessage({ type: "result", session_id: "b", num_turns: 1, usage: {}, ...result } as never, (e) => events.push(e), "claude", state); + expect((events.find((e) => e.type === "turn_done") as { result: NormalizedTurnResult }).result.nativeRef).toBeUndefined(); + } + }); + it("names the project dir the session is filed under, so a fork looks only there", () => { const events: ProviderEvent[] = []; const state: TranslateState = { lastLocalCommandStderr: null, primaryModel: "m", cwd: "/work/repo" }; @@ -209,7 +226,9 @@ describe("ClaudeProvider.continueNatively", () => { return { sessionId: "forked-1" }; }, getSessionMessages: async (id, opts) => { - calls.push(`read ${id} ${opts.dir}`); + calls.push(`read ${id}${opts?.dir ? ` ${opts.dir}` : ""}`); + // No dir: the fork read-back. The forked session exists. + if (!opts?.dir) return id === "forked-1" || id === "f2" ? [{ uuid: "x" }] : []; const file = join(configDir, "projects", claudeProjectDirName(opts.dir), `${id}.jsonl`); return readFileSync(file, "utf8").trim().split("\n").map((l) => JSON.parse(l) as { uuid: string }); }, @@ -225,16 +244,33 @@ describe("ClaudeProvider.continueNatively", () => { const p = provider(s); const r = await p.continueNatively({ history, forkFrom: { backingId: "parent-b", ref: "uuid-9" }, maxChars: 1e6, workdir: "/w" }); expect(r).toEqual({ via: "fork", backingId: "forked-1" }); - expect(s.calls).toEqual(["fork parent-b uuid-9"]); + expect(s.calls).toEqual(["fork parent-b uuid-9", "read forked-1"]); // With the source's dir, the lookup is pinned there. let seen: unknown; - const pinned = provider(sdk({ forkSession: async (_id, o) => ((seen = o), { sessionId: "f2" }) })); + const pinned = provider( + sdk({ + forkSession: async (_id, o) => { + seen = o; + return { sessionId: "f2" }; + }, + }), + ); await pinned.continueNatively({ history, forkFrom: { backingId: "parent-b", ref: "uuid-9", dir: "/src/repo" }, maxChars: 1e6, workdir: "/w" }); expect(seen).toEqual({ upToMessageId: "uuid-9", dir: "/src/repo" }); expect(p.backingSessionId).toBe("forked-1"); expect(p.hasQueried).toBe(true); // resume, not create }); + it("a fork that reads back empty is not adopted — history is built instead", async () => { + const s = sdk({ forkSession: async () => ({ sessionId: "ghost" }) }); + const p = provider(s); + const workdir = join(configDir, "work-ghost"); + mkdirSync(workdir); + const r = await p.continueNatively({ history, forkFrom: { backingId: "parent-b", ref: "uuid-9" }, maxChars: 1e6, workdir }); + expect(r?.via).toBe("history"); + expect(p.backingSessionId).not.toBe("ghost"); + }); + it("when the fork source is gone, builds the session from history instead — and verifies it with the SDK's reader", async () => { const s = sdk({ forkSession: async () => Promise.reject(new Error("Session parent-b not found")) }); const p = provider(s); @@ -375,12 +411,18 @@ describe("Session: native first, transcript as the fallback", () => { conductor: { enabled: false, name: "conductor", provider: "claude" }, }) as CodeoidConfig; + let identity: { registered: string[] } | undefined; + /** Input tokens each scripted turn reports (the context size a fork of it starts at). */ + let turnInputTokens = 1; + function manager(session: Record = {}): SessionManager { const factory = (id: string) => ({ id, displayName: id, create: () => { - const p = new MockSessionProvider(id, [say("a1", id, { backingId: `${id}-b1`, ref: "00000000-0000-0000-0000-0000000000e1" }), say("a2", id, { backingId: `${id}-b1`, ref: "00000000-0000-0000-0000-0000000000e2" }), say("a3", id)]); + const big = (events: ProviderEvent[]) => + events.map((e) => (e.type === "turn_done" ? { ...e, result: { ...e.result, inputTokens: turnInputTokens } } : e)) as ProviderEvent[]; + const p = new MockSessionProvider(id, [big(say("a1", id, { backingId: `${id}-b1`, ref: "00000000-0000-0000-0000-0000000000e1" })), big(say("a2", id, { backingId: `${id}-b1`, ref: "00000000-0000-0000-0000-0000000000e2" })), say("a3", id), say("a4", id)]); // Like a real warm backend (claude, codex, pi), report the transcript it stashed. p.seedResultOverride = { text: "…", totalTurns: 0, keptTurns: 0, omittedTurns: 0, newestTurnSliced: false }; if (nativeAnswer) { @@ -394,7 +436,17 @@ describe("Session: native first, transcript as the fallback", () => { const registry = new ProviderRegistry("claude"); registry.register(factory("claude")); registry.register(factory("codex")); - const m = new SessionManager(store, transcript, undefined, undefined, undefined, { config: mkConfig(session), providers: registry }); + const im = identity + ? ({ + registerSessionAgent: async (sessionId: string) => { + identity!.registered.push(sessionId); + return { wimseUri: `spiffe://test/agent/${sessionId}` }; + }, + deactivateSessionAgent: async () => {}, + getGatewayCredential: () => undefined, + } as never) + : undefined; + const m = new SessionManager(store, transcript, im, undefined, undefined, { config: mkConfig(session), providers: registry }); managers.push(m); return m; } @@ -406,6 +458,8 @@ describe("Session: native first, transcript as the fallback", () => { managers = []; made = []; nativeAnswer = undefined; + identity = undefined; + turnInputTokens = 1; }); afterEach(async () => { for (const m of managers) await m.drain(2_000).catch(() => {}); @@ -414,7 +468,9 @@ describe("Session: native first, transcript as the fallback", () => { }); async function newSession(m: SessionManager): Promise { - const r = await m.handle({ type: "session.create", id: "c", name: `n-${Math.random()}`, workdir: tmp }, AUTH, CLIENT); + const workdir = join(tmp, "work"); + mkdirSync(workdir, { recursive: true }); + const r = await m.handle({ type: "session.create", id: "c", name: `n-${Math.random()}`, workdir }, AUTH, CLIENT); if (r.type !== "response.ok") throw new Error(JSON.stringify(r)); return (r.data as { id: string }).id; } @@ -493,6 +549,41 @@ describe("Session: native first, transcript as the fallback", () => { expect(made.at(-1)!.nativeRequests[0]!.forkFrom).toBeUndefined(); }); + it("a natively continued fork still registers its own agent identity on its first send", async () => { + identity = { registered: [] }; + nativeAnswer = (req) => ({ via: req.forkFrom ? "fork" : "history", backingId: "native-backing" }); + const m = manager(); + const id = await newSession(m); + await send(m, id, "one"); + const info = await fork(m, id); + expect(made.at(-1)!.hasQueried).toBe(true); // resumes the adopted session... + await send(m, info.id, "two"); + expect(identity.registered).toEqual([id, info.id]); // ...and is still its own agent + }); + + it("doesn't fork natively when the parent's context was already near the target's window", async () => { + turnInputTokens = 50_000_000; // far past any window: the fork would overflow at once + nativeAnswer = (req) => ({ via: req.forkFrom ? "fork" : "history", backingId: "nb" }); + const m = manager(); + const id = await newSession(m); + await send(m, id, "one"); + await fork(m, id); + const req = made.at(-1)!.nativeRequests[0]!; + expect(req.forkFrom).toBeUndefined(); // budgeted history instead + }); + + it("when Claude can't find its session, recovery hands the new one the conversation", async () => { + nativeAnswer = () => ({ via: "history", backingId: "recovered" }); + const m = manager(); + const id = await newSession(m); + await send(m, id, "one"); + await send(m, id, "two"); + const p = made.at(-1)!; + p.onRecoveryNeeded!("two"); + for (let i = 0; i < 100 && p.nativeRequests.length === 0; i++) await Bun.sleep(10); + expect(p.nativeRequests[0]!.history.map((t) => t.content)).toEqual(["one", "a1", "two", "a2"]); + }); + it("a fork onto another backend asks for native history — no fork point to borrow", async () => { nativeAnswer = () => ({ via: "history" }); const m = manager(); diff --git a/src/tests/turns.test.ts b/src/tests/turns.test.ts index ac3bbd65..4d33eaa8 100644 --- a/src/tests/turns.test.ts +++ b/src/tests/turns.test.ts @@ -512,6 +512,8 @@ describe("canonicalFromTranscript", () => { toolCalls: [{ id: "tu1", name: "read_file", originalName: "Read", input: { file_path: "a.txt" }, output: "v0", success: true }], providerId: "codex", model: "unknown", + // Rebuilt from what was shown, not recorded from the backend (#387). + restored: true, }, { role: "user", @@ -520,7 +522,7 @@ describe("canonicalFromTranscript", () => { at: "2026-01-01T00:00:00Z", background: true, }, - { role: "assistant", content: "background result", turnId: "T2", providerId: "codex", model: "unknown" }, + { role: "assistant", content: "background result", turnId: "T2", providerId: "codex", model: "unknown", restored: true }, ]); }); }); From 29f343153ede3d324bc10d7a93c178c527deda71 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 17 Oct 2026 02:21:03 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20native=20continuation=20round=202=20?= =?UTF-8?q?=E2=80=94=20redaction=20sticks=20to=20the=20backing,=20durably;?= =?UTF-8?q?=20imported=20sessions=20may=20still=20fork?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/daemon/providers/claude/index.ts | 6 +- src/daemon/providers/codex/index.ts | 1 + src/daemon/providers/interface.ts | 13 ++++ src/daemon/session-manager.ts | 6 +- src/daemon/session.ts | 97 +++++++++++++++++++++++---- src/daemon/transcript.ts | 6 ++ src/tests/native-continuation.test.ts | 81 +++++++++++++++++++++- 7 files changed, 193 insertions(+), 17 deletions(-) diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index 2020e2a9..c476108c 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -498,7 +498,7 @@ export class ClaudeProvider implements SessionProvider { console.error(`${tag} native fork of ${req.forkFrom.backingId.slice(0, 8)} failed, building history instead: ${err instanceof Error ? err.message : String(err)}`); } } - if (req.history.length === 0) return null; + if (req.history.length === 0 || req.allowHistory === false) return null; const id = randomUUID(); const { entries, truncation } = toClaudeSessionEntries(req.history, { sessionId: id, cwd: req.workdir, maxChars: req.maxChars }); if (entries.length === 0) return null; @@ -519,7 +519,11 @@ export class ClaudeProvider implements SessionProvider { /** Make `id` this provider's backing session, resumed (not created) on the next turn. */ #adoptBacking(id: string): void { + // Adopting a backing during recovery must not re-arm recovery: if this + // one can't be resumed either, the turn fails rather than looping. + const recovering = this.#backingRecoveryAttempted; this.resetToNewSession(id); + this.#backingRecoveryAttempted = recovering; this.#hasQueried = true; } diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index ea572a68..d787ba8a 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -403,6 +403,7 @@ export class CodexProvider implements SessionProvider { this.#pendingNative = { kind: "fork", threadId: req.forkFrom.backingId, turnId: req.forkFrom.ref, fallback }; return { via: "fork" }; } + if (req.allowHistory === false) return null; const { items, truncation } = toResponsesItems(req.history, { maxChars: req.maxChars }); if (items.length === 0) return null; this.#pendingNative = { kind: "inject", items, fallback }; diff --git a/src/daemon/providers/interface.ts b/src/daemon/providers/interface.ts index 185e728f..a662b7cb 100644 --- a/src/daemon/providers/interface.ts +++ b/src/daemon/providers/interface.ts @@ -223,6 +223,13 @@ export interface NativeTurnRef { * sessions by it (Claude): a fork looks the source up there and nowhere else. */ dir?: string; + /** + * Some tool output in this backing session was redacted by a tool_result + * hook: the backend's own record holds the original, so this point must not + * be forked natively. Stamped by the session on every later turn of the + * backing, so it survives history that no longer shows the redacted call. + */ + redacted?: boolean; } /** What a session asks a provider when it hands it an existing conversation. */ @@ -237,6 +244,12 @@ export interface NativeContinuationRequest { forkFrom?: NativeTurnRef; /** Char budget for history the provider builds itself (sized to the target window). */ maxChars: number; + /** + * False when the history holds turns rebuilt from a displayed transcript: + * the provider may fork, but must not build native history from them — + * resolve null instead (the session uses the transcript). + */ + allowHistory?: boolean; /** Where the continued session runs. */ workdir: string; } diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index fdf06492..ca106db9 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -880,6 +880,7 @@ mcpHub: this.#mcpHub, persistIndex: !index && history.some((t) => t.turnId), partial: canonical?.partial ?? false, }); + if (meta.redactedBackings) session.adoptRedactedBackings(meta.redactedBackings); if (meta.backingFresh) await session.reseedFreshBacking(); } catch (err) { console.error( @@ -3755,7 +3756,10 @@ mcpHub: this.#mcpHub, // what sizes the fork's history to the real window instead of the 200k // floor — forks never carry a model id, so the cache alone can't key it. fork.inheritObservedLimits(parent); - await fork.primeFromFork(history, transcriptRows, sizeHints, workdirNote, { parentContextTokens: parent.lastContextTokens }); + await fork.primeFromFork(history, transcriptRows, sizeHints, workdirNote, { + parentContextTokens: parent.lastContextTokens, + redactedBackings: parent.redactedBackings, + }); // The inherited turns keep their ids, list entries and file snapshots // (#354) — and the snapshots survive the parent being destroyed. // Only turns the fork's history actually contains: the parent may have diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 7b41bc2e..767fb5ec 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -11,6 +11,7 @@ import { CanonicalHistoryAccumulator, + canonicalTurnChars, type CanonicalTurn, type HistorySeedResult, } from "./providers/canonical.js"; @@ -752,6 +753,14 @@ export class Session { * resumable backing session before this session has ever sent. */ #identityOnNextSend = false; + /** + * Backing sessions holding output a tool_result hook redacted from codeoid's + * record: no native fork of them, ever (#387). Rebuilt from the history's + * stamps whenever history is loaded, so it holds across restarts. + */ + readonly #redactedBackings = new Set(); + /** A tool_result hook redacted output in the turn now running. */ + #turnRedacted = false; /** Context size (tokens) the source of the next fork was last running at — see #continueNatively. */ #forkSourceContextTokens = 0; /** A rewind is in progress: nothing may wake the agent meanwhile. */ @@ -2422,7 +2431,17 @@ export class Session { // silently dropped everything but the prompt. const history = this.#accumulator.history; const before = history.at(-1)?.role === "user" ? history.length - 1 : history.length; - if (before > 0) await this.#seedProviderFromHistory(undefined, { upTo: before }); + if (before > 0) { + await this.#seedProviderFromHistory(undefined, { upTo: before }); + // A transcript seed rides THIS prompt: record it, so an overflow + // retries it here rather than leaving the seed for a later send. + const asked = history[before]; + if (this.#pendingSeed && this.#pendingSeed.prompt === undefined && asked?.role === "user" && this.#currentTurnId) { + this.#pendingSeed.prompt = content; + this.#pendingSeed.sender = recoverySender; + this.#pendingSeed.turn = { id: this.#currentTurnId, text: asked.prompt ?? asked.content, at: asked.at ?? new Date().toISOString() }; + } + } // Do NOT pushUserTurn(content) here — the original send() already // pushed it before runTurn(). Pushing again duplicates the user turn. const recoveryRun = this.#provider.runTurn({ @@ -3276,25 +3295,29 @@ export class Session { const provider = this.#provider; if (!provider.continueNatively) return null; // Turns rebuilt from a displayed transcript (an import, a pre-canonical - // session) aren't the model's own words: they stay a quoted transcript, - // never native history in the assistant's voice. - if (history.some((t) => t.role === "assistant" && t.restored)) return null; + // session) aren't the model's own words: they are never BUILT into native + // history in the assistant's voice. A fork is still fine — it copies the + // backend's own record, not the rebuilt text. + const allowHistory = !history.some((t) => t.role === "assistant" && t.restored); const last = history.at(-1); const forkFrom = last?.role === "assistant" && last.providerId === provider.id && isNativeRef(last.native) && + !last.native.redacted && + !this.#redactedBackings.has(last.native.backingId) && // A fork copies the backend's own record, which still holds what a // tool_result hook redacted from codeoid's: rebuild from codeoid's instead. !history.some((t) => t.role === "assistant" && t.toolCalls?.some((tc) => tc.redacted)) && // A fork carries the source's whole context: when that was already near // the target's window, build history (budgeted, oldest dropped) instead. - this.#forkFits() + this.#forkFits(history) ? last.native : undefined; + if (!forkFrom && !allowHistory) return null; let result: NativeContinuationResult | null; try { - result = await provider.continueNatively({ history, ...(forkFrom ? { forkFrom } : {}), maxChars, workdir: this.workdir }); + result = await provider.continueNatively({ history, ...(forkFrom ? { forkFrom } : {}), maxChars, workdir: this.workdir, allowHistory }); } catch (err) { console.error(`[codeoid/session ${this.id}] native continuation on ${provider.id} failed, using the transcript: ${err instanceof Error ? err.message : String(err)}`); return null; @@ -3318,9 +3341,14 @@ export class Session { * the size the source was last running at (a fork's parent, or this session * before a rewind or restart). Unknown counts as fitting. */ - #forkFits(): boolean { - const tokens = this.#forkSourceContextTokens || this.#usage.lastTurnInputTokens || 0; - if (tokens <= 0) return true; + #forkFits(history: readonly CanonicalTurn[]): boolean { + // The size the source last ran at; unknown (no usage on record) falls back + // to an estimate from the history itself, at the seed's chars-per-token. + const known = this.#forkSourceContextTokens || this.#usage.lastTurnInputTokens || 0; + const tokens = + known > 0 + ? known + : history.reduce((n, t) => n + canonicalTurnChars(t), 0) / (this.#config?.session?.seedCharsPerToken ?? 2); const window = targetContextWindow(this.#provider.id, this.#model, this.#knownWindow(this.#provider.id)); return tokens <= window * 0.85; } @@ -3330,6 +3358,43 @@ export class Session { return this.#usage.lastTurnInputTokens ?? 0; } + /** Mark a turn's native point as redacted when its backing holds redacted output. */ + #stampRedaction>(event: E): E { + const ref = event.result.nativeRef; + // The turn's own point names the backing that recorded the original. + // A turn that errored has no point of its own, but its backing still + // recorded the original: the provider's current backing is that one. + if (this.#turnRedacted) this.#noteRedactedBacking(ref?.backingId ?? this.#provider.backingSessionId); + this.#turnRedacted = false; + if (!ref || ref.redacted || !this.#redactedBackings.has(ref.backingId)) return event; + return { ...event, result: { ...event.result, nativeRef: { ...ref, redacted: true } } }; + } + + /** Learn which backings hold redacted output from a history being loaded or adopted. */ + #learnRedactedBackings(history: readonly CanonicalTurn[]): void { + for (const t of history) { + if (t.role !== "assistant" || !t.native) continue; + if (t.native.redacted || t.toolCalls?.some((tc) => tc.redacted)) this.#redactedBackings.add(t.native.backingId); + } + } + + /** Remember a backing holds redacted output, durably. */ + #noteRedactedBacking(backingId: string): void { + if (this.#redactedBackings.has(backingId)) return; + this.#redactedBackings.add(backingId); + this.#saveMetaNow(); + } + + /** Backings this session knows hold redacted output (persisted in meta; a fork inherits them). */ + get redactedBackings(): readonly string[] { + return [...this.#redactedBackings]; + } + + /** Restore the persisted set on resume (before any reseed). */ + adoptRedactedBackings(ids: readonly string[]): void { + for (const id of ids) this.#redactedBackings.add(id); + } + /** Say how a fork or switch carried the conversation to this backend. */ #announceContinuation(via: "fork" | "history" | "transcript", turns: number): void { const p = this.#provider.id; @@ -3562,10 +3627,12 @@ export class Session { * "shares the parent's workdir — no isolation" warning). */ note?: string, /** The parent's last context size, so a native fork isn't tried when it wouldn't fit. */ - opts: { parentContextTokens?: number } = {}, + opts: { parentContextTokens?: number; redactedBackings?: readonly string[] } = {}, ): Promise { this.#forkSourceContextTokens = opts.parentContextTokens ?? 0; this.#accumulator.seed(history); + this.#learnRedactedBackings(history); + this.adoptRedactedBackings(opts.redactedBackings ?? []); // Replay the parent's transcript into the fork for visibility (reusing // restoreScrollback's frozen-tool reconciliation), but DON'T let its // "prior scrollback ⇒ setHasQueried(true)" fire: a fork's backend is @@ -4986,7 +5053,7 @@ export class Session { if (event.type === "turn_done" && this.#pendingMidTurnCount > 0) { this.#pendingMidTurnCount--; // Record history / cost for the interrupted partial turn. - this.#accumulator.handleEvent(event); + this.#accumulator.handleEvent(this.#stampRedaction(event)); this.#recordTurnFromResult(event.result); // Flush per-turn accumulators so the continuation turn starts clean. // Same boundary, same reasoning as turn exit: the partial turn's @@ -5342,6 +5409,10 @@ export class Session { }); if (patched.updatedOutput !== undefined) output = patched.updatedOutput; } + if (output !== event.output) { + this.#turnRedacted = true; + this.#noteRedactedBacking(this.#provider.backingSessionId); + } this.#accumulator.handleEvent(output === event.output ? event : { ...event, output, redacted: true }); if (msgId) { const toolMsg = this.#toolCallMessages.get(msgId); @@ -5500,7 +5571,7 @@ export class Session { } case "turn_done": { - this.#accumulator.handleEvent(event); + this.#accumulator.handleEvent(this.#stampRedaction(event)); this.#recordTurnFromResult(event.result); // No sweep here: a terminal turn_done breaks #consumeEvents, whose // finally reconciles sub-agents alongside tools. Sweeping here too would @@ -6651,6 +6722,7 @@ export class Session { ): void { if (opts.persistHistory) this.#accumulator.seed(history); else this.#accumulator.restore(history); + this.#learnRedactedBackings(history); this.#turnIndex = index.map((e) => ({ ...e })); if (opts.persistIndex) void this.#transcriptStore.replaceTurnIndex(this.id, this.#turnIndex); this.#canonicalPartial = opts.partial; @@ -6869,6 +6941,7 @@ export class Session { role: this.role, providerId: this.#provider.id, ...(this.#backingFresh ? { backingFresh: true } : {}), + ...(this.#redactedBackings.size > 0 ? { redactedBackings: [...this.#redactedBackings] } : {}), forkedFrom: this.forkedFrom, worktree: this.worktree, // MUST be written here too, not only at create: #writeMetaAtomic diff --git a/src/daemon/transcript.ts b/src/daemon/transcript.ts index 839d7ead..9439f98c 100644 --- a/src/daemon/transcript.ts +++ b/src/daemon/transcript.ts @@ -171,6 +171,12 @@ export interface TranscriptMeta { * instead of resuming an empty backing session (#355). */ backingFresh?: boolean; + /** + * Backing sessions that hold tool output a tool_result hook redacted from + * codeoid's record: never forked natively (#387). Persisted the moment a + * redaction happens, so a crash before the turn ends can't forget it. + */ + redactedBackings?: string[]; /** Fork lineage (session.fork). Absent = not a fork. */ forkedFrom?: { sessionId: string; name: string; atTurn: number }; /** Git worktree backing workdir (fork isolation / bind). Absent = shared. */ diff --git a/src/tests/native-continuation.test.ts b/src/tests/native-continuation.test.ts index 75ca1fbb..6c2080ee 100644 --- a/src/tests/native-continuation.test.ts +++ b/src/tests/native-continuation.test.ts @@ -21,6 +21,7 @@ import { claudeProjectDirName, toClaudeSessionEntries, type ClaudeSessionEntry } import { CodexProvider } from "../daemon/providers/codex/index.js"; import { toResponsesItems } from "../daemon/providers/codex/native-history.js"; import type { NormalizedTurnResult, ProviderEvent, TurnOpts, TurnRun } from "../daemon/providers/interface.js"; +import { HookBus } from "../daemon/hooks/bus.js"; import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; import { ProviderRegistry } from "../daemon/providers/registry.js"; import { SessionManager } from "../daemon/session-manager.js"; @@ -286,6 +287,13 @@ describe("ClaudeProvider.continueNatively", () => { expect(p.hasQueried).toBe(true); }); + it("told not to build history (restored turns), a failed fork resolves null rather than building it", async () => { + const s = sdk({ forkSession: async () => Promise.reject(new Error("gone")) }); + const p = provider(s); + expect(await p.continueNatively({ history, forkFrom: { backingId: "parent-b", ref: "uuid-9" }, maxChars: 1e6, workdir: "/w", allowHistory: false })).toBeNull(); + expect(s.calls.some((c) => c.startsWith("read "))).toBe(false); + }); + it("a session the SDK can't read back exactly is removed, and the transcript is used instead", async () => { const s = sdk({ getSessionMessages: async () => [{ uuid: "only-one" }] }); const p = provider(s); @@ -414,6 +422,9 @@ describe("Session: native first, transcript as the fallback", () => { let identity: { registered: string[] } | undefined; /** Input tokens each scripted turn reports (the context size a fork of it starts at). */ let turnInputTokens = 1; + /** Replaces the scripted turns of every new provider, when set. */ + let script: ProviderEvent[][] | undefined; + let hooks: HookBus | undefined; function manager(session: Record = {}): SessionManager { const factory = (id: string) => ({ @@ -422,7 +433,10 @@ describe("Session: native first, transcript as the fallback", () => { create: () => { const big = (events: ProviderEvent[]) => events.map((e) => (e.type === "turn_done" ? { ...e, result: { ...e.result, inputTokens: turnInputTokens } } : e)) as ProviderEvent[]; - const p = new MockSessionProvider(id, [big(say("a1", id, { backingId: `${id}-b1`, ref: "00000000-0000-0000-0000-0000000000e1" })), big(say("a2", id, { backingId: `${id}-b1`, ref: "00000000-0000-0000-0000-0000000000e2" })), say("a3", id), say("a4", id)]); + const p = new MockSessionProvider( + id, + script ?? [big(say("a1", id, { backingId: `${id}-b1`, ref: "00000000-0000-0000-0000-0000000000e1" })), big(say("a2", id, { backingId: `${id}-b1`, ref: "00000000-0000-0000-0000-0000000000e2" })), say("a3", id), say("a4", id)], + ); // Like a real warm backend (claude, codex, pi), report the transcript it stashed. p.seedResultOverride = { text: "…", totalTurns: 0, keptTurns: 0, omittedTurns: 0, newestTurnSliced: false }; if (nativeAnswer) { @@ -446,7 +460,7 @@ describe("Session: native first, transcript as the fallback", () => { getGatewayCredential: () => undefined, } as never) : undefined; - const m = new SessionManager(store, transcript, im, undefined, undefined, { config: mkConfig(session), providers: registry }); + const m = new SessionManager(store, transcript, im, undefined, undefined, { config: mkConfig(session), providers: registry, ...(hooks ? { hooks } : {}) }); managers.push(m); return m; } @@ -460,6 +474,8 @@ describe("Session: native first, transcript as the fallback", () => { nativeAnswer = undefined; identity = undefined; turnInputTokens = 1; + script = undefined; + hooks = undefined; }); afterEach(async () => { for (const m of managers) await m.drain(2_000).catch(() => {}); @@ -526,18 +542,77 @@ describe("Session: native first, transcript as the fallback", () => { expect(req.history.at(-1)).toMatchObject({ role: "assistant" }); }); + it("once a hook redacts output in a backing, every later turn of it is marked — no native fork, even where the redacted call isn't in view", async () => { + hooks = new HookBus([{ event: "tool_result", type: "command", command: `printf '{"updatedOutput":"[REDACTED]"}'` }]); + const ref = (n: number) => ({ backingId: "claude-b1", ref: `00000000-0000-0000-0000-00000000000${n}` }); + script = [ + [ + { type: "tool_start", toolId: "t1", sdkToolUseId: "su1", name: "Bash", input: { command: "cat .env" }, approvalId: "ap1" } as ProviderEvent, + { type: "tool_complete", sdkToolUseId: "su1", output: "API_KEY=hunter2", success: true } as ProviderEvent, + ...say("read it", "claude", ref(1)), + ], + say("later", "claude", ref(2)), + ]; + nativeAnswer = (req) => ({ via: req.forkFrom ? "fork" : "history", backingId: "nb" }); + const m = manager(); + const id = await newSession(m); + await m.handle({ type: "session.set_mode", id: "md", sessionId: id, mode: "autonomous", maxTurns: 10 } as never, AUTH, CLIENT); + await send(m, id, "one"); + await send(m, id, "two"); + const h = m._sessionForTest(id)!.canonicalHistory; + const first = h[1] as Extract; + expect(first.toolCalls?.[0]).toMatchObject({ output: "[REDACTED]", redacted: true }); + // The later turn has no redacted call of its own, but its backing does. + expect((h.at(-1) as Extract).native).toMatchObject({ ...ref(2), redacted: true }); + // Durable the moment it happened: in the meta a restart reads. + const meta = JSON.parse(readFileSync(transcript.metaPath(id), "utf8")) as { redactedBackings?: string[] }; + expect(meta.redactedBackings).toContain("claude-b1"); + const forked = await fork(m, id); + expect(made.at(-1)!.nativeRequests[0]!.forkFrom).toBeUndefined(); + // ...and the fork inherits it, so its own rewinds can't fork that backing either. + expect(m._sessionForTest(forked.id)!.redactedBackings).toContain("claude-b1"); + + // A restart that loads only the newest turns still knows — and keeps + // marking that backing's new turns, though nothing in them was redacted. + script = undefined; + hooks = undefined; + const m2 = manager(); + const id2 = await newSession(m2); + const s2 = m2._sessionForTest(id2)!; + s2.restoreTurns([user("two"), assistant("later", { native: { ...ref(2), redacted: true } })], [], { persistHistory: true, persistIndex: false, partial: true }); + await send(m2, id2, "three"); + expect((s2.canonicalHistory.at(-1) as Extract).native).toMatchObject({ backingId: "claude-b1", redacted: true }); + await fork(m2, id2); + expect(made.at(-1)!.nativeRequests[0]!.forkFrom).toBeUndefined(); + }); + it("turns rebuilt from a displayed transcript stay a transcript — never native history in the assistant's voice", async () => { nativeAnswer = () => ({ via: "history", backingId: "nb" }); const m = manager(); const id = await newSession(m); await send(m, id, "one"); const last = m._sessionForTest(id)!.canonicalHistory.at(-1) as Extract; - last.restored = true; + last.restored = true; // rebuilt turns never carry a native point + delete last.native; await fork(m, id); expect(made.at(-1)!.nativeRequests).toEqual([]); expect(made.at(-1)!.seededHistory).not.toBeNull(); }); + it("an imported session with a real turn since may still fork natively — but is never rebuilt as native history", async () => { + nativeAnswer = (req) => (req.forkFrom ? { via: "fork", backingId: "nb" } : null); + const m = manager(); + const id = await newSession(m); + await send(m, id, "one"); + await send(m, id, "two"); + const first = m._sessionForTest(id)!.canonicalHistory[1] as Extract; + first.restored = true; + await fork(m, id); + const req = made.at(-1)!.nativeRequests[0]!; + expect(req.forkFrom).toBeDefined(); // the backend's own record, not the rebuilt text + expect(req.allowHistory).toBe(false); + }); + it("a malformed native point is not acted on", async () => { nativeAnswer = (req) => ({ via: req.forkFrom ? "fork" : "history", backingId: "nb" }); const m = manager(); From 5e0fe0c3a0cb08be0b38a58d554d19bb56ffe141 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 17 Oct 2026 02:35:34 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20native=20continuation=20round=203=20?= =?UTF-8?q?=E2=80=94=20backing=20recovery=20can't=20loop,=20redaction=20fl?= =?UTF-8?q?ag=20can't=20leak=20into=20the=20next=20turn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/daemon/providers/claude/index.ts | 10 +++++----- src/daemon/session.ts | 6 +++++- src/tests/provider-claude.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index c476108c..8292679e 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -322,7 +322,9 @@ export class ClaudeProvider implements SessionProvider { this.#hasQueried = false; // A turn anchor belongs to the session it was seen in. this.#translateState.lastChainUuid = null; - this.#backingRecoveryAttempted = false; + // NOT #backingRecoveryAttempted: recovery itself resets the backing (and + // may adopt a native one) before retrying, and must not re-arm on the way. + // It clears once a turn on the new backing succeeds (#emit). this.#zeroTurnRecoveryAttempted = false; this.#lastPushedContent = null; } @@ -519,11 +521,7 @@ export class ClaudeProvider implements SessionProvider { /** Make `id` this provider's backing session, resumed (not created) on the next turn. */ #adoptBacking(id: string): void { - // Adopting a backing during recovery must not re-arm recovery: if this - // one can't be resumed either, the turn fails rather than looping. - const recovering = this.#backingRecoveryAttempted; this.resetToNewSession(id); - this.#backingRecoveryAttempted = recovering; this.#hasQueried = true; } @@ -1003,6 +1001,8 @@ export class ClaudeProvider implements SessionProvider { /** Push a ProviderEvent to the active per-turn queue. */ #emit(event: ProviderEvent): void { + // A backing that just completed a turn is resumable: recovery may arm again. + if (event.type === "turn_done" && !event.result.isError) this.#backingRecoveryAttempted = false; const queue = this.#currentTurnQueue; if (queue) { try { diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 767fb5ec..fbe0caf0 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -2515,6 +2515,9 @@ export class Session { this.#pendingSeed.turn = { id: turnId, text, at: turnAt }; } this.#accumulator.pushUserTurn(effectivePrompt, turnId, { prompt: text, at: turnAt }); + // A redaction flagged by a turn that never reached turn_done (interrupted, + // errored, switched away) belongs to that turn's backing, not this one. + this.#turnRedacted = false; const run = this.#provider.runTurn({ ...(this.#maxBudgetUsd !== undefined ? { maxBudgetUsd: this.#maxBudgetUsd } : {}), history: this.#accumulator.history, @@ -3382,7 +3385,8 @@ export class Session { #noteRedactedBacking(backingId: string): void { if (this.#redactedBackings.has(backingId)) return; this.#redactedBackings.add(backingId); - this.#saveMetaNow(); + // A late tool result must not recreate a destroyed session's meta. + if (!this.#destroyed) this.#saveMetaNow(); } /** Backings this session knows hold redacted output (persisted in meta; a fork inherits them). */ diff --git a/src/tests/provider-claude.test.ts b/src/tests/provider-claude.test.ts index bec4f183..d52c8fde 100644 --- a/src/tests/provider-claude.test.ts +++ b/src/tests/provider-claude.test.ts @@ -709,6 +709,32 @@ describe("decodeStderrEntities", () => { * (anthropics/claude-code#80223) — this recovers whatever produced the zero * turn, because it keys on `num_turns` rather than on any cause. */ +describe("ClaudeProvider – backing-session recovery can't loop (#387)", () => { + beforeEach(() => { sdkMessages = []; sdkThrowError = null; capturedQueryOpts = null; sdkGate = null; }); + + it("recovers once; if the backing it then resumes is missing too, the turn fails instead of recovering again", async () => { + const provider = makeProvider(); + provider.setHasQueried(true); // a resumed backing + sdkThrowError = new Error("No conversation found with session ID: gone-1"); + let recoveries = 0; + let retry: Promise | null = null; + provider.onRecoveryNeeded = (content) => { + recoveries += 1; + // What the session does: reset, adopt a resumable backing (native + // continuation), and run the prompt again — which also fails. + provider.resetToNewSession("adopted-2"); + provider.setHasQueried(true); + retry = collectTurnEvents(provider, content); + }; + await collectTurnEvents(provider); + for (let i = 0; i < 100 && !retry; i++) await Bun.sleep(5); + const events = await retry!; + await Bun.sleep(20); + expect(recoveries).toBe(1); + expect(events.some((e) => e.type === "error" && String((e as { message: string }).message).includes("No conversation found"))).toBe(true); + }); +}); + describe("ClaudeProvider – unexplained zero-turn re-send", () => { beforeEach(() => { sdkMessages = []; sdkThrowError = null; capturedQueryOpts = null; sdkGate = null; }); From d0e810cbe803d9630f02aa51f9e1703716c78490 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 17 Oct 2026 02:39:13 +0800 Subject: [PATCH 6/7] fix: test that backing recovery re-arms after a successful turn, and 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) --- src/tests/provider-claude.test.ts | 53 +++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/src/tests/provider-claude.test.ts b/src/tests/provider-claude.test.ts index d52c8fde..ac714147 100644 --- a/src/tests/provider-claude.test.ts +++ b/src/tests/provider-claude.test.ts @@ -696,19 +696,6 @@ describe("decodeStderrEntities", () => { }); }); -// Park-and-retry: a blocked skill command must NOT fail the turn (that killed the -// pipeline — #233). It parks the turn (approval_pending), and on approval the -// loop rebuilds with the grant and retries the SAME prompt in place. -/** - * The UNEXPLAINED zero-turn: `num_turns: 0` with no `` - * naming a cause. The prompt was consumed and the model never ran, so before - * this the prompt was simply lost and the user retyped it. - * - * Distinct from #233 (a blocked skill command, which HAS a stderr cause and - * parks for approval) and from the upstream reporting defect - * (anthropics/claude-code#80223) — this recovers whatever produced the zero - * turn, because it keys on `num_turns` rather than on any cause. - */ describe("ClaudeProvider – backing-session recovery can't loop (#387)", () => { beforeEach(() => { sdkMessages = []; sdkThrowError = null; capturedQueryOpts = null; sdkGate = null; }); @@ -733,8 +720,48 @@ describe("ClaudeProvider – backing-session recovery can't loop (#387)", () => expect(recoveries).toBe(1); expect(events.some((e) => e.type === "error" && String((e as { message: string }).message).includes("No conversation found"))).toBe(true); }); + + it("re-arms once a turn on the new backing succeeds: a later missing backing recovers again", async () => { + const provider = makeProvider(); + provider.setHasQueried(true); + sdkThrowError = new Error("No conversation found with session ID: gone-1"); + let recoveries = 0; + let retry: Promise | null = null; + provider.onRecoveryNeeded = (content) => { + recoveries += 1; + provider.resetToNewSession(`adopted-${recoveries}`); + provider.setHasQueried(true); + // The first recovery's retry succeeds; afterwards the backing goes missing again. + if (recoveries === 1) { + sdkThrowError = null; + sdkMessages = [{ type: "result", subtype: "success", num_turns: 1, result: "ok", modelUsage: {} }]; + } + retry = collectTurnEvents(provider, content); + }; + await collectTurnEvents(provider); + for (let i = 0; i < 100 && !retry; i++) await Bun.sleep(5); + expect((await retry!).some((e) => e.type === "turn_done")).toBe(true); + retry = null; + sdkThrowError = new Error("No conversation found with session ID: adopted-1"); + await collectTurnEvents(provider, "later"); + for (let i = 0; i < 100 && !retry; i++) await Bun.sleep(5); + expect(recoveries).toBe(2); + }); }); +// Park-and-retry: a blocked skill command must NOT fail the turn (that killed the +// pipeline — #233). It parks the turn (approval_pending), and on approval the +// loop rebuilds with the grant and retries the SAME prompt in place. +/** + * The UNEXPLAINED zero-turn: `num_turns: 0` with no `` + * naming a cause. The prompt was consumed and the model never ran, so before + * this the prompt was simply lost and the user retyped it. + * + * Distinct from #233 (a blocked skill command, which HAS a stderr cause and + * parks for approval) and from the upstream reporting defect + * (anthropics/claude-code#80223) — this recovers whatever produced the zero + * turn, because it keys on `num_turns` rather than on any cause. + */ describe("ClaudeProvider – unexplained zero-turn re-send", () => { beforeEach(() => { sdkMessages = []; sdkThrowError = null; capturedQueryOpts = null; sdkGate = null; }); From 69218779a845ae13afa9e2da56652605de94ffd9 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 17 Oct 2026 02:53:17 +0800 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20native=20continuation=20final-audit?= =?UTF-8?q?=20pass=20=E2=80=94=20docs=20and=20comments=20match=20the=20cod?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 8 +++- docs/COMPARISON.md | 2 +- docs/FEATURES.md | 9 ++-- docs/providers-pi.md | 3 +- src/daemon/providers/canonical.ts | 2 +- src/daemon/providers/claude/native-history.ts | 2 +- src/daemon/providers/codex/index.ts | 47 ++++++++++--------- src/daemon/session.ts | 45 +++++++++++++----- src/tests/native-continuation.test.ts | 5 +- 9 files changed, 78 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4a5ccec..9f957081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). SDK's own reader before use, or Codex history items through `thread/inject_items`. - The transcript remains the fallback, for pi, the Gemini CLI, and any native + The transcript remains the fallback, for pi, the Gemini CLI, qwen, and any native path that fails. The session says which way it went, and `session.nativeContinuation` / `CODEOID_NATIVE_CONTINUATION` turns this off. Each turn now records where it ends in the backend's own conversation @@ -170,6 +170,12 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). the new backend fresh until its first prompt, so a restart in between re-seeds it instead of resuming nothing. +- **Claude's "No conversation found" recovery keeps the conversation.** When + a backing session can't be resumed, recovery used to start a blank session + and re-send only the latest prompt. It now hands the new session the + conversation before that prompt (natively where it can), and can't loop if + the new backing is missing too. + - **The context window is no longer guessed from the model id.** codeoid inferred every window from a substring table over model ids, and that table was wrong the moment a model shipped: `claude-opus-5-5` inferred to the 200k fallback while every turn result stated 1,000,000. The percent-of-window display and the history seed sized for a fork or a provider switch were both built on that guess. diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index d27e5007..07a4b6be 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -25,7 +25,7 @@ Codeoid is the latter, which is why the rows below weigh memory, attribution, an | **Multi-harness** (multiple agent backends) | ❌ Claude only | ❌ | ❌ | ❌ | ✅ swap/combine harnesses in one session (also Cursor, OpenCode, Hermes) | ✅ 12 built-in + any CLI agent, as terminal processes | ✅ Claude, Codex, Gemini, OpenAI, pi, Gemini CLI — per session + fork across backends | | **Cost of adding a backend** | — | — | — | ~ model config | PTY wrapper per harness (tmux) | ~8-line declarative manifest (command + prompt transport) | 300–1,300 LOC native provider, or a thin ACP adapter | | **Structured agent event model** (tool calls, subagent lineage, task graph) | ✅ native SDK | — | — | ❌ | ❌ PTY bytes | ~ terminal path is opaque bytes; ACP path is structured but drops subagent identity, task dependencies, and workflows | ✅ canonical `ProviderEvent` — tool calls, `parentToolUseId` lineage, normalized usage | -| **Cross-backend session fork** (switch harness mid-session, keep context) | ❌ | — | — | ❌ | ✅ swap/combine within one session | ❌ a session is bound to the agent that started it | ✅ `session.set_provider` / `session.fork`; Claude and Codex continue natively (their own fork, or native history codeoid builds), others from a transcript | +| **Cross-backend session fork** (switch harness mid-session, keep context) | ❌ | — | — | ❌ | ✅ swap/combine within one session | ❌ a session is bound to the agent that started it | ✅ `session.set_provider` / `session.fork`; Claude and Codex continue natively (their own fork, or native history codeoid builds); OpenAI, Gemini and llama.cpp replay full history natively every turn; pi, the Gemini CLI and Qwen from a transcript | | **Built-in diff review + PR workflow** | ❌ | ✅ | ✅ | ~ | ❌ | ✅ editable diff viewer, inline comments, commit/push, PR status | ❌ not built — roadmap | | **Remote multi-machine reach** | ~ hosted Remote Control (Max plan) | ❌ | ❌ | ❌ | ~ server deploy | ✅ host registry + cloud relay routes by host key; wakes offline hosts | ~ one daemon, reached directly or through Telegram; no relay | | **Scheduled / unattended automations** | ~ routines (research preview): Anthropic cloud only, 1-hour minimum interval | ❌ | ~ Automations, in Cursor's cloud | ❌ | ~ | ✅ cron-scheduled agent sessions, versioned | ✅ [routines](routines.md): cron / interval / API trigger, a zero-cost check gate, `[SILENT]`, per-run + daily spend caps, guarded or budgeted approvals, failure auto-pause — on your own machine, any backend | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index a6497894..2d768726 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -19,7 +19,7 @@ Pick a harness when you create a session — the New Session modal in the web UI #### How a fork, switch or rewind carries the conversation -The files come back exactly: a fork gets a git worktree at the turn's checkpoint. +Files are the checkpoint's business: a fork in its own worktree starts from the turn's checkpoint, and a rewind can restore files too. The conversation is handed over in the backend's own form wherever the backend allows it, and as a text transcript only where it doesn't: | Going to | Same backend (fork, rewind) | From another backend (fork, switch) | @@ -31,9 +31,10 @@ The conversation is handed over in the backend's own form wherever the backend a Reasoning never crosses vendors (Claude's is signed, OpenAI's encrypted). History codeoid builds that doesn't fit the target's window loses its oldest turns, with a notice; a native fork isn't used when the source was already running near the target's window, nor from a turn that ended in an error, so a fork is the way out of an overflowed session rather than a copy of it. -The session says which way it went (`↪ … continues its own conversation`, `↪ … was given … as its own history`, or `↪ … carried as a text transcript`), and if a native path fails it falls back to the transcript and says so. -Two cases always use codeoid's own record rather than the backend's: history that a `tool_result` [hook](hooks.md) redacted (a native fork would copy the backend's unredacted log), and turns rebuilt from a displayed transcript such as an imported session (they aren't the model's own words, so they stay quoted). -Like every Claude session codeoid runs, a session it writes or forks for Claude lives under `CLAUDE_CONFIG_DIR` (default `~/.claude/projects/`), owner-only, and is subject to Claude Code's own `cleanupPeriodDays`. +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`), and if a native path fails it falls back to the transcript and says so. +History a `tool_result` [hook](hooks.md) redacted is never forked natively — a fork would copy the backend's unredacted log — so codeoid's redacted record is used instead. +Turns rebuilt from a displayed transcript, such as an imported session, are never built into native history (they aren't the model's own words, so they stay quoted); a native fork from a real turn after them is still used. +Like every Claude session codeoid runs, a session it writes or forks for Claude lives under `projects/` in `CLAUDE_CONFIG_DIR` (default `~/.claude`), and is subject to Claude Code's own `cleanupPeriodDays`. `session.nativeContinuation: false` (or `CODEOID_NATIVE_CONTINUATION=false`) keeps everything on the transcript. Native continuation is tried before the `CODEOID_CONTEXT_STRATEGY` seed (`transcript` or `vws`), which still applies to every backend it doesn't cover. diff --git a/docs/providers-pi.md b/docs/providers-pi.md index 6cb95ec2..284a0782 100644 --- a/docs/providers-pi.md +++ b/docs/providers-pi.md @@ -78,6 +78,7 @@ The bridge announces itself on session start; **if it fails to load, turns fail `/provider pi` (or `session.set_provider`) swaps a live session's backend while keeping the session id, scrollback, transcript, and identity. The conversation so far is carried into the incoming backend as a structured-text transcript — tool calls rendered as structured blocks (name, input JSON, fenced output), oldest turns dropped first past ~24k chars. Stateless backends (gemini, openai) go further: they replay the history in their native function-call structure on every request. -Warm backends (claude, pi) stay a faithful transcript, **not** a native continuation — neither the Claude Agent SDK nor pi RPC accepts synthesized native-history injection, so prompt-cache state and extension state don't cross. +Claude and Codex continue natively — their own fork on the same backend, native history from another (see [how a fork, switch or rewind carries the conversation](FEATURES.md#how-a-fork-switch-or-rewind-carries-the-conversation)). +pi stays a faithful transcript, **not** a native continuation: its RPC has no history-import or fork entry point, so prompt-cache state and extension state don't cross. Switching resets the model to the new backend's default, and mid-turn switches are rejected — interrupt first. `/provider claude` brings the session back the same way. diff --git a/src/daemon/providers/canonical.ts b/src/daemon/providers/canonical.ts index b787c72a..e5dfbef6 100644 --- a/src/daemon/providers/canonical.ts +++ b/src/daemon/providers/canonical.ts @@ -597,7 +597,7 @@ function toolCallToSeedText(tc: CanonicalToolCall): string { * the FALLBACK: a backend that can take the conversation in its own form does * so through `continueNatively` (#387) — Claude and Codex fork their own * conversation, or take native history codeoid builds — and this transcript - * is used only where that isn't available (pi, the Gemini CLI) or fails. + * is used only where that isn't available (pi, the Gemini CLI, qwen) or fails. * Oldest turns are dropped first when the budget is exceeded (recent context * matters most), with an elision note. * diff --git a/src/daemon/providers/claude/native-history.ts b/src/daemon/providers/claude/native-history.ts index 4e71dc0c..99299e17 100644 --- a/src/daemon/providers/claude/native-history.ts +++ b/src/daemon/providers/claude/native-history.ts @@ -126,7 +126,7 @@ export function toClaudeSessionEntries( } /** Claude Code's config dir, as the backend subprocess will see it. */ -export function claudeConfigDir(env: Record = process.env): string { +function claudeConfigDir(env: Record = process.env): string { return env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); } diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index d787ba8a..b400b564 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -34,8 +34,11 @@ * - `item/tool/requestUserInput` → requestUserInput (session.ui_request) * - text/reasoning deltas stream; command/fileChange/mcp items surface * as tool records; usage from turn/completed - * - seedFromHistory prepends renderHistorySeed to the first prompt (the - * warm-backend ceiling; codex threads only resume codex's own state) + * - continueNatively (#387): the thread is created by `thread/fork` at a + * recorded turn (same backend) or `thread/start` + `thread/inject_items` + * (native history from another backend); thread ids are persisted + * - seedFromHistory prepends renderHistorySeed to the first prompt — the + * fallback when the native path is off or fails * * Non-gated items (codex ran something its policy considers trusted, e.g. * read-only commands) are recorded retrospectively at item/completed as a @@ -187,6 +190,17 @@ function codexPolicies(mode?: SessionMode): { approvalPolicy: string; sandboxMod * SandboxPolicyDeserialize` at turn/start. Shapes verified live against * @openai/codex@0.144.1 `generate-ts` + a real turn. */ +/** What a new thread (started or forked) is created with: cwd, policies, the turn's instructions. */ +function threadConfig(opts: TurnOpts): Record { + const { approvalPolicy, sandboxMode } = codexPolicies(opts.mode); + return { + cwd: opts.workdir, + approvalPolicy, + sandboxPolicy: sandboxPolicyWire(sandboxMode, opts.workdir), + ...(opts.systemPromptAppend ? { developerInstructions: opts.systemPromptAppend } : {}), + }; +} + function sandboxPolicyWire(mode: SandboxMode, workdir: string): Record { switch (mode) { case "read-only": @@ -418,13 +432,7 @@ export class CodexProvider implements SessionProvider { const pending = this.#pendingNative; this.#pendingNative = null; if (!pending || !this.#proc) return null; - const { approvalPolicy, sandboxMode } = codexPolicies(opts.mode); - const config = { - cwd: opts.workdir, - approvalPolicy, - sandboxPolicy: sandboxPolicyWire(sandboxMode, opts.workdir), - ...(opts.systemPromptAppend ? { developerInstructions: opts.systemPromptAppend } : {}), - }; + const config = threadConfig(opts); try { if (pending.kind === "fork") { const forked = (await this.#proc.request("thread/fork", { @@ -679,20 +687,19 @@ export class CodexProvider implements SessionProvider { this.#proc.notify("initialized"); } - // A native continuation (#387) creates the thread itself. if (this.#pendingNative) { + // A native continuation (#387) creates the thread itself; if it fails, + // a fresh thread below takes the transcript instead. const native = await this.#applyPendingNative(opts); if (native) { this.#adoptThread(native); await this.#reportModelsOnce(); return; } - } - - // Resume the prior thread when the backing id looks like one codex - // minted; otherwise (first run: backing id is the codeoid session id) - // start fresh. Resume failure degrades to a fresh thread — never wedge. - else if (this.#backingSessionId !== this.#sessionId) { + } else if (this.#backingSessionId !== this.#sessionId) { + // Resume the prior thread when the backing id looks like one codex + // minted; otherwise (first run: backing id is the codeoid session id) + // start fresh. Resume failure degrades to a fresh thread — never wedge. try { const resumed = (await this.#proc.request("thread/resume", { threadId: this.#backingSessionId, @@ -705,13 +712,7 @@ export class CodexProvider implements SessionProvider { /* fall through to thread/start */ } } - const { approvalPolicy, sandboxMode } = codexPolicies(opts.mode); - const started = (await this.#proc.request("thread/start", { - cwd: opts.workdir, - approvalPolicy, - sandboxPolicy: sandboxPolicyWire(sandboxMode, opts.workdir), - ...(opts.systemPromptAppend ? { developerInstructions: opts.systemPromptAppend } : {}), - })) as { thread?: { id?: string } }; + const started = (await this.#proc.request("thread/start", threadConfig(opts))) as { thread?: { id?: string } }; if (!started.thread?.id) throw new Error("codex thread/start returned no thread id"); this.#adoptThread(started.thread.id); await this.#reportModelsOnce(); diff --git a/src/daemon/session.ts b/src/daemon/session.ts index fbe0caf0..a87e3999 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -21,6 +21,7 @@ import type { McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent- import { type ProviderEvent, type NativeContinuationResult, + type NativeTurnRef, type NormalizedTurnResult, type TurnRun, type ToolApprovalFn, @@ -763,6 +764,8 @@ export class Session { #turnRedacted = false; /** Context size (tokens) the source of the next fork was last running at — see #continueNatively. */ #forkSourceContextTokens = 0; + /** How the last seed carried the conversation, for a notice posted after it (a switch). */ + #lastContinuation: { via: "fork" | "history" | "transcript"; messages: number } | null = null; /** A rewind is in progress: nothing may wake the agent meanwhile. */ #rewinding = false; /** @@ -1461,7 +1464,7 @@ export class Session { // Offer the canonical history to the incoming provider. Best-effort by // contract: a seed failure degrades to an unseeded switch, never a // wedged session. - const seeded = await this.#seedProviderFromHistory(undefined, { announce: true }); + const seeded = await this.#seedProviderFromHistory(); // Until the first prompt, a backend that took a transcript (or will build // its thread lazily) holds nothing: a restart must re-seed, not resume. if (this.#accumulator.history.length > 0) this.#markBackingFresh(!this.#nativeBackingReady); @@ -1482,6 +1485,8 @@ export class Session { ); this.#persistAndBuffer(infoMsg); this.#broadcastRaw(infoMsg); + // After the switch line, so the two read in order. + if (this.#lastContinuation) this.#announceContinuation(this.#lastContinuation.via, this.#lastContinuation.messages); // Writes the new providerId into the transcript meta (restart resume) // and refreshes every client's SessionInfo. this.#persistStatus(); @@ -3215,16 +3220,22 @@ export class Session { } /** - * Offer the accumulated canonical history to the current provider — - * shared by `switchProvider` and `session.fork`. Stateless backends no-op - * (they consume TurnOpts.history every turn); warm backends (claude, pi, - * codex) prepend a rendered transcript to their first prompt. Best-effort - * by contract: a throw degrades to an unseeded start, never a wedge. - * Returns whether a seed was actually applied. + * Hand the canonical history to the current provider, which starts from + * nothing: a backend switch, a fork, a rewind, Claude's backing recovery, + * and a restart of a backend left fresh. Native first (#387) — Claude and + * Codex fork their own conversation or take native history — then the + * context strategy: stateless backends no-op (they read TurnOpts.history + * every turn), warm ones get a transcript in their first prompt. + * Best-effort by contract: a throw degrades to an unseeded start, never a + * wedge. Returns whether anything was applied. */ async #seedProviderFromHistory( retry?: { historyLength: number; budgetChars: number; attempts: number }, - /** Tell the session's clients which way the conversation was carried (fork, switch). */ + /** + * `announce`: tell the session's clients which way the conversation was + * carried (fork, switch). `upTo`: seed only the first N turns (recovery, + * where the last turn is the prompt about to be re-run). + */ opts: { announce?: boolean; upTo?: number } = {}, ): Promise { const history = retry @@ -3234,6 +3245,7 @@ export class Session { : this.#accumulator.history; this.#pendingSeed = null; this.#nativeBackingReady = false; + this.#lastContinuation = null; if (history.length === 0) return false; const s = this.#config?.session; const budgetChars = @@ -3248,6 +3260,7 @@ export class Session { if (!retry) { const native = await this.#continueNatively(history, budgetChars); if (native) { + this.#lastContinuation = { via: native.via, messages: history.length }; if (opts.announce) this.#announceContinuation(native.via, history.length); return true; } @@ -3274,6 +3287,7 @@ export class Session { this.#pendingSeed = { historyLength: history.length, budgetChars, attempts: retry?.attempts ?? 0 }; // Only a warm backend that actually took the transcript: a stateless // one already reads the whole history natively every turn. + this.#lastContinuation = { via: "transcript", messages: history.length }; if (opts.announce) this.#announceContinuation("transcript", history.length); } return outcome.applied; @@ -3353,7 +3367,7 @@ export class Session { ? known : history.reduce((n, t) => n + canonicalTurnChars(t), 0) / (this.#config?.session?.seedCharsPerToken ?? 2); const window = targetContextWindow(this.#provider.id, this.#model, this.#knownWindow(this.#provider.id)); - return tokens <= window * 0.85; + return tokens <= window * FORK_FIT_FRACTION; } /** How full the context was on this session's last turn (tokens) — what a fork of it starts at. */ @@ -3402,12 +3416,13 @@ export class Session { /** Say how a fork or switch carried the conversation to this backend. */ #announceContinuation(via: "fork" | "history" | "transcript", turns: number): void { const p = this.#provider.id; + const n = `${turns} message${turns === 1 ? "" : "s"}`; const text = via === "fork" ? `↪ ${p} continues its own conversation from this point: nothing was converted or cut.` : via === "history" - ? `↪ The conversation (${turns} turn${turns === 1 ? "" : "s"}) was given to ${p} as its own history, tool calls included. Reasoning from another backend isn't carried.` - : `↪ ${p} can't take the conversation in its own form, so it was carried as a text transcript of ${turns} turn${turns === 1 ? "" : "s"}.`; + ? `↪ The conversation (${n}) was given to ${p} as its own history, tool calls included. Reasoning from another backend isn't carried.` + : `↪ The conversation (${n}) was carried to ${p} as a text transcript.`; const msg = this.#makeMessage("info", text, SYSTEM_IDENTITY, undefined, undefined, { event: "history.continuation", via, @@ -7023,8 +7038,14 @@ function sanitizeApprovalPatch( return undefined; } +/** + * How full the source's context may be for a native fork onto a backend: a + * fork carries all of it, and the turn about to run needs room on top. + */ +const FORK_FIT_FRACTION = 0.85; + /** A recorded native point codeoid will act on: two plain ids, nothing else. */ -function isNativeRef(ref: unknown): ref is NonNullable["native"]> { +function isNativeRef(ref: unknown): ref is NativeTurnRef { if (!ref || typeof ref !== "object") return false; const r = ref as Record; const id = (v: unknown) => typeof v === "string" && /^[A-Za-z0-9-]{8,128}$/.test(v); diff --git a/src/tests/native-continuation.test.ts b/src/tests/native-continuation.test.ts index 6c2080ee..7a0ff414 100644 --- a/src/tests/native-continuation.test.ts +++ b/src/tests/native-continuation.test.ts @@ -678,7 +678,7 @@ describe("Session: native first, transcript as the fallback", () => { await send(m, id, "one"); const info = await fork(m, id); expect(made.at(-1)!.seededHistory?.map((t) => t.content)).toEqual(["one", "a1"]); - expect(notes(m, info.id).some((n) => n.includes("carried as a text transcript"))).toBe(true); + expect(notes(m, info.id).some((n) => n.includes("as a text transcript"))).toBe(true); } }); @@ -700,6 +700,9 @@ describe("Session: native first, transcript as the fallback", () => { const r = await m.handle({ type: "session.set_provider", id: "p", sessionId: id, providerId: "codex" } as never, AUTH, CLIENT); expect(r.type).toBe("response.ok"); expect(made.at(-1)!.nativeRequests).toHaveLength(1); + // The switch line first, then how the conversation went over. + const said = notes(m, id); + expect(said.findIndex((n) => n.startsWith("Backend switched"))).toBeLessThan(said.findIndex((n) => n.includes("given to codex"))); await Bun.sleep(20); const meta = JSON.parse(readFileSync(transcript.metaPath(id), "utf8")) as { backingFresh?: boolean }; expect(meta.backingFresh).toBe(true);