Ctrl K
ComunidadGuías…

test/client-budget-offset-transient.test.js

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

Archivo original ↗Preguntar a KiBot
// Three verified defects in src/client.js, pinned at their seams:
//
// 1. The Cascade history budget compared UTF-16 `line.length` while the wire
//    encoder writes UTF-8 (proto.js uses Buffer.byteLength), so a CJK history
//    could "fit" 600 KB and reach the wire at ~1.8 MB.
// 2. The trajectory poll loop never advanced `stepOffset`, so every 500 ms
//    poll re-fetched the whole trajectory (and the resume entry recorded an
//    offset that was really the last batch size).
// 3. `isCascadeTransportError` matched /panel state/i, including the
//    deterministic terminal error the Send retry loop synthesizes after
//    MAX_PANEL_RETRIES — that one rotated an oversized payload across every
//    account instead of surfacing.
//
// The budget tests drive `cascadeChat` against a fake HTTP/2 language server
// and measure the prompt the client actually sends. The offset test uses the
// same seam and asserts on the GetCascadeTrajectorySteps requests. The
// classification test calls the exported pure function.

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import http2 from 'node:http2';

Object.assign(process.env, {
  GRPC_PROTOCOL: 'connect',
  CASCADE_POLL_INTERVAL_MS: '10',
  CASCADE_IDLE_GRACE_MS: '1',
  CASCADE_MAX_WAIT_MS: '2000',
  CASCADE_COLD_STALL_BASE_MS: '2000',
  CASCADE_WARM_STALL_MS: '2000',
  WINDSURFAPI_USER_JWT: '0',
});
delete process.env.DEVIN_CONNECT;
delete process.env.DEVIN_ONLY;

const { isCascadeTransportError, WindsurfClient, __TEST_CASCADE_TIMEOUTS } =
  await import('../src/client.js');
const { unwrapRequest, wrapEnvelope, endOfStreamEnvelope } = await import('../src/connect.js');
const {
  getField, parseFields, writeMessageField, writeStringField, writeVarintField,
} = await import('../src/proto.js');

// 3 UTF-8 bytes, 1 UTF-16 code unit.
const CJK = '漢';

function plannerStep(text, status = 3) {
  return Buffer.concat([
    writeVarintField(1, 15), // CORTEX_STEP_TYPE_PLANNER_RESPONSE
    writeVarintField(4, status),
    writeMessageField(20, writeStringField(1, text)),
  ]);
}

function runCommandStep(command, status = 1) {
  return Buffer.concat([
    writeVarintField(1, 28), // CORTEX_STEP_TYPE_RUN_COMMAND
    writeVarintField(4, status),
    writeMessageField(28, writeStringField(23, command)),
  ]);
}

function rawStepsResponse(steps) {
  return Buffer.concat(steps.map((step) => writeMessageField(1, step)));
}

function stepsResponse(texts) {
  return rawStepsResponse(texts.map((text) => plannerStep(text)));
}

function readStepOffset(payload) {
  const field = getField(parseFields(payload), 2, 0);
  return field ? Number(field.value) : 0;
}

function readSendText(payload) {
  const scope = getField(parseFields(payload), 2, 2);
  return getField(parseFields(scope.value), 1, 2).value.toString('utf8');
}

function respond(stream, headers, body) {
  stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/connect+proto' });
  stream.end(Buffer.concat([wrapEnvelope(body, { compress: false }), endOfStreamEnvelope()]));
}

async function withFakeLanguageServer(handler, fn) {
  const server = http2.createServer();
  const sessions = new Set();
  server.on('session', (session) => {
    sessions.add(session);
    session.on('close', () => sessions.delete(session));
  });
  server.on('stream', handler);
  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
  const port = server.address().port;
  try {
    return await fn(port);
  } finally {
    for (const session of sessions) {
      try { session.close(); } catch {}
      try { session.destroy(); } catch {}
    }
    await new Promise((resolve) => server.close(resolve));
  }
}

