// Q10 P1: the Cascade cursor is the design-critical state. // // E2b (research/Q10/REPORT.md §3.2) detached at stepOffset=3 with 596 chars, // let the SAME three steps grow to 16,632 chars, then resumed: // // windowStart = stepOffset (3) -> 0 steps / 0 chars <- the bug // windowStart = 0 -> 3 steps / 16,632 <- correct // // These tests script that trajectory at the producer seam. They pin the // resume contract (per-step cursors, overlap window, snapshot round-trip) and // the default-off flag. They do not need a language server: the turn's only // upstream dependency is the injected fetchSteps callback. import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { CascadeTurn, CascadeTurnCursorError, CASCADE_STEP_OVERLAP, cascadeTurnEnabled, } from '../src/cascade-turn.js'; // E2b's measured shape: 596 chars at detach, 16,632 after the turn finished, // all inside three steps. Step 2 carries the growth; steps 0/1 are stable. const DETACHED_STEP_TEXT = 'P'.repeat(396); const FINAL_STEP_TEXT = 'P'.repeat(396) + 'G'.repeat(16632 - 596); const FULL_TEXT = 'A'.repeat(100) + 'B'.repeat(100) + FINAL_STEP_TEXT; const DETACHED_TEXT = 'A'.repeat(100) + 'B'.repeat(100) + DETACHED_STEP_TEXT; const DETACHED_THINKING = 't'.repeat(72); const FINAL_THINKING = 't'.repeat(81); function plannerStep(responseText, { thinking = '', modifiedText = '', status = 3 } = {}) { return { responseText, thinking, modifiedText, status, toolCalls: [] }; } function e2bTrajectory() { let steps = [ plannerStep('A'.repeat(100)), plannerStep('B'.repeat(100)), plannerStep(DETACHED_STEP_TEXT, { thinking: DETACHED_THINKING }), ]; return { steps, grow() { steps = [ plannerStep('A'.repeat(100)), plannerStep('B'.repeat(100)), plannerStep(FINAL_STEP_TEXT, { thinking: FINAL_THINKING }), ]; }, fetch(start) { return steps.slice(start); }, }; } async function consumeAll(turn, read) { for (let i = 0; i < read.steps.length; i++) { await turn.consumeStep(read.steps[i], read.windowStart + i); } } describe('CascadeTurn lazy resume (Q10 E2b)', () => { it('returns the full text that grew inside the last step after detach', async () => { const upstream = e2bTrajectory(); const original = new CascadeTurn({ cascadeId: 'q10-e2b' }); const firstRead = await original.readWindow(start => upstream.fetch(start)); assert.equal(firstRead.windowStart, 0); assert.equal(firstRead.steps.length, 3); assert.equal(firstRead.advanced, true); assert.equal(original.stepOffset, 3, 'the three counted steps are the absolute next offset'); await consumeAll(original, firstRead); assert.equal(original.text, DETACHED_TEXT); assert.equal(original.totalYielded, 596); assert.equal(original.thinking, DETACHED_THINKING); const snapshot = original.snapshot(); assert.equal(snapshot.stepOffset, 3); assert.equal(snapshot.pollFloor, 0); assert.equal(snapshot.windowStart, 0, 'the next poll window must reach behind the unread offset'); // The upstream finishes the turn while the client is detached. Nothing // new is appended: the same three steps now hold 16,632 chars. upstream.grow(); // The bug, reproduced: a resume keyed on stepOffset alone asks the LS for // steps starting at 3 and gets nothing back. const naive = new CascadeTurn({ cascadeId: 'q10-e2b', stepOffset: 3, pollFloor: 3 }); const naiveRead = await naive.readWindow(start => upstream.fetch(start)); assert.equal(naiveRead.windowStart, 3); assert.equal(naiveRead.steps.length, 0); assert.equal(naive.text, '', 'stepOffset=3 → 0 steps / 0 chars'); // The fix: restore the cursor snapshot. The window starts at 0 and the // per-step cursors emit only the 16,036 chars that arrived while detached. const resumed = CascadeTurn.fromSnapshot(JSON.parse(JSON.stringify(snapshot))); const resumedRead = await resumed.readWindow(start => upstream.fetch(start)); assert.equal(resumedRead.windowStart, 0); assert.equal(resumedRead.steps.length, 3); assert.equal(resumedRead.advanced, false, 're-reading counted steps does not advance the offset'); const chunksBefore = resumed.chunks.length; await consumeAll(resumed, resumedRead); const replayDelta = resumed.chunks.slice(chunksBefore).map(chunk => chunk.text || '').join(''); assert.equal(resumed.text, FULL_TEXT, 'the resumed turn holds the full answer, prefix included exactly once'); assert.equal(resumed.totalYielded, FULL_TEXT.length); assert.equal(replayDelta, FULL_TEXT.slice(DETACHED_TEXT.length), 'only the post-detach growth is re-emitted'); assert.equal(resumed.thinking, FINAL_THINKING); assert.equal(resumed.chunks.slice(chunksBefore).map(chunk => chunk.thinking || '').join(''), 't'.repeat(9), 'thinking resumes from its own per-step cursor'); }); it('reconstructs the window from per-step cursors when windowStart is absent', async () => { const upstream = e2bTrajectory(); const original = new CascadeTurn({ cascadeId: 'no-window' }); const firstRead = await original.readWindow(start => upstream.fetch(start)); await consumeAll(original, firstRead); const snapshot = original.snapshot(); delete snapshot.windowStart; // older entry shape: cursors only upstream.grow(); const resumed = CascadeTurn.fromSnapshot(snapshot); assert.equal(resumed.nextWindowStart(), 0, 'stepOffset - overlap reaches back into the counted steps'); const resumedRead = await resumed.readWindow(start => upstream.fetch(start)); await consumeAll(resumed, resumedRead); assert.equal(resumed.text, FULL_TEXT); }); it('refuses a snapshot without per-step cursors instead of resuming at stepOffset', () => { assert.throws( () => CascadeTurn.fromSnapshot({ stepOffset: 3, pollFloor: 0 }), (err) => { assert.ok(err instanceof CascadeTurnCursorError); assert.equal(err.code, 'CASCADE_TURN_CURSOR'); assert.match(err.message, /yieldedByStep\/thinkingByStep/); assert.match(err.message, /stepOffset=3/); return true; }, ); assert.throws( () => CascadeTurn.fromSnapshot({ stepOffset: 3, pollFloor: 0, yieldedByStep: [] }), CascadeTurnCursorError, 'both cursor maps are required', ); }); it('round-trips a JSON-serialized snapshot without losing cursors or buffered chunks', async () => { const original = new CascadeTurn({ cascadeId: 'round-trip' }); const step = plannerStep('hello', { thinking: 'why' }); await original.consumeStep(step, 0); const snapshot = JSON.parse(JSON.stringify(original.snapshot())); const restored = CascadeTurn.fromSnapshot(snapshot); assert.equal(restored.cascadeId, 'round-trip'); assert.equal(restored.stepOffset, 0); assert.equal(restored.text, 'hello'); assert.equal(restored.thinking, 'why'); assert.deepEqual([...restored.yieldedByStep.entries()], [[0, 5]]); assert.deepEqual([...restored.thinkingByStep.entries()], [[0, 3]]); }); }); describe('CascadeTurn overlap window', () => { it('re-reads a bounded overlap instead of the whole trajectory and emits only growth', async () => { const steps = Array.from({ length: 20 }, (_, i) => plannerStep(`S${i}`)); const requested = []; const turn = new CascadeTurn({ cascadeId: 'overlap' }); const read = await turn.readWindow((start) => { requested.push(start); return steps.slice(start); }); await consumeAll(turn, read); assert.equal(turn.text, steps.map(step => step.responseText).join('')); // Step 19 grows after it was counted. The next window starts eight steps // back, not at the unread offset (20), so the growth is visible. steps[19] = plannerStep('S19-plus-growth'); const next = await turn.readWindow((start) => { requested.push(start); return steps.slice(start); }); assert.deepEqual(requested, [0, 20 - CASCADE_STEP_OVERLAP]); assert.equal(next.windowStart, 12); assert.equal(next.advanced, false, 'no new step appeared; only an existing one grew'); const chunksBefore = turn.chunks.length; await consumeAll(turn, next); assert.equal(turn.chunks.slice(chunksBefore).map(chunk => chunk.text || '').join(''), '-plus-growth'); assert.equal(turn.text, 'S0S1S2S3S4S5S6S7S8S9S10S11S12S13S14S15S16S17S18S19-plus-growth'); }); it('never reaches behind pollFloor on a resumed cascade', async () => { const requested = []; const turn = new CascadeTurn({ cascadeId: 'resume-floor', stepOffset: 5, pollFloor: 5 }); assert.equal(turn.nextWindowStart(), 5, 'a fresh turn on an existing cascade starts at its floor'); const read = await turn.readWindow((start) => { requested.push(start); return [plannerStep('R5'), plannerStep('R6')].slice(start - 5); }); assert.deepEqual(requested, [5]); assert.equal(read.steps.length, 2); assert.equal(turn.stepOffset, 7); assert.equal(turn.nextWindowStart(), 5, 'overlap is clamped to the turn floor'); }); it('retries the same resume window after a transient fetch failure', async () => { const original = new CascadeTurn({ cascadeId: 'flaky-resume' }); const read = await original.readWindow(() => [plannerStep('abc')]); await consumeAll(original, read); const resumed = CascadeTurn.fromSnapshot(original.snapshot()); await assert.rejects(resumed.readWindow(() => { throw new Error('transport reset'); }), /transport reset/); assert.equal(resumed.nextWindowStart(), 0, 'the pending resume window survives a failed read'); const retry = await resumed.readWindow(() => [plannerStep('abcdef')]); assert.equal(retry.windowStart, 0); assert.equal(resumed.text, 'abc', 'no delta is emitted until the caller consumes the retried steps'); await consumeAll(resumed, retry); assert.equal(resumed.text, 'abcdef'); }); }); describe('CascadeTurn final sweep', () => { it('tops up responseText and only appends modifiedText when it is a strict extension', async () => { const turn = new CascadeTurn({ cascadeId: 'polish' }); await turn.consumeFinalStep(plannerStep('abc'), 0); assert.equal(turn.text, 'abc'); await turn.consumeFinalStep(plannerStep('abc', { modifiedText: 'abcd' }), 0); assert.equal(turn.text, 'abcd', 'a strict extension is appended'); await turn.consumeFinalStep(plannerStep('abc', { modifiedText: 'Xbcdef' }), 0); assert.equal(turn.text, 'abcd', 'a rewritten prefix is not spliced onto the stream'); }); }); describe('CascadeTurn feature flag', () => { it('is off unless WINDSURFAPI_CASCADE_TURN is exactly 1', () => { assert.equal(cascadeTurnEnabled({}), false); assert.equal(cascadeTurnEnabled({ WINDSURFAPI_CASCADE_TURN: '0' }), false); assert.equal(cascadeTurnEnabled({ WINDSURFAPI_CASCADE_TURN: 'true' }), false); assert.equal(cascadeTurnEnabled({ WINDSURFAPI_CASCADE_TURN: '' }), false); assert.equal(cascadeTurnEnabled({ WINDSURFAPI_CASCADE_TURN: '1' }), true); }); });