Ctrl K
ComunidadGuías…

test/cascade-image-history.test.js

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

Archivo original ↗Preguntar a KiBot
// DATA-AUDIT #1 — replayed Cascade history must not lose images silently.
//
// SendUserCascadeMessage carries the replayed history as ONE text blob (field 2)
// and images as a FLAT message-level repeated field (field 6), so the wire cannot
// attribute an image to a turn — but it can carry every image. Before the fix
// only the newest turn was extracted, so an image from turn 1..n-1 reached the
// model as the literal "[Image omitted from text history]" placeholder with no
// counter and no log line anywhere.
//
// These tests drive the real client against a fake LS that decodes the actual
// proto it receives, so they assert on wire bytes, not on an internal return
// value: field 6 contents, marker text, the structured drop log, and the
// process-lifetime counter.
import './setup-env.mjs';
import { afterEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import http2 from 'node:http2';
import { wrapEnvelope, unwrapRequest, endOfStreamEnvelope } from '../src/connect.js';
import { getAllFields, getField, parseFields, writeMessageField, writeStringField, writeVarintField } from '../src/proto.js';
import { encodePngRGBA } from './helpers/png-encode.mjs';
import { getCascadeImageMetrics, resetCascadeImageMetrics } from '../src/cascade-image-metrics.js';

Object.assign(process.env, {
  CASCADE_POLL_INTERVAL_MS: '5', CASCADE_IDLE_GRACE_MS: '1',
  CASCADE_MAX_WAIT_MS: '1500', CASCADE_COLD_STALL_BASE_MS: '1500', CASCADE_WARM_STALL_MS: '1500',
  GRPC_PROTOCOL: 'connect', WINDSURFAPI_USER_JWT: '0',
});
const { WindsurfClient } = await import('../src/client.js');

// A real 2x2 PNG, so it survives maybeShrinkImage and is byte-identical on the wire.
const PNG_B64 = encodePngRGBA(Buffer.alloc(2 * 2 * 4, 0xff), 2, 2).toString('base64');
const IMAGE_MARKER = '[Image #';
const PLACEHOLDER = '[Image omitted from text history]';

const ENV_KEYS = ['CASCADE_MAX_HISTORY_IMAGES', 'CASCADE_MAX_HISTORY_IMAGE_BYTES', 'CASCADE_MAX_HISTORY_BYTES'];
const envSnapshot = new Map(ENV_KEYS.map((k) => [k, process.env[k]]));
afterEach(() => {
  for (const [k, v] of envSnapshot) {
    if (v === undefined) delete process.env[k];
    else process.env[k] = v;
  }
  resetCascadeImageMetrics();
});

function decodeImages(payload) {
  return getAllFields(parseFields(payload), 6).map((field) => {
    const inner = parseFields(field.value);
    return {
      base64_data: getField(inner, 1, 2)?.value.toString('utf8') || '',
      mime_type: getField(inner, 2, 2)?.value.toString('utf8') || '',
    };
  });
}

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

async function runCascade({ messages, reuseEntry = null, opts = {}, env = {}, failFirstSend = false }) {
  for (const [k, v] of Object.entries(env)) process.env[k] = String(v);
  resetCascadeImageMetrics();

  const sends = [];
  const warnings = [];
  const origWarn = console.warn;
  console.warn = (...args) => { warnings.push(args.map(String).join(' ')); };
  const server = http2.createServer();
  const sessions = new Set();
  server.on('session', (session) => {
    sessions.add(session);
    session.on('close', () => sessions.delete(session));
  });
  server.on('stream', (stream, headers) => {
    const chunks = [];
    stream.on('data', (b) => chunks.push(b));
    stream.on('end', () => {
      const payload = unwrapRequest(Buffer.concat(chunks), headers);
      const method = String(headers[':path']).split('/').pop();
      let body = Buffer.alloc(0);
      if (method === 'StartCascade') body = writeStringField(1, 'synthetic-cascade');
      if (method === 'SendUserCascadeMessage') {
        sends.push({ text: decodeText(payload), images: decodeImages(payload) });
        if (failFirstSend && sends.length === 1) {
          const bytes = Buffer.from(JSON.stringify({ error: { message: 'not_found: cascade trajectory has expired' } }));
          const frame = Buffer.alloc(5 + bytes.length);
          frame[0] = 2;
          frame.writeUInt32BE(bytes.length, 1);
          bytes.copy(frame, 5);
          stream.respond({ ':status': 200, 'content-type': headers['content-type'] });
          stream.end(frame);
          return;
        }
      }
      if (method === 'GetCascadeTrajectorySteps') {
        body = writeMessageField(1, Buffer.concat([
          writeVarintField(1, 15), writeVarintField(4, 3), writeMessageField(20, writeStringField(1, 'answer')),
        ]));
      }
      if (method === 'GetCascadeTrajectory') body = writeVarintField(2, 1);
      stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/connect+proto' });
      stream.end(Buffer.concat([wrapEnvelope(body, { compress: false }), endOfStreamEnvelope()]));
    });
  });
  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
  try {
    const client = new WindsurfClient('fixture-only-api-key', server.address().port, 'fixture-csrf');
    await client.cascadeChat(messages, 0, 'audit-neutral-model', { reuseEntry, ...opts });
    return { sends, last: sends.at(-1), warnings, metrics: getCascadeImageMetrics() };
  } finally {
    console.warn = origWarn;
    for (const session of sessions) session.destroy();
    await new Promise((resolve) => server.close(resolve));
  }
}

function imageTurn(text, ...images) {
  return {
    role: 'user',
    content: [
      { type: 'text', text },
      ...images.map((b64) => ({ type: 'image', source: { type: 'base64', media_type: 'image/png', data: b64 } })),
    ],
  };
}

describe('Cascade replayed-history images (field 6)', () => {
  it('carries an image from turn 1 of a 3-turn history instead of a placeholder', async () => {
    const { last } = await runCascade({
      messages: [
        imageTurn('TURN_0_IMAGE', PNG_B64),
        { role: 'assistant', content: 'TURN_1_TEXT' },
        { role: 'user', content: 'TURN_2_TEXT' },
      ],
    });

    assert.equal(last.images.length, 1, 'the turn-1 image must ride field 6');
    assert.equal(last.images[0].base64_data, PNG_B64, 'same bytes, same order');
    assert.equal(last.images[0].mime_type, 'image/png');
    assert.ok(last.text.includes(`${IMAGE_MARKER}1 attached to this request`),
      'turn 1 text must point at the attached image by number');
    assert.ok(!last.text.includes(PLACEHOLDER), 'no silent placeholder for an image that was carried');
    assert.ok(last.text.includes('TURN_0_IMAGE') && last.text.includes('TURN_1_TEXT') && last.text.includes('TURN_2_TEXT'),
      'the text history is unchanged around the marker');
  });

  it('keeps the current turn image first and numbers the historical one after it', async () => {
    const { last } = await runCascade({
      messages: [
        imageTurn('TURN_0_IMAGE', PNG_B64),
        { role: 'assistant', content: 'TURN_1_TEXT' },
        imageTurn('TURN_2_IMAGE', PNG_B64),
      ],
    });

    assert.equal(last.images.length, 2, 'current turn image + one historical image');
    assert.ok(last.text.includes(`${IMAGE_MARKER}2 attached to this request`),
      'the historical image is #2 because the current turn already contributed #1');
  });

  it('bounds history images newest-first and counts what it dropped', async () => {
    const { last, warnings, metrics } = await runCascade({
      env: { CASCADE_MAX_HISTORY_IMAGES: '1' },
      messages: [
        imageTurn('TURN_0_IMAGE', PNG_B64),
        { role: 'assistant', content: 'TURN_1_TEXT' },
        imageTurn('TURN_2_IMAGE', PNG_B64),
        { role: 'assistant', content: 'TURN_3_TEXT' },
        { role: 'user', content: 'TURN_4_TEXT' },
      ],
    });

    assert.equal(last.images.length, 1, 'cap of one historical image');
    assert.ok(last.text.includes('TURN_2_IMAGE'), 'the NEWER historical turn wins');
    assert.ok(last.text.includes(`${IMAGE_MARKER}1 attached to this request`), 'only the kept image is numbered');
    assert.equal(last.text.split(IMAGE_MARKER).length - 1, 1, 'exactly one marker, so one attached image');
    assert.ok(last.text.includes('TURN_0_IMAGE'), 'the older turn text is still replayed');
    assert.ok(last.text.includes(PLACEHOLDER), 'its image keeps the explicit placeholder');
    const logged = warnings.join('\n');
    assert.match(logged, /cascade_image_history_dropped count=1/);
    assert.match(logged, /cap=1/);
    assert.equal(metrics.cascade_image_history_dropped, 1, 'the drop is counted, not silent');
    assert.equal(metrics.cascade_image_history_attached, 1);
  });

  it('drops on the byte cap with bytes=1 when even a single image exceeds the budget', async () => {
    const { last, warnings, metrics } = await runCascade({
      env: { CASCADE_MAX_HISTORY_IMAGE_BYTES: '1' },
      messages: [
        imageTurn('TURN_0_IMAGE', PNG_B64),
        { role: 'assistant', content: 'TURN_1_TEXT' },
        { role: 'user', content: 'TURN_2_TEXT' },
      ],
    });

    assert.equal(last.images.length, 0, 'a one-byte budget cannot hold any image');
    assert.ok(last.text.includes(PLACEHOLDER), 'the text says the image is missing');
    assert.match(warnings.join('\n'),
      /cascade_image_history_dropped count=1 \(cap=0 bytes=1 truncated=0 no_vision=0\)/,
      'the drop is attributed to the BYTE cap, not to the count cap');
    assert.equal(metrics.cascade_image_history_dropped, 1);
  });

  it('attaches only the per-request cap from a single turn holding more images', async () => {
    const a = encodePngRGBA(Buffer.alloc(2 * 2 * 4, 0x11), 2, 2).toString('base64');
    const b = encodePngRGBA(Buffer.alloc(2 * 2 * 4, 0x22), 2, 2).toString('base64');
    const c = encodePngRGBA(Buffer.alloc(2 * 2 * 4, 0x33), 2, 2).toString('base64');
    const { last, warnings, metrics } = await runCascade({
      env: { CASCADE_MAX_HISTORY_IMAGES: '2' },
      messages: [
        imageTurn('TURN_0_IMAGE', a, b, c),
        { role: 'assistant', content: 'TURN_1_TEXT' },
        { role: 'user', content: 'TURN_2_TEXT' },
      ],
    });

    assert.equal(last.images.length, 2, 'exactly the cap, not the whole turn');
    assert.deepEqual(last.images.map((i) => i.base64_data), [a, b], 'the first two in block order');
    assert.equal(last.text.split(IMAGE_MARKER).length - 1, 2, 'exactly two markers');
    assert.ok(last.text.includes(`${IMAGE_MARKER}1 attached`) && last.text.includes(`${IMAGE_MARKER}2 attached`));
    assert.ok(last.text.includes(PLACEHOLDER), 'the third image keeps the placeholder');
    assert.match(warnings.join('\n'), /cascade_image_history_dropped count=1/,
      'exactly one image is dropped from the turn');
    assert.equal(metrics.cascade_image_history_dropped, 1);
  });

  it('honours CASCADE_MAX_HISTORY_IMAGES=0 as the explicit opt-out, still counted', async () => {
    const { last, warnings, metrics } = await runCascade({
      env: { CASCADE_MAX_HISTORY_IMAGES: '0' },
      messages: [
        imageTurn('TURN_0_IMAGE', PNG_B64),
        { role: 'assistant', content: 'TURN_1_TEXT' },
        { role: 'user', content: 'TURN_2_TEXT' },
      ],
    });

    assert.equal(last.images.length, 0, 'opt-out drops every historical image');
    assert.ok(last.text.includes(PLACEHOLDER), 'the text says the image is missing');
    assert.match(warnings.join('\n'), /cascade_image_history_dropped count=1/);
    assert.equal(metrics.cascade_image_history_dropped, 1);
  });

  it('counts history images dropped because a truncated turn is not replayed at all', async () => {
    const filler = 'x'.repeat(1200);
    const { last, warnings, metrics } = await runCascade({
      env: { CASCADE_MAX_HISTORY_BYTES: '900' },
      messages: [
        imageTurn(`TURN_0_IMAGE ${filler}`, PNG_B64),
        { role: 'assistant', content: `TURN_1_TEXT ${filler}` },
        { role: 'user', content: 'TURN_2_TEXT' },
      ],
    });

    assert.equal(last.images.length, 0, 'an image whose turn is truncated must not ride the wire');
    assert.match(warnings.join('\n'), /cascade_image_history_dropped count=1 .*truncated=1/);
    assert.equal(metrics.cascade_image_history_dropped, 1);
  });

  it('carries the same history image when a resumed cascade is rebuilt from scratch', async () => {
    const { sends, metrics } = await runCascade({
      failFirstSend: true,
      reuseEntry: { cascadeId: 'synthetic-existing', sessionId: 'fixture-session', stepOffset: 5, generatorOffset: 5 },
      messages: [
        imageTurn('TURN_0_IMAGE', PNG_B64),
        { role: 'assistant', content: 'TURN_1_TEXT' },
        { role: 'user', content: 'TURN_2_TEXT' },
      ],
    });

    assert.equal(sends.length, 2, 'the first send failed and the history was rebuilt');
    assert.equal(sends[1].images.length, 1, 'the rebuilt payload carries the historical image too');
    assert.equal(sends[1].images[0].base64_data, PNG_B64);
    assert.ok(sends[1].text.includes(`${IMAGE_MARKER}1 attached to this request`),
      'rebuild uses the same numbering as a fresh request');
    // The resume attempt never calls the history builder (it reuses the live
    // trajectory and only extracts the current turn), so exactly ONE build is
    // counted for this request — not both wire attempts.
    assert.equal(metrics.cascade_image_history_attached, 1,
      'only the rebuilt build is counted');
  });

  it('does not attach history images when the planner runs without vision, and counts them', async () => {
    const { last, warnings, metrics } = await runCascade({
      opts: { toolPreamble: 'TOOL DEFINITIONS' },
      messages: [
        imageTurn('TURN_0_IMAGE', PNG_B64),
        { role: 'assistant', content: 'TURN_1_TEXT' },
        { role: 'user', content: 'TURN_2_TEXT' },
      ],
    });

    assert.equal(last.images.length, 0, 'NO_TOOL mode never renders field 6, so nothing is sent');
    assert.match(warnings.join('\n'), /cascade_image_history_dropped count=1 .*no_vision=1/);
    assert.equal(metrics.cascade_image_history_dropped, 1);
  });
});

// G08 — the field-6 image budget is ONE budget over the whole image list, not
// a history-only allowance. Before the fix the current turn's images were
// appended unbounded (MAX_BODY_SIZE only caps the HTTP body, not the model
// context): a caller could push unlimited image bytes through the current
// turn and STILL get the full history allowance on top.
describe('Cascade shared image budget — current turn included (G08)', () => {
  it('bounds the current turn by the shared count cap', async () => {
    const a = encodePngRGBA(Buffer.alloc(2 * 2 * 4, 0x44), 2, 2).toString('base64');
    const b = encodePngRGBA(Buffer.alloc(2 * 2 * 4, 0x55), 2, 2).toString('base64');
    const c = encodePngRGBA(Buffer.alloc(2 * 2 * 4, 0x66), 2, 2).toString('base64');
    const d = encodePngRGBA(Buffer.alloc(2 * 2 * 4, 0x77), 2, 2).toString('base64');
    const { last, warnings, metrics } = await runCascade({
      env: { CASCADE_MAX_HISTORY_IMAGES: '2' },
      messages: [
        imageTurn('TURN_0_IMAGE', a),
        { role: 'assistant', content: 'TURN_1_TEXT' },
        imageTurn('TURN_2_CURRENT', b, c, d),
      ],
    });

    assert.equal(last.images.length, 2,
      'the shared cap is over the WHOLE field-6 list: 3 current + 1 history must not ride');
    assert.deepEqual(last.images.map((i) => i.base64_data), [b, c],
      'the current turn spends the budget first, in block order');
    assert.equal(metrics.cascade_image_current_dropped, 1,
      'the current-turn overflow is counted, not silent');
    assert.equal(metrics.cascade_image_history_dropped, 1,
      'the history image that lost its slot to the current turn is counted too');
    assert.match(warnings.join('\n'), /cascade_image_current_dropped count=1/);
  });

  it('the current turn spends the budget first; replayed history gets the remainder', async () => {
    const a = encodePngRGBA(Buffer.alloc(2 * 2 * 4, 0x11), 2, 2).toString('base64');
    const h = encodePngRGBA(Buffer.alloc(2 * 2 * 4, 0x22), 2, 2).toString('base64');
    const c = encodePngRGBA(Buffer.alloc(2 * 2 * 4, 0x33), 2, 2).toString('base64');
    const { last, metrics } = await runCascade({
      env: { CASCADE_MAX_HISTORY_IMAGES: '2' },
      messages: [
        imageTurn('TURN_0_IMAGE', a),
        { role: 'assistant', content: 'TURN_1_TEXT' },
        imageTurn('TURN_2_IMAGE', h),
        { role: 'assistant', content: 'TURN_3_TEXT' },
        imageTurn('TURN_4_CURRENT', c),
      ],
    });

    assert.equal(last.images.length, 2,
      'budget 2 = current(1) + the single newest history image, not current + full history allowance');
    assert.deepEqual(last.images.map((i) => i.base64_data), [c, h],
      'current-turn image first, then the newest historical one');
    assert.ok(last.text.includes(`${IMAGE_MARKER}2 attached to this request`),
      'the surviving history image is numbered after the current one');
    assert.equal(metrics.cascade_image_history_dropped, 1,
      'the older history image that did not fit is counted');
  });

  it('bounds the single-turn path by the same shared cap', async () => {
    const a = encodePngRGBA(Buffer.alloc(2 * 2 * 4, 0x11), 2, 2).toString('base64');
    const b = encodePngRGBA(Buffer.alloc(2 * 2 * 4, 0x22), 2, 2).toString('base64');
    const c = encodePngRGBA(Buffer.alloc(2 * 2 * 4, 0x33), 2, 2).toString('base64');
    const { last, warnings, metrics } = await runCascade({
      env: { CASCADE_MAX_HISTORY_IMAGES: '2' },
      messages: [imageTurn('ONLY_TURN', a, b, c)],
    });

    assert.equal(last.images.length, 2,
      'a one-message request is all current turn — "no history" must not mean "no cap"');
    assert.deepEqual(last.images.map((i) => i.base64_data), [a, b]);
    assert.equal(metrics.cascade_image_current_dropped, 1);
    assert.match(warnings.join('\n'), /cascade_image_current_dropped count=1/);
  });

  it('bounds the current turn by the shared byte cap as well', async () => {
    const { last, metrics } = await runCascade({
      env: { CASCADE_MAX_HISTORY_IMAGE_BYTES: '1' },
      messages: [imageTurn('ONLY_TURN', PNG_B64)],
    });

    assert.equal(last.images.length, 0, 'a one-byte budget cannot hold the current-turn image either');
    assert.equal(metrics.cascade_image_current_dropped, 1);
  });
});