diff --git a/packages/cli/src/commands/chat.session-reuse.test.ts b/packages/cli/src/commands/chat.session-reuse.test.ts new file mode 100644 index 00000000..863ff110 --- /dev/null +++ b/packages/cli/src/commands/chat.session-reuse.test.ts @@ -0,0 +1,386 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { isResumeFailedNoSession, findLastBackendSessionId, envelopeShapeKey } from './chat.js'; + +/** + * Stage 2A — across-turn provider session reuse. + * + * The seed/resume decision lives inside runUserTurn (chat.ts), a large closure + * that isn't unit-addressable directly. Mirror the exact decision as a pure + * helper here (same idiom as tool-loop stop decision) so the across-turn + + * compaction-reset behavior is guarded directly, plus test the exported + * resume-not-found detector against real backend stderr shapes. + */ + +// Mirrors chat.ts runUserTurn: first spawn of the session SEEDS a fresh +// provider session (full envelope); every later turn RESUMES it (delta only). +// A compaction resets activeId to undefined, forcing the next turn to re-seed. +function decideProviderSession( + canReuse: boolean, + activeId: string | undefined, + mintId: () => string +): { + seedId: string | undefined; + resumeId: string | undefined; + nextActiveId: string | undefined; + sendDelta: boolean; + sendFullEnvelope: boolean; +} { + const resume = canReuse && activeId !== undefined; + let seedId: string | undefined; + let nextActiveId = activeId; + if (canReuse && !resume) { + seedId = mintId(); + nextActiveId = seedId; + } + return { + seedId, + resumeId: resume ? activeId : undefined, + nextActiveId, + sendDelta: resume, + sendFullEnvelope: !resume, // seed turns and stateless backends re-pack the envelope + }; +} + +describe('across-turn provider session decision', () => { + const ids = ['S1', 'S2', 'S3']; + const minter = () => { + let i = 0; + return () => ids[i++]!; + }; + + it('first turn SEEDS a fresh session with the full envelope', () => { + const d = decideProviderSession(true, undefined, minter()); + expect(d.seedId).toBe('S1'); + expect(d.resumeId).toBeUndefined(); + expect(d.nextActiveId).toBe('S1'); + expect(d.sendDelta).toBe(false); + expect(d.sendFullEnvelope).toBe(true); + }); + + it('subsequent turn RESUMES the live session with delta only', () => { + const d = decideProviderSession(true, 'S1', minter()); + expect(d.seedId).toBeUndefined(); + expect(d.resumeId).toBe('S1'); + expect(d.nextActiveId).toBe('S1'); + expect(d.sendDelta).toBe(true); + expect(d.sendFullEnvelope).toBe(false); + }); + + it('stateless backends (canReuse=false) never seed/resume — always full envelope', () => { + const d = decideProviderSession(false, undefined, minter()); + expect(d.seedId).toBeUndefined(); + expect(d.resumeId).toBeUndefined(); + expect(d.nextActiveId).toBeUndefined(); + expect(d.sendDelta).toBe(false); + expect(d.sendFullEnvelope).toBe(true); + }); + + it('a multi-turn conversation reuses ONE session id across turns', () => { + const mint = minter(); + let active: string | undefined; + const used: Array<{ seed?: string; resume?: string; delta: boolean }> = []; + for (let turn = 0; turn < 3; turn++) { + const d = decideProviderSession(true, active, mint); + active = d.nextActiveId; + used.push({ seed: d.seedId, resume: d.resumeId, delta: d.sendDelta }); + } + // Turn 1 seeds S1; turns 2 and 3 resume S1 with deltas — one coherent jsonl. + expect(used[0]).toEqual({ seed: 'S1', resume: undefined, delta: false }); + expect(used[1]).toEqual({ seed: undefined, resume: 'S1', delta: true }); + expect(used[2]).toEqual({ seed: undefined, resume: 'S1', delta: true }); + }); + + it('ink-owned compaction rolls the provider session: next turn seeds a NEW id', () => { + const mint = minter(); + let active: string | undefined; + + // Turn 1: seed S1 + let d = decideProviderSession(true, active, mint); + active = d.nextActiveId; + expect(active).toBe('S1'); + + // Turn 2: resume S1 + d = decideProviderSession(true, active, mint); + expect(d.resumeId).toBe('S1'); + active = d.nextActiveId; + + // ink compacts the ledger → reset the live provider session id. + active = undefined; + + // Turn 3: with the summary in the ledger, seed a FRESH session (S2 ≠ S1). + d = decideProviderSession(true, active, mint); + expect(d.seedId).toBe('S2'); + expect(d.sendFullEnvelope).toBe(true); // fresh session gets the compacted summary + expect(d.nextActiveId).toBe('S2'); + }); +}); + +describe('isResumeFailedNoSession', () => { + it('detects claude "session not found"', () => { + expect(isResumeFailedNoSession('Error: session not found: abc-123')).toBe(true); + }); + + it('detects "No such session" case-insensitively', () => { + expect(isResumeFailedNoSession('No such session')).toBe(true); + expect(isResumeFailedNoSession('NO SUCH SESSION')).toBe(true); + }); + + it('is false for unrelated stderr (provider stall, econnreset)', () => { + expect(isResumeFailedNoSession('ECONNRESET while streaming')).toBe(false); + expect(isResumeFailedNoSession('request timed out')).toBe(false); + }); + + it('is false for empty/whitespace stderr', () => { + expect(isResumeFailedNoSession('')).toBe(false); + expect(isResumeFailedNoSession(' ')).toBe(false); + }); +}); + +describe('findLastBackendSessionId (cross-process recovery)', () => { + let dir: string; + const writeTranscript = (events: object[]): string => { + dir = mkdtempSync(join(tmpdir(), 'sb-transcript-')); + const path = join(dir, 'transcript.jsonl'); + writeFileSync(path, events.map((e) => JSON.stringify(e)).join('\n') + '\n'); + return path; + }; + + afterEach(() => { + if (dir) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // best effort + } + } + }); + + it('returns undefined when the transcript file does not exist', () => { + expect(findLastBackendSessionId(join(tmpdir(), 'does-not-exist-xyz.jsonl'))).toBeUndefined(); + expect(findLastBackendSessionId('')).toBeUndefined(); + }); + + it('recovers the last backend_session id so the next process resumes it', () => { + const path = writeTranscript([ + { type: 'user', content: 'hi' }, + { type: 'backend_session', id: 'sess-1' }, + { type: 'user', content: 'more' }, + { type: 'backend_session', id: 'sess-2' }, + ]); + expect(findLastBackendSessionId(path)).toBe('sess-2'); + }); + + it('returns undefined when there is no backend_session marker', () => { + const path = writeTranscript([ + { type: 'user', content: 'hi' }, + { type: 'system_turn', content: 'heartbeat' }, + ]); + expect(findLastBackendSessionId(path)).toBeUndefined(); + }); + + it('a compaction AFTER the last seed clears the candidate (roll to fresh)', () => { + // ink compacted and the process ended before seeding again — the next + // process must NOT resume the pre-compaction session (it would drag the + // pre-compaction window back in). Seed fresh instead. + const path = writeTranscript([ + { type: 'backend_session', id: 'pre-compaction' }, + { type: 'user', content: 'lots of turns' }, + { type: 'compaction', summary: '[summary]' }, + ]); + expect(findLastBackendSessionId(path)).toBeUndefined(); + }); + + it('a seed AFTER a compaction is the live session (re-established)', () => { + const path = writeTranscript([ + { type: 'backend_session', id: 'pre-compaction' }, + { type: 'compaction', summary: '[summary]' }, + { type: 'backend_session', id: 'post-compaction' }, + { type: 'user', content: 'next turn' }, + ]); + expect(findLastBackendSessionId(path)).toBe('post-compaction'); + }); + + it('a context_evict AFTER the last seed clears the candidate', () => { + // /evict or evict_context removed entries; a resumed native session would + // still hold the evicted content, so recovery must NOT resume it. + const path = writeTranscript([ + { type: 'backend_session', id: 'pre-evict' }, + { type: 'user', content: 'stuff' }, + { type: 'context_evict', actor: 'sb', refs: [{ hash: 'h' }] }, + ]); + expect(findLastBackendSessionId(path)).toBeUndefined(); + }); + + it('a context_trim AFTER the last seed clears the candidate', () => { + const path = writeTranscript([ + { type: 'backend_session', id: 'pre-trim' }, + { type: 'context_trim', reason: 'manual' }, + ]); + expect(findLastBackendSessionId(path)).toBeUndefined(); + }); + + it('a seed AFTER an eviction is the live session (re-established)', () => { + const path = writeTranscript([ + { type: 'backend_session', id: 'old' }, + { type: 'context_evict', actor: 'user', refs: [{ hash: 'h' }] }, + { type: 'backend_session', id: 'post-evict' }, + ]); + expect(findLastBackendSessionId(path)).toBe('post-evict'); + }); + + it('ignores malformed lines', () => { + dir = mkdtempSync(join(tmpdir(), 'sb-transcript-')); + const path = join(dir, 'transcript.jsonl'); + writeFileSync( + path, + ['not json', JSON.stringify({ type: 'backend_session', id: 'ok-1' }), '', '{bad'].join('\n') + ); + expect(findLastBackendSessionId(path)).toBe('ok-1'); + }); +}); + +// Mirrors runUserTurn's envelope-shape check: a live provider session is +// invalidated when the current envelope shape differs from the one it was +// seeded with. A /backend switch, /tool-routing, /skill-use/clear, /model, +// /refresh, or profile change all shift the shape, so a resumed native session +// is never left stale. `canReuse` (claude-only) gates seeding independently. +function decideWithShape( + canReuse: boolean, + currentShape: string, + activeId: string | undefined, + activeShape: string | undefined, + mintId: () => string +): { + invalidated: boolean; + resume: boolean; + seedId: string | undefined; + nextActiveId: string | undefined; + nextShape: string | undefined; +} { + let id = activeId; + let shape = activeShape; + let invalidated = false; + if (id !== undefined) { + if (shape === undefined) { + shape = currentShape; // recovered from a prior process — adopt baseline + } else if (shape !== currentShape) { + id = undefined; + shape = undefined; + invalidated = true; + } + } + const resume = canReuse && id !== undefined; + let seedId: string | undefined; + if (canReuse && !resume) { + seedId = mintId(); + id = seedId; + shape = currentShape; + } + return { invalidated, resume, seedId, nextActiveId: id, nextShape: shape }; +} + +describe('provider session envelope-shape invalidation', () => { + const minter = () => { + const ids = ['S1', 'S2', 'S3']; + let i = 0; + return () => ids[i++]!; + }; + + it('/tool-routing drift (same claude backend) reseeds — Lumen P1 concrete case', () => { + const mint = minter(); + // Seed with the backend-routing envelope shape. + let d = decideWithShape(true, 'shape:backend-routing', undefined, undefined, mint); + expect(d.seedId).toBe('S1'); + // /tool-routing local changes the rendered tool instructions → new shape. + d = decideWithShape(true, 'shape:local-routing', d.nextActiveId, d.nextShape, mint); + expect(d.invalidated).toBe(true); + expect(d.resume).toBe(false); + expect(d.seedId).toBe('S2'); // fresh session carries the new envelope + }); + + it('/skill-use drift reseeds so the new skill instructions are seen', () => { + const mint = minter(); + let d = decideWithShape(true, 'shape:noskills', undefined, undefined, mint); // S1 + d = decideWithShape(true, 'shape:skill-a', d.nextActiveId, d.nextShape, mint); + expect(d.invalidated).toBe(true); + expect(d.seedId).toBe('S2'); + }); + + it('/backend claude→codex invalidates and does not resume (codex cannot reuse)', () => { + const mint = minter(); + const d1 = decideWithShape(true, 'shape:claude', undefined, undefined, mint); // S1 + const d2 = decideWithShape(false, 'shape:codex', d1.nextActiveId, d1.nextShape, mint); + expect(d2.invalidated).toBe(true); + expect(d2.resume).toBe(false); + expect(d2.seedId).toBeUndefined(); + expect(d2.nextActiveId).toBeUndefined(); + }); + + it('claude→codex→claude reseeds fresh (never resumes the pre-switch session)', () => { + const mint = minter(); + const d1 = decideWithShape(true, 'shape:claude', undefined, undefined, mint); // S1 + const onCodex = decideWithShape(false, 'shape:codex', d1.nextActiveId, d1.nextShape, mint); + const d3 = decideWithShape(true, 'shape:claude', onCodex.nextActiveId, onCodex.nextShape, mint); + expect(d3.resume).toBe(false); + expect(d3.seedId).toBe('S2'); // not S1 — intervening codex turns aren't in it + }); + + it('stable shape resumes the same session (no spurious invalidation)', () => { + const mint = minter(); + const d1 = decideWithShape(true, 'shape:claude', undefined, undefined, mint); // S1 + const d2 = decideWithShape(true, 'shape:claude', d1.nextActiveId, d1.nextShape, mint); + expect(d2.invalidated).toBe(false); + expect(d2.resume).toBe(true); + expect(d2.nextActiveId).toBe('S1'); + }); + + it('a recovered session (no baseline shape yet) adopts the shape and resumes', () => { + // Cross-process reattach: the id is recovered from the transcript but the + // shape baseline is adopted lazily on this first turn — so it resumes + // (Myra heartbeat continuity) instead of spuriously reseeding. + const mint = minter(); + const d = decideWithShape(true, 'shape:claude', 'recovered-id', undefined, mint); + expect(d.invalidated).toBe(false); + expect(d.resume).toBe(true); + expect(d.nextActiveId).toBe('recovered-id'); + expect(d.nextShape).toBe('shape:claude'); + }); +}); + +describe('envelopeShapeKey (real function)', () => { + type RT = Parameters[0]; + const base = { + backend: 'claude', + model: 'claude-sonnet-5', + toolMode: 'backend', + toolRouting: 'local', + strictTools: false, + threadKey: undefined, + activeSkills: [] as Array<{ name: string }>, + bootstrapContext: 'ctx', + }; + const key = (over: Partial): string => + envelopeShapeKey({ ...base, ...over } as unknown as RT); + + it('is stable for an identical shape', () => { + expect(key({})).toBe(key({})); + }); + + it('changes when tool routing changes (the concrete stale-tools case)', () => { + expect(key({ toolRouting: 'backend' })).not.toBe(key({ toolRouting: 'local' })); + }); + + it('changes on any envelope-shaping field, so no mutation site can be missed', () => { + const b = key({}); + expect(key({ backend: 'codex' })).not.toBe(b); + expect(key({ model: 'other-model' })).not.toBe(b); + expect(key({ toolMode: 'off' })).not.toBe(b); + expect(key({ strictTools: true })).not.toBe(b); + expect(key({ activeSkills: [{ name: 'skill-a' }] })).not.toBe(b); + expect(key({ threadKey: 'pr:1' })).not.toBe(b); + expect(key({ bootstrapContext: 'different identity context' })).not.toBe(b); + }); +}); diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index cbbc6f8c..a07d0f50 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -2193,6 +2193,57 @@ function formatBootstrapContext(result: Record, agentId: string return sections.join('\n\n'); } +/** + * Detect claude's "resume failed because the session no longer exists locally" + * signal from stderr. Mirrors the same check in the server runners + * (ink-runner.ts / claude-runner.ts) so the CLI recovers the same way. + */ +export function isResumeFailedNoSession(stderr: string): boolean { + const lower = (stderr || '').toLowerCase(); + return lower.includes('session not found') || lower.includes('no such session'); +} + +/** + * Recover the live provider-native session id from a reattached transcript so a + * fresh process (the next server heartbeat, or a reattach) resumes the SAME + * native session instead of fragmenting into a new jsonl. Returns the id of the + * last `backend_session` marker. A `compaction` marker clears the candidate: + * after ink compacts we deliberately roll to a fresh provider session, so a + * pre-compaction id must never be resumed (it would drag the pre-compaction + * window back in). + */ +export function findLastBackendSessionId(transcriptPath: string): string | undefined { + if (!transcriptPath || !existsSync(transcriptPath)) return undefined; + let content: string; + try { + content = readFileSync(transcriptPath, 'utf-8'); + } catch { + return undefined; + } + let found: string | undefined; + for (const line of content.split('\n')) { + if (!line.trim()) continue; + let event: { type?: unknown; id?: unknown }; + try { + event = JSON.parse(line) as { type?: unknown; id?: unknown }; + } catch { + continue; + } + if (event.type === 'backend_session' && typeof event.id === 'string') { + found = event.id; + } else if ( + event.type === 'compaction' || + event.type === 'context_evict' || + event.type === 'context_trim' + ) { + // A context-boundary mutation rolled the provider session. Abandon any + // prior id — a backend_session marker after this point re-establishes it. + found = undefined; + } + } + return found; +} + function buildPromptEnvelope( agentId: string, runtime: ChatRuntime, @@ -2248,6 +2299,39 @@ function buildPromptEnvelope( .join('\n'); } +/** + * A stable signature of everything buildPromptEnvelope renders that does NOT + * change per turn — the static "shape" a seeded provider session already holds: + * system framing, tool instructions (from tool mode/routing), strict flag, + * skills, thread key, and identity context. Excludes the transcript/recall/raw, + * which ARE the intended per-turn delta. When this drifts mid-session — /backend, + * /model, /tool-routing, /skill-use, /skill-clear, /refresh, profile changes — + * the resumed native session would be stale (e.g. seeded with backend + * tool-routing, then /tool-routing local leaves it without ink-tool + * instructions), so runUserTurn invalidates and reseeds. Subsumes the backend + * check (backend is part of the shape). Hashed so the stored key stays small. + * Keep this in sync with buildPromptEnvelope's static (non-transcript) fields. + */ +export function envelopeShapeKey(runtime: ChatRuntime): string { + const shape = [ + runtime.backend, + runtime.model ?? '', + runtime.toolMode, + runtime.toolRouting, + runtime.strictTools ? '1' : '0', + runtime.threadKey ?? '', + runtime.activeSkills.map((s) => s.name).join(','), + runtime.bootstrapContext ?? '', + ].join(''); + // djb2 — cheap, kept in int32 each step; collision-resistant enough to detect + // config drift (we only need change-detection, not cryptographic strength). + let hash = 5381; + for (let i = 0; i < shape.length; i++) { + hash = ((hash << 5) + hash + shape.charCodeAt(i)) | 0; + } + return (hash >>> 0).toString(36); +} + export async function runChat(options: ChatOptions): Promise { const debugFile = initSbDebug({ enabled: options.sbDebug, @@ -2807,9 +2891,36 @@ export async function runChat(options: ChatOptions): Promise { ? findLatestTranscriptForSession(runtime.sessionId) : undefined; runtime.transcriptPath = existingTranscript || ensureRuntimeTranscriptPath(runtime.sessionId); + + // ── Provider session reuse (claude only) — Stage 2 ── + // One provider-native session id per ink session, reused across turns AND + // across processes. Seeded on the first backend spawn, resumed thereafter, + // and reset at every ink-owned context-boundary change (compaction, trim, + // eviction). Recovered from the reattached transcript below so a fresh + // process (e.g. the next Myra heartbeat, which reattaches the same pcp + // session) RESUMES the same native session — the jsonl accumulates one + // coherent thread instead of fragmenting into a new file per message. ink + // owns compaction; the provider never runs its own. + let activeBackendSessionId: string | undefined; + // Signature of the envelope's static shape at the time the session was seeded + // (backend, model, tool mode/routing, strict flag, skills, thread key, + // identity context). When it drifts mid-session — /backend, /model, + // /tool-routing, /skill-use, /skill-clear, /refresh, profile changes — the + // resumed native session would be stale, so runUserTurn invalidates and + // reseeds. Subsumes the backend check (backend is part of the shape). + let activeBackendSessionShape: string | undefined; + let historyHydration: HistoryHydrationResult | null = null; if (attachedToExistingSession && existingTranscript) { const hydrated = hydrateLedgerFromTranscript(ledger, existingTranscript); + // Resume the provider session the prior process left live (delta only), + // unless a compaction/eviction rolled it — then the next turn seeds fresh. + if (runtime.backend === 'claude') { + // Recover the id only; the shape baseline is adopted on the first turn + // below, AFTER all startup mutations, so a recovered session resumes + // rather than spuriously reseeding on a startup-timing shape difference. + activeBackendSessionId = findLastBackendSessionId(existingTranscript); + } // Continue the event-id sequence from where the file left off seedTranscriptEidCounter(existingTranscript, hydrated.maxEid); sessionEvictedEntries.push(...hydrated.evictedEntries); @@ -3188,6 +3299,14 @@ export async function runChat(options: ChatOptions): Promise { if (sessionEvictedEntries.length > EVICTED_DISPLAY_MAX) { sessionEvictedEntries.splice(0, sessionEvictedEntries.length - EVICTED_DISPLAY_MAX); } + // A context-boundary mutation (SB evict_context, user /evict, system trim — + // all route through this single writer) just removed entries from ink's + // window. Roll the provider session so the next turn re-seeds from the + // post-eviction ledger; otherwise a resumed native session would still hold + // the evicted content. findLastBackendSessionId clears cross-process + // recovery on the matching markers. + activeBackendSessionId = undefined; + activeBackendSessionShape = undefined; }; const trimContextToPercent = async ( @@ -3332,6 +3451,13 @@ export async function runChat(options: ChatOptions): Promise { } } finally { compactionInFlight = false; + // ink just rolled the ledger — roll the provider session too so the next + // turn seeds a fresh native session with the summary (we compact before + // the provider ever would). No-op when nothing was compacted: the early + // returns above never reach this block. Unconditional: for non-claude + // these are already undefined. + activeBackendSessionId = undefined; + activeBackendSessionShape = undefined; } }; @@ -3862,19 +3988,64 @@ export async function runChat(options: ChatOptions): Promise { } } - let prompt = buildPromptEnvelope(agentId, runtime, ledger, raw); - - // Within-turn backend session reuse (claude only). Seed ONE backend-native - // session id for this turn: the first spawn creates it (--session-id) with - // the full envelope, and every tool-loop continuation resumes it (--resume) - // sending only the tool-results delta. This collapses a turn's N tool - // round-trips into a single coherent Claude session — the jsonl becomes the - // real thread (debuggable) instead of N fragments — and stops re-piping the - // whole transcript window on every round-trip. Other backends keep the - // stateless full-envelope-per-spawn behavior (codex/gemini can't seed a - // session id up front the way claude's --session-id allows). + // Provider session seed/resume decision (claude only). The first backend + // spawn of the session SEEDS a fresh provider session (--session-id) with + // the FULL envelope; every later turn RESUMES it (--resume) sending only + // this turn's delta — the new user message plus any passive-recall surfaced + // this turn — because the provider already holds the system prompt, tools, + // bootstrap, and prior turns. The tool-loop continuations below always + // resume the same session. This collapses the whole conversation into ONE + // coherent Claude jsonl and stops re-piping the transcript window on every + // round-trip. Stateless backends (codex/gemini) always get the full + // envelope. + // + // `canReuseBackendSession` is computed PER-TURN against the current backend + // so a mid-session /backend switch is honored (not captured once at + // startup). And a live session is invalidated when the envelope's static + // SHAPE has drifted since it was seeded — /backend, /model, /tool-routing, + // /skill-use, /skill-clear, /refresh, profile changes. Otherwise the resumed + // native session would be stale (e.g. seeded with backend tool-routing, then + // /tool-routing local leaves it without ink-tool instructions while native + // tools are disabled). On drift we reseed fresh with the new envelope. const canReuseBackendSession = runtime.backend === 'claude'; - const backendSeedId = canReuseBackendSession ? randomUUID() : undefined; + const currentEnvelopeShape = envelopeShapeKey(runtime); + if (activeBackendSessionId !== undefined) { + if (activeBackendSessionShape === undefined) { + // Recovered from a prior process — adopt this turn's shape as the + // baseline (no invalidation). Cross-process bootstrap drift is + // tolerated; only in-process drift from here triggers a reseed. + activeBackendSessionShape = currentEnvelopeShape; + } else if (activeBackendSessionShape !== currentEnvelopeShape) { + // In-process envelope drift — the resumed native session would be + // stale, so invalidate and reseed fresh with the new envelope. + activeBackendSessionId = undefined; + activeBackendSessionShape = undefined; + } + } + const resumeProviderSession = canReuseBackendSession && activeBackendSessionId !== undefined; + let seedProviderSessionId: string | undefined; + if (canReuseBackendSession && !resumeProviderSession) { + seedProviderSessionId = randomUUID(); + activeBackendSessionId = seedProviderSessionId; + activeBackendSessionShape = currentEnvelopeShape; + // Persist the seed so a later process (next heartbeat / reattach) recovers + // and RESUMES this native session instead of fragmenting into a new jsonl. + appendTranscript(runtime.transcriptPath, { + type: 'backend_session', + id: seedProviderSessionId, + }); + } + + let prompt: string; + if (resumeProviderSession) { + const recallDelta = promptHookResult.injectedEntries + .filter((e) => e.source === 'passive-recall') + .map((e) => e.content) + .join('\n\n'); + prompt = recallDelta ? `${recallDelta}\n\n${raw}` : raw; + } else { + prompt = buildPromptEnvelope(agentId, runtime, ledger, raw); + } const turnStartedAt = Date.now(); const backendGate = toolPolicy.getBackendToolGate(); @@ -3966,8 +4137,12 @@ export async function runChat(options: ChatOptions): Promise { passthroughArgs, timeoutMs: runtime.backendTurnTimeoutMs, attachmentDirs: sessionAttachmentDirs.length > 0 ? sessionAttachmentDirs : undefined, - // Seed the turn's backend session so tool-loop continuations can resume it. - ...(backendSeedId ? { backendSessionSeedId: backendSeedId } : {}), + // Seed a fresh provider session (first spawn) OR resume the live one + // (subsequent turns). Tool-loop continuations below always resume it. + ...(seedProviderSessionId ? { backendSessionSeedId: seedProviderSessionId } : {}), + ...(resumeProviderSession && activeBackendSessionId + ? { backendSessionId: activeBackendSessionId } + : {}), }); currentTurnAbort = turn.abort; inkRepl?.setAbortHandler(abortCurrentTurn); @@ -3979,6 +4154,44 @@ export async function runChat(options: ChatOptions): Promise { turnDurationSeconds = Math.max(0, Math.round((Date.now() - turnStartedAt) / 1000)); stopWaiting(); }); + // If a resumed turn failed because the provider session vanished (jsonl + // cleaned up / different machine), drop the live id so the NEXT turn seeds a + // fresh one. Within a single interactive process this is near-impossible (we + // seeded the id ourselves); the full mid-turn re-seed lands with the + // server/cross-process path. + if (resumeProviderSession && !runResult.success && isResumeFailedNoSession(runResult.stderr)) { + // The resumed provider session no longer exists locally (jsonl pruned / + // different machine). Mint a fresh native session, re-send the FULL + // envelope (the ledger already holds the history), and retry once so a + // server heartbeat still produces output instead of dying on a stale id. + // Mirrors ClaudeRunner/InkRunner's resume-not-found recovery. + const reseedId = randomUUID(); + activeBackendSessionId = reseedId; + activeBackendSessionShape = currentEnvelopeShape; + appendTranscript(runtime.transcriptPath, { type: 'backend_session', id: reseedId }); + printEvent( + chalk.yellow(' ⛁ provider session not found on resume — re-seeding a fresh native session') + ); + process.on('SIGINT', onSigintDuringTurn); + const reseedTurn = startBackendTurn({ + backend: runtime.backend, + agentId, + model: runtime.model, + prompt: buildPromptEnvelope(agentId, runtime, ledger, raw), + verbose: runtime.verbose, + passthroughArgs, + timeoutMs: runtime.backendTurnTimeoutMs, + attachmentDirs: sessionAttachmentDirs.length > 0 ? sessionAttachmentDirs : undefined, + backendSessionSeedId: reseedId, + }); + currentTurnAbort = reseedTurn.abort; + inkRepl?.setAbortHandler(abortCurrentTurn); + runResult = await reseedTurn.result.finally(() => { + currentTurnAbort = null; + inkRepl?.setAbortHandler(null); + process.off('SIGINT', onSigintDuringTurn); + }); + } sbDebugLog( 'chat', 'backend_turn_result', @@ -4391,9 +4604,10 @@ export async function runChat(options: ChatOptions): Promise { // transcript + tool instructions from the seeded turn — send ONLY the // delta. Otherwise (stateless backends) re-pack the full envelope so the // fresh spawn has the context it needs. - const continuationPrompt = canReuseBackendSession - ? continuationBody - : buildPromptEnvelope(agentId, runtime, ledger, continuationBody); + const continuationPrompt = + canReuseBackendSession && activeBackendSessionId + ? continuationBody + : buildPromptEnvelope(agentId, runtime, ledger, continuationBody); // Show continuation indicator naming the tools that just ran — this is // the SB working, not a system message @@ -4423,9 +4637,9 @@ export async function runChat(options: ChatOptions): Promise { passthroughArgs, timeoutMs: runtime.backendTurnTimeoutMs, attachmentDirs: sessionAttachmentDirs.length > 0 ? sessionAttachmentDirs : undefined, - // Resume the turn's seeded backend session so this round-trip appends to - // the same Claude thread instead of re-piping the whole window. - ...(backendSeedId ? { backendSessionId: backendSeedId } : {}), + // Resume the live provider session so this round-trip appends to the + // same Claude thread instead of re-piping the whole window. + ...(activeBackendSessionId ? { backendSessionId: activeBackendSessionId } : {}), }); currentTurnAbort = contTurn.abort; inkRepl?.setAbortHandler(abortCurrentTurn);