// Runs one fresh cascade and returns the prompt text of every
// SendUserCascadeMessage the fake LS received.
async function runFreshCascade(messages) {
  const sent = [];
  await withFakeLanguageServer((stream, headers) => {
    const chunks = [];
    stream.on('data', (chunk) => chunks.push(chunk));
    stream.on('end', () => {
      const method = String(headers[':path']).split('/').pop();
      const payload = unwrapRequest(Buffer.concat(chunks), headers);
      let body = Buffer.alloc(0);

      if (method === 'StartCascade') body = writeStringField(1, 'budget-cascade');
      if (method === 'SendUserCascadeMessage') sent.push(readSendText(payload));
      if (method === 'GetCascadeTrajectorySteps' && readStepOffset(payload) === 0) {
        body = stepsResponse(['answer']);
      }
      if (method === 'GetCascadeTrajectory') body = writeVarintField(2, 1); // IDLE
      respond(stream, headers, body);
    });
  }, async (port) => {
    const client = new WindsurfClient('test-api-key', port, 'csrf-token');
    await client.cascadeChat(messages, 0, 'claude-sonnet-4-6');
  });
  assert.equal(sent.length, 1, 'exactly one SendUserCascadeMessage per cascade');
  return sent[0];
}

describe('Cascade history budget counts UTF-8 bytes', () => {
  it('trims a CJK history that fits 600 KB as UTF-16 but is ~3x over as UTF-8', async () => {
    const previous = process.env.CASCADE_MAX_HISTORY_BYTES;
    process.env.CASCADE_MAX_HISTORY_BYTES = '600000';
    try {
      // Three 199,000-char turns: 597,024 UTF-16 units each including markup
      // (597,072 total, under the 600 KB budget) but 597,024 UTF-8 bytes each
      // (~1.79 MB total). Counting units kept all three; counting bytes keeps
      // only the newest.
      const prior = Array.from({ length: 3 }, (_, i) => ({
        role: i % 2 ? 'assistant' : 'user',
        content: `${CJK.repeat(199_000)}TURN_${i}`,
      }));
      const text = await runFreshCascade([...prior, { role: 'user', content: 'latest turn' }]);
      const wire = Buffer.byteLength(text, 'utf8');

      assert.ok(wire < 650_000, `prompt is ${wire} bytes; UTF-16 counting sent ~1.79 MB`);
      assert.ok(text.includes('TURN_2'), 'the newest prior turn survives');
      assert.ok(!text.includes('TURN_0') && !text.includes('TURN_1'), 'the older turns are dropped');
      assert.match(text, /truncation_note.*2 earlier turns were dropped/);
    } finally {
      if (previous === undefined) delete process.env.CASCADE_MAX_HISTORY_BYTES;
      else process.env.CASCADE_MAX_HISTORY_BYTES = previous;
    }
  });

  it('counts the prepended system prompt in the same UTF-8 units', async () => {
    const previous = process.env.CASCADE_MAX_HISTORY_BYTES;
    process.env.CASCADE_MAX_HISTORY_BYTES = '600000';
    try {
      // 100,000 CJK chars of system prompt are 300,000 wire bytes (but
      // 100,000 units). Each history line is ~180,024 bytes. Only one line
      // fits after the prompt; counting units would have kept both.
      const system = CJK.repeat(100_000);
      const messages = [
        { role: 'system', content: system },
        { role: 'user', content: `${CJK.repeat(60_000)}TURN_0` },
        { role: 'assistant', content: `${CJK.repeat(60_000)}TURN_1` },
        { role: 'user', content: 'latest turn' },
      ];
      const text = await runFreshCascade(messages);
      const wire = Buffer.byteLength(text, 'utf8');

      assert.ok(wire < 600_000, `prompt is ${wire} bytes; UTF-16 counting sent ~660 KB`);
      assert.ok(text.includes('TURN_1'), 'the newest prior turn survives');
      assert.ok(!text.includes('TURN_0'), 'the older turn no longer fits after the 300 KB prompt');
    } finally {
      if (previous === undefined) delete process.env.CASCADE_MAX_HISTORY_BYTES;
      else process.env.CASCADE_MAX_HISTORY_BYTES = previous;
    }
  });
});

describe('Cascade trajectory polling advances stepOffset with bounded overlap', () => {
  it('advances the poll window while re-reading at most one bounded overlap', async () => {
    const requests = [];
    let produced = 0;

    await withFakeLanguageServer((stream, headers) => {
      const chunks = [];
      stream.on('data', (chunk) => chunks.push(chunk));
      stream.on('end', () => {
        const method = String(headers[':path']).split('/').pop();
        const payload = unwrapRequest(Buffer.concat(chunks), headers);
        let body = Buffer.alloc(0);

        if (method === 'StartCascade') body = writeStringField(1, 'advance-cascade');
        if (method === 'GetCascadeTrajectorySteps') {
          const offset = readStepOffset(payload);
          if (produced < 20) produced += 2;
          const trajectory = Array.from({ length: produced }, (_, i) => plannerStep(`S${i}`));
          requests.push({ offset, count: trajectory.length - offset });
          body = rawStepsResponse(trajectory.slice(offset));
        }
        if (method === 'GetCascadeTrajectory') {
          body = writeVarintField(2, produced >= 20 ? 1 : 2);
        }
        respond(stream, headers, body);
      });
    }, async (port) => {
      const client = new WindsurfClient('test-api-key', port, 'csrf-token');
      const chunks = await client.cascadeChat(
        [{ role: 'user', content: 'go' }], 0, 'claude-sonnet-4-6',
      );
      const text = chunks.map((chunk) => chunk.text || '').join('');
      const offsets = requests.map((request) => request.offset);

      assert.equal(offsets[0], 0);
      assert.deepEqual(offsets, [...offsets].sort((a, b) => a - b), 'the poll window never moves backwards');
      assert.ok(offsets.at(-1) > 0, `the window never advanced past 0: ${offsets.join(',')}`);
      // Source constant CASCADE_STEP_OVERLAP is 8; each request adds at most
      // two new steps, so no response may exceed 10 steps.
      const largest = Math.max(...requests.map((request) => request.count));
      assert.ok(largest <= 10, `a poll re-read ${largest} steps instead of a bounded overlap`);
      assert.equal(text, Array.from({ length: 20 }, (_, i) => `S${i}`).join(''),
        'every step is emitted once, in order, despite the overlap');
      assert.equal(chunks.stepOffset, 20, 'the resume offset is the absolute next step');
    });
  });

  it('re-reads a partial step so growth after the first batch is not lost', async () => {
    let stepPolls = 0;
    let trajectory = [];

    await withFakeLanguageServer((stream, headers) => {
      const chunks = [];
      stream.on('data', (chunk) => chunks.push(chunk));
      stream.on('end', () => {
        const method = String(headers[':path']).split('/').pop();
        const payload = unwrapRequest(Buffer.concat(chunks), headers);
        let body = Buffer.alloc(0);

        if (method === 'StartCascade') body = writeStringField(1, 'growing-cascade');
        if (method === 'GetCascadeTrajectorySteps') {
          stepPolls++;
          if (stepPolls === 1) trajectory = [plannerStep('A', 1)];
          else if (stepPolls === 2) trajectory = [plannerStep('AB', 1), plannerStep('C')];
          else trajectory = [plannerStep('AB', 1), plannerStep('CD')];
          body = rawStepsResponse(trajectory.slice(readStepOffset(payload)));
        }
        if (method === 'GetCascadeTrajectory') {
          body = writeVarintField(2, stepPolls >= 2 ? 1 : 2);
        }
        respond(stream, headers, body);
      });
    }, async (port) => {
      const client = new WindsurfClient('test-api-key', port, 'csrf-token');
      const chunks = await client.cascadeChat(
        [{ role: 'user', content: 'go' }], 0, 'claude-sonnet-4-6',
      );
      const text = chunks.map((chunk) => chunk.text || '').join('');

      // Step 0 arrived as "A", grew to "AB" after the offset advanced, and
      // the IDLE sweep added the final "D" of step 1.
      assert.equal(text, 'ABCD', `partial-step growth was lost: ${JSON.stringify(text)}`);
      assert.equal(chunks.stepOffset, 2, 'both steps are accounted for exactly once');
    });
  });

  it('keeps the tool-active stall ceiling after the tool step leaves the poll window', async () => {
    const savedTimeouts = { ...__TEST_CASCADE_TIMEOUTS };
    Object.assign(__TEST_CASCADE_TIMEOUTS, {
      maxWaitMs: 150,
      pollIntervalMs: 10,
      idleGraceMs: 1,
      coldStallBaseMs: 1000,
      warmStallMs: 50,
      warmStallThinkingMs: 50,
      warmStallToolActiveMs: 300,
    });
    const savedGrace = process.env.CASCADE_TOOL_ACTIVE_GRACE_MS;
    process.env.CASCADE_TOOL_ACTIVE_GRACE_MS = '50';
    try {
      // The ACTIVE tool step is step 0; eight later steps push it out of the
      // bounded overlap window while the tool is still running.
      const trajectory = [
        runCommandStep('git clone https://example.invalid/big.git'),
        ...Array.from({ length: 9 }, (_, i) => plannerStep(`seed${i}`)),
      ];

      await withFakeLanguageServer((stream, headers) => {
        const chunks = [];
        stream.on('data', (chunk) => chunks.push(chunk));
        stream.on('end', () => {
          const method = String(headers[':path']).split('/').pop();
          const payload = unwrapRequest(Buffer.concat(chunks), headers);
          let body = Buffer.alloc(0);

          if (method === 'StartCascade') body = writeStringField(1, 'tool-stall-cascade');
          if (method === 'GetCascadeTrajectorySteps') {
            body = rawStepsResponse(trajectory.slice(readStepOffset(payload)));
          }
          if (method === 'GetCascadeTrajectory') body = writeVarintField(2, 2); // ACTIVE
          respond(stream, headers, body);
        });
      }, async (port) => {
        const client = new WindsurfClient('test-api-key', port, 'csrf-token');
        const started = Date.now();
        const chunks = await client.cascadeChat(
          [{ role: 'user', content: 'run it' }], 0, 'claude-sonnet-4-6',
        );
        const elapsed = Date.now() - started;

        assert.ok(elapsed >= 100, `turn was stall-killed after ${elapsed}ms by the 50ms text ceiling`);
        assert.match(chunks.map((chunk) => chunk.text || '').join(''), /seed0/);
        assert.equal(chunks.toolCalls.length, 1, 'the active tool call is still accounted for');
      });
    } finally {
      Object.assign(__TEST_CASCADE_TIMEOUTS, savedTimeouts);
      if (savedGrace === undefined) delete process.env.CASCADE_TOOL_ACTIVE_GRACE_MS;
      else process.env.CASCADE_TOOL_ACTIVE_GRACE_MS = savedGrace;
    }
  });

  it('keeps resume semantics: starts at the stored offset and returns the absolute next step', async () => {
    const offsets = [];

    await withFakeLanguageServer((stream, headers) => {
      const chunks = [];
      stream.on('data', (chunk) => chunks.push(chunk));
      stream.on('end', () => {
        const method = String(headers[':path']).split('/').pop();
        const payload = unwrapRequest(Buffer.concat(chunks), headers);
        let body = Buffer.alloc(0);

        if (method === 'GetCascadeTrajectorySteps') {
          const offset = readStepOffset(payload);
          offsets.push(offset);
          if (offset === 5) body = stepsResponse(['R5', 'R6']);
        }
        if (method === 'GetCascadeTrajectory') body = writeVarintField(2, 1); // IDLE
        respond(stream, headers, body);
      });
    }, async (port) => {
      const client = new WindsurfClient('test-api-key', port, 'csrf-token');
      const chunks = await client.cascadeChat(
        [{ role: 'user', content: 'continue' }], 0, 'claude-sonnet-4-6',
        {
          reuseEntry: {
            cascadeId: 'resume-cascade',
            sessionId: 'resume-session',
            lsPort: port,
            apiKey: 'test-api-key',
            stepOffset: 5,
            generatorOffset: 5,
          },
        },
      );
      const text = chunks.map((chunk) => chunk.text || '').join('');

      assert.equal(offsets[0], 5, 'resume must poll from the stored offset');
      assert.equal(text, 'R5R6', 'only the new steps are streamed');
      assert.equal(chunks.stepOffset, 7, 'the stored offset plus the new steps');
    });
  });
});

describe('Cascade transport classification', () => {
  it('does not treat the synthesized terminal panel-loss error as transient', () => {
    const terminal = new Error(
      'Panel state lost 2 times after re-warm — likely an LS-level issue with very large '
      + 'payloads (350000 chars). Try reducing system prompt size or tool count.',
    );
    assert.equal(isCascadeTransportError(terminal), false);
  });

  it('still treats genuine panel-state transport errors as transient', () => {
    assert.equal(isCascadeTransportError(new Error('panel state not found for session abc')), true);
    assert.equal(
      isCascadeTransportError(new Error('InitializeCascadePanelState failed: panel state lost')),
      true,
      'an LS-originated panel-state loss (no synthesized "after re-warm" suffix) stays transient',
    );
  });
});