Ctrl K
ComunidadGuías…

test/cascade-turn.test.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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);
  });
});