test/cascade-turn-snapshot-v2.test.js
Rama master · réplica de lectura del repositorio original.
// Detach P1 — snapshot v2 (S1–S7).
//
// The v1 snapshot carried the per-step cursors but not the four fields a
// CORRECT resume needs:
// - generatorOffset: the metadata RPC is offset-scoped. Resuming from 0
// re-sums every prior turn of the cascade and over-bills it.
// - sawActive: a resumed ACTIVE turn must not wait the IDLE grace again.
// - usageByStep: the fallback when the metadata RPC fails.
// - consumedThrough: proves every counted step was actually consumed.
// Without it `fromSnapshot({stepOffset:3, cursors:[]})` is accepted and
// the resumed window re-emits steps 0–2 (a duplicated prefix).
//
// S1–S6 are RED-B against the pre-change module (the fields are undefined and
// the empty-cursor snapshot is accepted); S7 is a post-bump guard.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { CascadeTurn, CascadeTurnCursorError } from '../src/cascade-turn.js';
function step(text, { thinking = '' } = {}) {
return { responseText: text, thinking, modifiedText: '', status: 3, toolCalls: [] };
}
function consumedTurn({ generatorOffset = 0, sawActive = false } = {}) {
const turn = new CascadeTurn({ cascadeId: 'snapshot-v2', generatorOffset, sawActive });
return turn;
}
async function readAndConsume(turn, steps) {
const read = await turn.readWindow(start => steps.slice(start));
for (let i = 0; i < read.steps.length; i++) {
await turn.consumeStep(read.steps[i], read.windowStart + i);
}
return read;
}
describe('CascadeTurn snapshot v2', () => {
it('S1 carries generatorOffset (the metadata RPC must not restart at 0)', async () => {
const turn = consumedTurn({ generatorOffset: 7 });
await readAndConsume(turn, [step('a')]);
const snap = JSON.parse(JSON.stringify(turn.snapshot()));
assert.equal(snap.generatorOffset, 7,
'a resumed turn without generatorOffset asks GetCascadeTrajectoryGeneratorMetadata from offset 0 and sums prior turns');
});
it('S2 carries sawActive (no second IDLE grace after a resume)', async () => {
const turn = consumedTurn();
await readAndConsume(turn, [step('a')]);
// The client sets this when GetCascadeTrajectory reports non-IDLE.
turn.sawActive = true;
const snap = turn.snapshot();
assert.equal(snap.sawActive, true);
const restored = CascadeTurn.fromSnapshot(JSON.parse(JSON.stringify(snap)));
assert.equal(restored.sawActive, true);
});
it('S3 carries usageByStep and survives JSON', async () => {
const turn = consumedTurn();
await readAndConsume(turn, [step('a')]);
turn.usageByStep.set(0, {
inputTokens: 11, outputTokens: 7, cacheReadTokens: 3, cacheWriteTokens: 2,
});
const snap = JSON.parse(JSON.stringify(turn.snapshot()));
assert.deepEqual(snap.usageByStep, [[0, {
inputTokens: 11, outputTokens: 7, cacheReadTokens: 3, cacheWriteTokens: 2,
}]]);
const restored = CascadeTurn.fromSnapshot(snap);
assert.deepEqual([...restored.usageByStep.entries()], [[0, {
inputTokens: 11, outputTokens: 7, cacheReadTokens: 3, cacheWriteTokens: 2,
}]]);
});
it('S4 consumedThrough equals stepOffset after a full window', async () => {
const turn = consumedTurn();
await readAndConsume(turn, [step('a'), step('b'), step('c')]);
assert.equal(turn.stepOffset, 3);
assert.equal(turn.snapshot().consumedThrough, 3);
});
it('S5 refuses a mid-batch snapshot instead of losing the unconsumed steps', async () => {
const turn = consumedTurn();
const read = await turn.readWindow(() => [step('a'), step('b')]);
await turn.consumeStep(read.steps[0], read.windowStart); // step 1 NOT consumed
assert.equal(turn.stepOffset, 2);
assert.equal(turn.consumedThrough, 1);
assert.throws(
() => turn.snapshot(),
(err) => {
assert.ok(err instanceof CascadeTurnCursorError);
assert.equal(err.code, 'CASCADE_TURN_CURSOR');
assert.match(err.message, /mid-batch/);
return true;
},
);
// Positive control in the same file: consuming the rest makes it valid.
await turn.consumeStep(read.steps[1], read.windowStart + 1);
assert.equal(turn.snapshot().consumedThrough, 2);
});
it('S6 refuses an empty-cursor snapshot (duplicated prefix)', () => {
assert.throws(
() => CascadeTurn.fromSnapshot({
version: 2,
cascadeId: 'empty',
stepOffset: 3,
pollFloor: 0,
windowStart: 0,
yieldedByStep: [],
thinkingByStep: [],
consumedThrough: 0,
generatorOffset: 0,
sawActive: false,
usageByStep: [],
chunks: [],
}),
CascadeTurnCursorError,
'consumedThrough=0 with stepOffset=3 must be refused',
);
// Positive control: the same shape with every counted step consumed is a
// valid resume (the window is 0 and the empty cursors are legitimate for
// steps that produced no text).
const ok = CascadeTurn.fromSnapshot({
version: 2,
cascadeId: 'empty-ok',
stepOffset: 3,
pollFloor: 0,
windowStart: 0,
yieldedByStep: [],
thinkingByStep: [],
consumedThrough: 3,
generatorOffset: 0,
sawActive: false,
usageByStep: [],
chunks: [],
});
assert.equal(ok.stepOffset, 3);
assert.equal(ok.consumedThrough, 3);
});
it('S7 rejects a v1 snapshot after the bump (guard, not red-first)', () => {
assert.throws(
() => CascadeTurn.fromSnapshot({
version: 1,
stepOffset: 1,
pollFloor: 0,
yieldedByStep: [[0, 1]],
thinkingByStep: [],
chunks: [],
}),
(err) => {
assert.ok(err instanceof CascadeTurnCursorError);
assert.match(err.message, /version 1 is not supported/);
return true;
},
);
});
});