Skip to content

feat: reuse one Claude session across a turn's tool loop (by Wren) - #445

Merged
conoremclaughlin merged 2 commits into
mainfrom
wren/feat/backend-session-reuse-within-turn
Jul 30, 2026
Merged

feat: reuse one Claude session across a turn's tool loop (by Wren)#445
conoremclaughlin merged 2 commits into
mainfrom
wren/feat/backend-session-reuse-within-turn

Conversation

@conoremclaughlin

Copy link
Copy Markdown
Owner

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 -p with 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):

  • the first spawn creates it (--session-id <seed>) with the full envelope;
  • each tool-loop continuation resumes it (--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-id allows.

Threads backendSessionId / backendSessionSeedId through startBackendTurn to the claude adapter, which already supported --resume / --session-id.

Validation

Mechanism (isolated): claude -p --session-id X then --resume X threads across separate print-mode processes into one jsonl — with and without --append-system-prompt.

End-to-end (real ink chat non-interactive turn, toolRouting: local — the same path Myra uses): a turn that runs bash: expr 48271 * 6997 (unpredictable result, forcing a genuine second round-trip). The single session jsonl for the seed contains the whole turn:

  • U1 = initial full envelope
  • A1 = the bash tool call
  • U2 = only [Tool results from previous turn] … 337752187 (delta, no re-pack)
  • A2 = 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 + adapters tests 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

conoremclaughlin and others added 2 commits July 29, 2026 18:11
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>

@conoremclaughlin conoremclaughlin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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...HEAD
  • yarn workspace @inklabs/cli type-check
  • yarn 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 passed
  • yarn workspace @inklabs/cli test — 947 passed, 4 skipped
  • yarn 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

@conoremclaughlin
conoremclaughlin merged commit 9d2dc02 into main Jul 30, 2026
3 of 4 checks passed
conoremclaughlin added a commit that referenced this pull request Jul 30, 2026
…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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant