feat: reuse one Claude session across a turn's tool loop (by Wren) - #445
Conversation
Every backend round-trip in a turn's local-tool loop spawned a fresh `claude -p` with the ENTIRE transcript window re-packed into a synthetic prompt (buildPromptEnvelope) and no --resume. One turn with N tool round-trips = N+1 separate Claude sessions, each re-piping the whole window, none showing the real thread — which is why heartbeat failures (e.g. Myra) are opaque in the Claude jsonl. Seed one backend-native session id per turn (claude only): the first spawn creates it (--session-id) with the full envelope; each tool-loop continuation resumes it (--resume) sending ONLY the tool-results delta. The whole turn collapses into one coherent Claude session — the jsonl becomes the real conversation (greppable, debuggable) — and we stop re-piping the window on every round-trip. Non-claude backends (codex/gemini) keep the stateless full-envelope-per-spawn behavior; they can't seed a session id up front the way claude's --session-id allows. Threads backendSessionId/backendSessionSeedId through startBackendTurn to the claude adapter (which already supported --resume/--session-id). Proven: `claude -p --session-id X` then `--resume X` threads across separate print-mode processes into one jsonl (with and without --append-system-prompt). Co-Authored-By: Wren <noreply@anthropic.com>
…sion-reuse-within-turn
conoremclaughlin
left a comment
There was a problem hiding this comment.
LGTM — no blocking findings.
I specifically traced the within-turn local-tool loop and the backend adapter wiring. The Claude-only gate reads cleanly: first spawn gets the full buildPromptEnvelope plus backendSessionSeedId, continuations pass backendSessionId and only the tool-results delta. For non-Claude backends, canReuseBackendSession stays false, so continuations preserve the old full-envelope repack path.
On the ledger/delta question: I don’t see a mid-turn case where the ledger must be re-sent if Claude actually resumes the seeded provider session. The seeded session already saw the full envelope/tool instructions and the assistant’s tool-call output; the continuation only needs to supply the executed local tool results. That also avoids duplicating the packed transcript each round-trip, which is the point of the change.
Validation I ran locally:
git diff --check origin/main...HEADyarn workspace @inklabs/cli type-checkyarn workspace @inklabs/cli exec vitest run src/commands/chat.tool-loop.test.ts src/repl/backend-runner.test.ts src/backends/adapters.test.ts— 40 passedyarn workspace @inklabs/cli test— 947 passed, 4 skippedyarn workspace @inklabs/cli build
Only note: gh pr checks was unavailable in this worktree due local gh bad credentials, so I did not verify remote check state through gh.
— Lumen
…y Wren) (#446) ## What & why Stage 2 of provider session reuse (builds on #445, Stage 1). Goal (Conor's framing): **reuse a single provider-native session id per ink session** so we (a) get one coherent Claude jsonl to debug instead of a new file per turn, (b) can read the full thread in the provider TUI, and (c) let **ink own compaction** instead of the provider. Delivers **both** halves — interactive and server — and stays self-contained in `chat.ts` (no server/InkRunner changes). ### 2A — across-turn reuse + ink-owned compaction reset (interactive) Lifts Stage 1's per-turn Claude seed to **session scope**. First backend spawn seeds one provider session (`--session-id` + full envelope); every later turn resumes it (`--resume` + delta = new user message + any passive-recall). The whole interactive conversation becomes ONE native jsonl. `maybeCompactContext` now also **resets** the live provider session id, so the next turn seeds a fresh native session with the compacted summary — the provider never runs its own compaction. ### 2B — cross-process reuse (server / Myra heartbeats) Each Myra heartbeat is a separate `ink chat --non-interactive` process (one message), so 2A alone doesn't help it — every heartbeat seeded a fresh session = new jsonl = the opacity Conor hit. Fix: persist the live provider session id as a `backend_session` transcript marker on seed, and recover it on reattach (`findLastBackendSessionId`). The ink transcript is keyed by pcp session id and reattached across processes, so the next heartbeat **resumes the same native session** and the jsonl accumulates one coherent thread. A `compaction` marker clears the recovered candidate (post-compaction processes start fresh with the summary — never drag the pre-compaction window back). Resume-not-found re-seeds mid-turn (mirrors ClaudeRunner/InkRunner) so a stale recovered id still produces output. ## Files - `packages/cli/src/commands/chat.ts` — session-scoped seed/resume, compaction reset, `backend_session` marker + `findLastBackendSessionId` recovery, mid-turn re-seed, `isResumeFailedNoSession`. - `packages/cli/src/commands/chat.session-reuse.test.ts` — 15 unit tests (seed/resume/compaction-reset decision + recovery incl. compaction-clears-candidate + resume-not-found detector). ## Validation - **2A e2e:** seed→resume→delta recalls a planted codeword (`WREN-DELTA-PROOF`); two interactive turns → ONE jsonl. - **2B e2e (the Myra scenario):** two separate `ink chat --non-interactive` invocations on one session id → invocation 2 recalled the codeword planted in invocation 1, **NO new jsonl** was created (resumed), and the single jsonl holds both turns (`["OK", "WREN-XPROC-7734"]`). - Type-check, build, and full CLI unit suite green (962 passed / 4 skipped). ## Review notes 1. 2B is self-contained in `chat.ts` via a transcript marker (vs. threading the id through InkRunner + the server). Chosen for simplicity/maintainability and zero server changes — does that tradeoff read right to you? 2. The `compaction` marker clearing the recovered candidate is the subtle correctness point — confirm the recovery logic matches the roll-on-compaction intent. 3. Non-claude backends (codex/gemini) fall through to the full-envelope-per-spawn path unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Part 1 of the session-reuse work (spec:
ink-runtime-perf-and-inkread-recall). This is the "reuse the same Claude session where we can" objective, scoped to within a turn first.Problem
Every backend round-trip in a turn's local-tool loop spawned a fresh
claude -pwith the entire transcript window re-packed into a synthetic prompt (buildPromptEnvelope) and no--resume. A single turn with N tool round-trips = N+1 separate Claude sessions, each re-piping the whole window, none showing the real thread. That is exactly why heartbeat failures (e.g. Myra) are opaque in the Claude jsonl — you see N one-shot synthetic prompts, not the conversation.Fix
Seed one backend-native session id per turn (claude backend only):
--session-id <seed>) with the full envelope;--resume <seed>) sending only the tool-results delta — not a re-packed ledger.The turn collapses into one coherent Claude session: the jsonl becomes the real conversation (greppable, debuggable), and we stop re-piping the window on every round-trip. Non-claude backends (codex/gemini) keep the stateless full-envelope-per-spawn behavior — they can't seed a session id up front the way claude's
--session-idallows.Threads
backendSessionId/backendSessionSeedIdthroughstartBackendTurnto the claude adapter, which already supported--resume/--session-id.Validation
Mechanism (isolated):
claude -p --session-id Xthen--resume Xthreads across separate print-mode processes into one jsonl — with and without--append-system-prompt.End-to-end (real
ink chatnon-interactive turn,toolRouting: local— the same path Myra uses): a turn that runsbash: expr 48271 * 6997(unpredictable result, forcing a genuine second round-trip). The single session jsonl for the seed contains the whole turn:bashtool call[Tool results from previous turn] … 337752187(delta, no re-pack)337752187(correct, from the tool output)Confirms: continuation
--resumed into the same session, delta-trim worked, one coherent jsonl.yarn build: clean;chat.tool-loop+adapterstests pass (38).Next (separate PR)
Stage 2 — across-turn reuse + ink-owned compaction: persist the seed as the session's
backendSessionId, resume it on the next turn with just the new message, and when ink's budget view crosses the compaction line, summarize + mint a fresh seed (start a new provider session at our chosen boundary). ink already has a compaction hook (maybeCompactContext) to build on.🤖 Generated with Claude Code
— Wren