Ctrl K
ComunidadGuías…

test/client-panel-retry.test.js

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

Archivo original ↗Preguntar a KiBot
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import http2 from 'http2';
import { terminateRpcWithStatus, isConnectContentType } from './helpers/fake-grpc-trailers.mjs';
import { endOfStreamEnvelope, unwrapRequest, wrapEnvelope } from '../src/connect.js';
import { getField, parseFields, writeMessageField, writeStringField, writeVarintField } from '../src/proto.js';

function grpcFrame(payload) {
  const buf = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
  const frame = Buffer.alloc(5 + buf.length);
  frame[0] = 0;
  frame.writeUInt32BE(buf.length, 1);
  buf.copy(frame, 5);
  return frame;
}

function stripGrpcFrame(buf) {
  if (buf.length >= 5 && buf[0] === 0) {
    const msgLen = buf.readUInt32BE(1);
    if (buf.length >= 5 + msgLen) return buf.subarray(5, 5 + msgLen);
  }
  return buf;
}

function extractGrpcFrames(buf) {
  const frames = [];
  let offset = 0;
  while (offset + 5 <= buf.length) {
    const compressed = buf[offset];
    const msgLen = buf.readUInt32BE(offset + 1);
    if (compressed !== 0 || offset + 5 + msgLen > buf.length) break;
    frames.push(buf.subarray(offset + 5, offset + 5 + msgLen));
    offset += 5 + msgLen;
  }
  return frames;
}

function responseBody(payload, headers) {
  const contentType = String(headers['content-type'] || '');
  if (isConnectContentType(contentType)) {
    return Buffer.concat([wrapEnvelope(payload, { compress: false }), endOfStreamEnvelope()]);
  }
  return grpcFrame(payload);
}

function errorBody(message, headers) {
  const contentType = String(headers['content-type'] || '');
  if (isConnectContentType(contentType)) {
    const body = Buffer.from(JSON.stringify({ error: { message } }));
    const frame = Buffer.alloc(5 + body.length);
    frame[0] = 0x02;
    frame.writeUInt32BE(body.length, 1);
    body.copy(frame, 5);
    return { body: frame, trailers: null };
  }
  return { body: Buffer.alloc(0), trailers: { 'grpc-status': '5', 'grpc-message': encodeURIComponent(message) } };
}

function requestPayload(body, headers) {
  const contentType = String(headers['content-type'] || '');
  if (isConnectContentType(contentType)) {
    return unwrapRequest(body, headers);
  }
  const frames = extractGrpcFrames(body);
  return frames.length ? Buffer.concat(frames) : stripGrpcFrame(body);
}

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

function startCascadeResponse(cascadeId) {
  return writeStringField(1, cascadeId);
}

function trajectoryStatusResponse(status) {
  return writeVarintField(2, status);
}

function trajectoryInfoResponse(status, trajectoryId = 'trajectory-test', cascadeId = 'cascade-test') {
  const trajectory = Buffer.concat([
    writeStringField(1, trajectoryId),
    writeStringField(6, cascadeId),
  ]);
  return Buffer.concat([
    writeMessageField(1, trajectory),
    writeVarintField(2, status),
  ]);
}

function trajectoryStepsResponse(text) {
  if (!text) return Buffer.alloc(0);
  const planner = writeStringField(1, text);
  const step = Buffer.concat([
    writeVarintField(1, 15),
    writeVarintField(4, 3),
    writeMessageField(20, planner),
  ]);
  return writeMessageField(1, step);
}

function runCommandStepResponse(command = 'printf HELLO') {
  const runCommandBody = writeStringField(23, command);
  const step = Buffer.concat([
    writeVarintField(1, 28),
    writeVarintField(4, 1),
    writeMessageField(28, runCommandBody),
  ]);
  return writeMessageField(1, step);
}

function pendingReadUrlStepResponse() {
  const spec = Buffer.concat([
    writeStringField(1, 'https://example.com/'),
    writeStringField(2, 'https://example.com'),
  ]);
  const body = Buffer.concat([
    writeStringField(1, 'https://example.com/'),
    writeVarintField(7, 8),
  ]);
  const step = Buffer.concat([
    writeVarintField(1, 40),
    writeVarintField(4, 9),
    writeMessageField(56, writeMessageField(14, spec)),
    writeMessageField(40, body),
  ]);
  return writeMessageField(1, step);
}

function readUrlContentDoneStepResponse() {
  const doc = Buffer.concat([
    writeStringField(2, 'Example Domain fetched body'),
    writeStringField(3, 'https://example.com/'),
    writeStringField(7, 'Example Domain summary'),
  ]);
  const body = Buffer.concat([
    writeStringField(1, 'https://example.com/'),
    writeMessageField(2, doc),
    writeStringField(3, 'https://example.com/'),
    writeVarintField(4, 123),
  ]);
  const step = Buffer.concat([
    writeVarintField(1, 40),
    writeVarintField(4, 3),
    writeMessageField(40, body),
  ]);
  return writeMessageField(1, step);
}

function readUrlContentDoneWithEchoStepResponse() {
  const spec = Buffer.concat([
    writeStringField(1, 'https://example.com/'),
    writeStringField(2, 'https://example.com'),
  ]);
  const doc = Buffer.concat([
    writeStringField(2, 'Example Domain fetched body'),
    writeStringField(3, 'https://example.com/'),
    writeStringField(7, 'Example Domain summary'),
  ]);
  const body = Buffer.concat([
    writeStringField(1, 'https://example.com/'),
    writeMessageField(2, doc),
    writeStringField(3, 'https://example.com/'),
    writeVarintField(4, 123),
    writeVarintField(7, 8),
  ]);
  const step = Buffer.concat([
    writeVarintField(1, 31),
    writeVarintField(4, 3),
    writeMessageField(56, writeMessageField(14, spec)),
    writeMessageField(40, body),
  ]);
  return writeMessageField(1, step);
}

function setEnvForTest(values) {
  const previous = new Map(Object.keys(values).map(key => [key, process.env[key]]));
  for (const [key, value] of Object.entries(values)) {
    if (value === undefined) delete process.env[key];
    else process.env[key] = String(value);
  }
  return () => {
    for (const [key, value] of previous) {
      if (value === undefined) delete process.env[key];
      else process.env[key] = value;
    }
  };
}

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', (stream, headers) => { terminateRpcWithStatus(stream); handler(stream, headers); });
  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));
  }
}

async function countPendingReadUrlApprovals({ nativeAllowlist = ['read_url_content'] } = {}) {
  let approvals = 0;
  await withFakeLanguageServer((stream, headers) => {
    const chunks = [];
    stream.on('data', chunk => chunks.push(chunk));
    stream.on('end', () => {
      const method = String(headers[':path'] || '').split('/').pop();
      if (method === 'StartCascade') {
        stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
        stream.end(responseBody(startCascadeResponse('webfetch-cascade'), headers));
        return;
      }
      if (method === 'SendUserCascadeMessage') {
        stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
        stream.end(responseBody(Buffer.alloc(0), headers));
        return;
      }
      if (method === 'GetCascadeTrajectorySteps') {
        stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
        stream.end(responseBody(pendingReadUrlStepResponse(), headers));
        return;
      }
      if (method === 'GetCascadeTrajectory') {
        stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
        stream.end(responseBody(trajectoryInfoResponse(2, 'trajectory-webfetch', 'webfetch-cascade'), headers));
        return;
      }
      if (method === 'HandleCascadeUserInteraction') {
        approvals++;
        stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
        stream.end(responseBody(Buffer.alloc(0), headers));
        return;
      }
      if (method === 'GetCascadeTrajectoryGeneratorMetadata') {
        stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
        stream.end(responseBody(Buffer.alloc(0), headers));
        return;
      }
      stream.respond({ ':status': 404 });
      stream.end();
    });
  }, async (port) => {
    const { WindsurfClient } = await import('../src/client.js');
    const client = new WindsurfClient('test-api-key', port, 'csrf-token');
    try {
      await client.cascadeChat([{ role: 'user', content: 'fetch it' }], 0, 'claude-4.5-haiku', {
        nativeMode: true,
        nativeAllowlist,
      });
    } catch (err) {
      if (err?.kind !== 'transient_stall') throw err;
    }
  });
  return approvals;
}

describe('WindsurfClient cascade panel retry', () => {
  it('resets trajectory offsets after re-warming to a fresh cascade', async () => {
    process.env.CASCADE_POLL_INTERVAL_MS = '10';
    process.env.CASCADE_IDLE_GRACE_MS = '1';
    process.env.CASCADE_MAX_WAIT_MS = '500';
    process.env.CASCADE_COLD_STALL_BASE_MS = '500';
    process.env.CASCADE_WARM_STALL_MS = '500';
    process.env.GRPC_PROTOCOL = 'connect';

    const observedStepOffsets = [];
    let sendCount = 0;

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

        if (method === 'StartCascade') {
          stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
          stream.end(responseBody(startCascadeResponse('fresh-cascade'), headers));
          return;
        }

        if (method === 'SendUserCascadeMessage') {
          sendCount++;
          if (sendCount === 1) {
            const err = errorBody('panel state not found', headers);
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            if (err.trailers) stream.additionalHeaders(err.trailers);
            stream.end(err.body);
            return;
          }
          stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
          stream.end(responseBody(Buffer.alloc(0), headers));
          return;
        }

        if (method === 'GetCascadeTrajectorySteps') {
          const offset = readStepOffset(payload);
          observedStepOffsets.push(offset);
          stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
          stream.end(responseBody(trajectoryStepsResponse(offset === 0 ? 'fresh-output' : ''), headers));
          return;
        }

        if (method === 'GetCascadeTrajectory') {
          stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
          stream.end(responseBody(trajectoryStatusResponse(1), headers));
          return;
        }

        if (method === 'GetCascadeTrajectoryGeneratorMetadata') {
          stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
          stream.end(responseBody(Buffer.alloc(0), headers));
          return;
        }

        stream.respond({ ':status': 404 });
        stream.end();
      });
    }, async (port) => {
      const { WindsurfClient } = await import('../src/client.js');
      const client = new WindsurfClient('test-api-key', port, 'csrf-token');
      const chunks = await client.cascadeChat([
        { role: 'user', content: 'old' },
        { role: 'assistant', content: 'ok' },
        { role: 'user', content: 'continue' },
      ], 0, 'claude-sonnet-4-6', {
        reuseEntry: {
          cascadeId: 'expired-cascade',
          sessionId: 'expired-session',
          lsPort: port,
          apiKey: 'test-api-key',
          stepOffset: 5,
          generatorOffset: 5,
        },
      });

      const text = chunks.map(c => c.text || '').join('');
      assert.equal(sendCount, 2);
      assert.ok(observedStepOffsets.length > 0);
      assert.equal(observedStepOffsets[0], 0);
      assert.match(text, /fresh-output/);
    });
  });

  it('v2.0.25 HIGH-2: cascade not_found triggers fresh fallback and marks reuse entry invalidated', async () => {
    process.env.CASCADE_POLL_INTERVAL_MS = '10';
    process.env.CASCADE_IDLE_GRACE_MS = '1';
    process.env.CASCADE_MAX_WAIT_MS = '500';
    process.env.CASCADE_COLD_STALL_BASE_MS = '500';
    process.env.CASCADE_WARM_STALL_MS = '500';
    process.env.GRPC_PROTOCOL = 'connect';

    let startCount = 0;
    let sendCount = 0;
    let observedFreshCascadeId = null;

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

        if (method === 'StartCascade') {
          startCount++;
          observedFreshCascadeId = 'fresh-' + startCount;
          stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
          stream.end(responseBody(startCascadeResponse(observedFreshCascadeId), headers));
          return;
        }

        if (method === 'SendUserCascadeMessage') {
          sendCount++;
          if (sendCount === 1) {
            // Simulate the upstream telling us the cascade we tried to
            // resume is gone — different message text from "panel state
            // not found" so we can prove isExpiredCascade triggers the
            // same recovery and not isPanelMissing.
            const err = errorBody('not_found: cascade trajectory has been expired by ttl', headers);
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            if (err.trailers) stream.additionalHeaders(err.trailers);
            stream.end(err.body);
            return;
          }
          stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
          stream.end(responseBody(Buffer.alloc(0), headers));
          return;
        }

        if (method === 'GetCascadeTrajectorySteps') {
          const offset = readStepOffset(payload);
          stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
          stream.end(responseBody(trajectoryStepsResponse(offset === 0 ? 'recovered-output' : ''), headers));
          return;
        }

        if (method === 'GetCascadeTrajectory') {
          stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
          stream.end(responseBody(trajectoryStatusResponse(1), headers));
          return;
        }

        if (method === 'GetCascadeTrajectoryGeneratorMetadata') {
          stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
          stream.end(responseBody(Buffer.alloc(0), headers));
          return;
        }

        stream.respond({ ':status': 404 });
        stream.end();
      });
    }, async (port) => {
      const { WindsurfClient } = await import('../src/client.js');
      const client = new WindsurfClient('test-api-key', port, 'csrf-token');
      const chunks = await client.cascadeChat([
        { role: 'user', content: 'turn1' },
        { role: 'assistant', content: 'reply1' },
        { role: 'user', content: 'turn2' },
      ], 0, 'claude-sonnet-4-6', {
        reuseEntry: {
          cascadeId: 'long-dead-cascade',
          sessionId: 'long-dead-session',
          lsPort: port,
          apiKey: 'test-api-key',
          stepOffset: 5,
          generatorOffset: 5,
        },
      });

      // Recovery path actually fired: client called StartCascade after the
      // expired-send and got a fresh cascadeId on the trailing send.
      assert.equal(sendCount, 2, 'should retry send once after recovery');
      assert.equal(startCount, 1, 'recovery path should issue exactly one fresh StartCascade');
      assert.equal(chunks.cascadeId, observedFreshCascadeId, 'final cascadeId must be the fresh one, not the dead reuseEntry.cascadeId');
      assert.notEqual(chunks.cascadeId, 'long-dead-cascade');
      assert.equal(chunks.reuseEntryInvalidated, true, 'should signal the caller to skip restoring the dead entry');
      assert.match(chunks.map(c => c.text || '').join(''), /recovered-output/);
    });
  });

  it('GPT-08: a rebuilt history reports the coverage of what was actually sent', async () => {
    // An independent audit (2026-09-22) found the recovery path reporting full coverage
    // for a truncated history: the fresh branch updated historyCoverage, the resume
    // rebuild truncated the same history without updating it, and the stale value was
    // returned verbatim. In THIS fixture both paths open with zero bytes of system
    // prompt, so the same history and budget must give the same answer — that is a
    // property of this input, not a general rule: with a system prompt present the fresh
    // path counts its length and the rebuilt path does not, so the two coverage values
    // legitimately differ. See test/review-history-coverage.test.js, which pins each
    // path against the turns actually present in the encoded request.
    const budget = 1024;
    const prevBudget = process.env.CASCADE_MAX_HISTORY_BYTES;
    process.env.CASCADE_MAX_HISTORY_BYTES = String(budget);
    process.env.CASCADE_POLL_INTERVAL_MS = '10';
    process.env.CASCADE_IDLE_GRACE_MS = '1';
    process.env.CASCADE_MAX_WAIT_MS = '1500';
    process.env.CASCADE_COLD_STALL_BASE_MS = '500';
    process.env.CASCADE_WARM_STALL_MS = '500';
    process.env.GRPC_PROTOCOL = 'connect';

    // Five turns, the first three far larger than the whole budget: with 1024 bytes
    // only the newest assistant turn and the closing user message can fit, so the
    // oldest three are dropped no matter which path builds the prompt.
    const big = 'x'.repeat(2048);
    const history = [
      { role: 'user', content: big },
      { role: 'assistant', content: big },
      { role: 'user', content: big },
      { role: 'assistant', content: 'small-reply' },
      { role: 'user', content: 'final question' },
    ];

    const run = ({ resume }) => {
      let sendCount = 0;
      return withFakeLanguageServer((stream, headers) => {
        const chunks = [];
        stream.on('data', chunk => chunks.push(chunk));
        stream.on('end', () => {
          const path = String(headers[':path'] || '');
          const method = path.split('/').pop();
          if (method === 'StartCascade') {
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(startCascadeResponse('cascade-' + (resume ? 'recovered' : 'fresh')), headers));
            return;
          }
          if (method === 'SendUserCascadeMessage') {
            sendCount++;
            if (resume && sendCount === 1) {
              const err = errorBody('not_found: cascade trajectory has been expired by ttl', headers);
              stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
              if (err.trailers) stream.additionalHeaders(err.trailers);
              stream.end(err.body);
              return;
            }
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(Buffer.alloc(0), headers));
            return;
          }
          if (method === 'GetCascadeTrajectorySteps') {
            const offset = readStepOffset(requestPayload(Buffer.concat(chunks), headers));
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(trajectoryStepsResponse(offset === 0 ? 'final answer' : ''), headers));
            return;
          }
          if (method === 'GetCascadeTrajectory') {
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(trajectoryStatusResponse(1), headers));
            return;
          }
          if (method === 'GetCascadeTrajectoryGeneratorMetadata') {
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(Buffer.alloc(0), headers));
            return;
          }
          stream.respond({ ':status': 404 });
          stream.end();
        });
      }, async (port) => {
        const { WindsurfClient } = await import('../src/client.js');
        const client = new WindsurfClient('test-api-key', port, 'csrf-token');
        const chunks = await client.cascadeChat(history.map(m => ({ ...m })), 0, 'claude-sonnet-4-6', {
          reuseEntry: resume
            ? {
              cascadeId: 'expired-cascade', sessionId: 'expired-session', lsPort: port,
              apiKey: 'test-api-key', stepOffset: 5, generatorOffset: 5,
            }
            : null,
        });
        if (resume) assert.equal(sendCount, 2, 'the fixture must actually take the recovery path');
        return chunks;
      });
    };

    try {
      const fresh = await run({ resume: false });
      const recovered = await run({ resume: true });

      assert.equal(fresh.historyCoverage.totalTurns, 5);
      assert.equal(fresh.historyCoverage.droppedTurnCount, 3, 'the fixture must truncate the three oversized turns');
      assert.equal(fresh.historyCoverage.firstIncludedTurnIndex, 3);
      assert.deepEqual(
        recovered.historyCoverage,
        fresh.historyCoverage,
        'with no system prompt both paths send the same truncated history, so their coverage must agree',
      );
    } finally {
      if (prevBudget === undefined) delete process.env.CASCADE_MAX_HISTORY_BYTES;
      else process.env.CASCADE_MAX_HISTORY_BYTES = prevBudget;
    }
  });

  it('native bridge returns after first cascade-native tool call instead of waiting for remote execution', async () => {
    process.env.CASCADE_POLL_INTERVAL_MS = '10';
    process.env.CASCADE_IDLE_GRACE_MS = '1';
    process.env.CASCADE_MAX_WAIT_MS = '500';
    process.env.CASCADE_COLD_STALL_BASE_MS = '500';
    process.env.CASCADE_WARM_STALL_MS = '500';
    process.env.GRPC_PROTOCOL = 'connect';

    let statusPolls = 0;
    let stepPolls = 0;
    const streamed = [];

    await withFakeLanguageServer((stream, headers) => {
      const chunks = [];
      stream.on('data', chunk => chunks.push(chunk));
      stream.on('end', () => {
        const path = String(headers[':path'] || '');
        const method = path.split('/').pop();

        if (method === 'StartCascade') {
          stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
          stream.end(responseBody(startCascadeResponse('native-cascade'), headers));
          return;
        }

        if (method === 'SendUserCascadeMessage') {
          stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
          stream.end(responseBody(Buffer.alloc(0), headers));
          return;
        }

        if (method === 'GetCascadeTrajectorySteps') {
          stepPolls++;
          stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
          stream.end(responseBody(runCommandStepResponse('printf HELLO'), headers));
          return;
        }

        if (method === 'GetCascadeTrajectory') {
          statusPolls++;
          stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
          stream.end(responseBody(trajectoryStatusResponse(2), headers));
          return;
        }

        if (method === 'GetCascadeTrajectoryGeneratorMetadata') {
          stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
          stream.end(responseBody(Buffer.alloc(0), headers));
          return;
        }

        stream.respond({ ':status': 404 });
        stream.end();
      });
    }, async (port) => {
      const { WindsurfClient } = await import('../src/client.js');
      const client = new WindsurfClient('test-api-key', port, 'csrf-token');
      const chunks = await client.cascadeChat([{ role: 'user', content: 'run it' }], 0, 'claude-4.5-haiku', {
        nativeMode: true,
        nativeAllowlist: ['run_command'],
        onChunk: c => streamed.push(c),
      });

      assert.equal(stepPolls, 1);
      assert.equal(statusPolls, 0, 'native tool call should end before status polling waits for remote execution');
      assert.equal(chunks.toolCalls.length, 1);
      assert.equal(chunks.toolCalls[0].name, 'run_command');
      assert.match(chunks.toolCalls[0].argumentsJson, /printf HELLO/);
      assert.equal(streamed.filter(c => c.nativeToolCall).length, 1);
    });
  });

  it('native bridge protocol lab can poll after a cascade-native tool call', async () => {
    const restoreEnv = setEnvForTest({
      WINDSURFAPI_NATIVE_TOOL_BRIDGE_POLL_AFTER_TOOL: '1',
      CASCADE_POLL_INTERVAL_MS: '10',
      CASCADE_IDLE_GRACE_MS: '1',
      CASCADE_MAX_WAIT_MS: '700',
      CASCADE_COLD_STALL_BASE_MS: '700',
      CASCADE_WARM_STALL_MS: '700',
      GRPC_PROTOCOL: 'connect',
    });

    let statusPolls = 0;
    let stepPolls = 0;
    const streamed = [];

    try {
      await withFakeLanguageServer((stream, headers) => {
        const chunks = [];
        stream.on('data', chunk => chunks.push(chunk));
        stream.on('end', () => {
          const path = String(headers[':path'] || '');
          const method = path.split('/').pop();

          if (method === 'StartCascade') {
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(startCascadeResponse('native-cascade-lab'), headers));
            return;
          }

          if (method === 'SendUserCascadeMessage') {
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(Buffer.alloc(0), headers));
            return;
          }

          if (method === 'GetCascadeTrajectorySteps') {
            stepPolls++;
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(runCommandStepResponse('printf LAB'), headers));
            return;
          }

          if (method === 'GetCascadeTrajectory') {
            statusPolls++;
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(trajectoryStatusResponse(1), headers));
            return;
          }

          if (method === 'GetCascadeTrajectoryGeneratorMetadata') {
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(Buffer.alloc(0), headers));
            return;
          }

          stream.respond({ ':status': 404 });
          stream.end();
        });
      }, async (port) => {
        const { WindsurfClient } = await import('../src/client.js');
        const client = new WindsurfClient('test-api-key', port, 'csrf-token');
        const chunks = await client.cascadeChat([{ role: 'user', content: 'run it' }], 0, 'claude-4.5-haiku', {
          nativeMode: true,
          nativeAllowlist: ['run_command'],
          onChunk: c => streamed.push(c),
        });

        assert.ok(stepPolls > 1, 'lab mode should keep polling trajectory steps after the native tool call');
        assert.ok(statusPolls > 0, 'lab mode should poll trajectory status instead of returning immediately');
        assert.equal(chunks.toolCalls.length, 1);
        assert.equal(chunks.toolCalls[0].name, 'run_command');
        assert.match(chunks.toolCalls[0].argumentsJson, /printf LAB/);
        assert.equal(streamed.filter(c => c.nativeToolCall).length, 1);
      });
    } finally {
      restoreEnv();
    }
  });

  it('does not auto-approve WebFetch requested interactions unless explicitly enabled', async () => {
    const restoreEnv = setEnvForTest({
      WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE: undefined,
      WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE_ORIGINS: undefined,
      CASCADE_POLL_INTERVAL_MS: '10',
      CASCADE_IDLE_GRACE_MS: '1',
      CASCADE_MAX_WAIT_MS: '80',
      CASCADE_COLD_STALL_BASE_MS: '80',
      GRPC_PROTOCOL: 'connect',
    });
    try {
      const approvals = await countPendingReadUrlApprovals();
      assert.equal(approvals, 0);
    } finally {
      restoreEnv();
    }
  });

  it('does not auto-approve WebFetch requested interactions when origin is not allowed', async () => {
    const restoreEnv = setEnvForTest({
      WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE: '1',
      WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE_ORIGINS: 'https://other.example',
      CASCADE_POLL_INTERVAL_MS: '10',
      CASCADE_IDLE_GRACE_MS: '1',
      CASCADE_MAX_WAIT_MS: '80',
      CASCADE_COLD_STALL_BASE_MS: '80',
      GRPC_PROTOCOL: 'connect',
    });
    try {
      const approvals = await countPendingReadUrlApprovals();
      assert.equal(approvals, 0);
    } finally {
      restoreEnv();
    }
  });

  it('does not auto-approve WebFetch requested interactions outside the read_url_content native allowlist', async () => {
    const restoreEnv = setEnvForTest({
      WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE: '1',
      WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE_ORIGINS: 'https://example.com',
      CASCADE_POLL_INTERVAL_MS: '10',
      CASCADE_IDLE_GRACE_MS: '1',
      CASCADE_MAX_WAIT_MS: '80',
      CASCADE_COLD_STALL_BASE_MS: '80',
      GRPC_PROTOCOL: 'connect',
    });
    try {
      const approvals = await countPendingReadUrlApprovals({ nativeAllowlist: ['run_command'] });
      assert.equal(approvals, 0);
    } finally {
      restoreEnv();
    }
  });

  it('lab auto-approves allowed WebFetch requested interaction and waits for completed content', async () => {
    const restoreEnv = setEnvForTest({
      WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE: '1',
      WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE_ORIGINS: 'https://example.com',
      CASCADE_POLL_INTERVAL_MS: '10',
      CASCADE_IDLE_GRACE_MS: '1',
      CASCADE_MAX_WAIT_MS: '500',
      CASCADE_COLD_STALL_BASE_MS: '500',
      GRPC_PROTOCOL: 'connect',
    });

    let approvals = 0;
    let approvedPayload = null;
    let approved = false;
    try {
      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 = requestPayload(Buffer.concat(chunks), headers);
          if (method === 'StartCascade') {
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(startCascadeResponse('webfetch-cascade'), headers));
            return;
          }
          if (method === 'SendUserCascadeMessage') {
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(Buffer.alloc(0), headers));
            return;
          }
          if (method === 'GetCascadeTrajectorySteps') {
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(approved ? readUrlContentDoneStepResponse() : pendingReadUrlStepResponse(), headers));
            return;
          }
          if (method === 'GetCascadeTrajectory') {
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(trajectoryInfoResponse(approved ? 1 : 2, 'trajectory-webfetch', 'webfetch-cascade'), headers));
            return;
          }
          if (method === 'HandleCascadeUserInteraction') {
            approvals++;
            approved = true;
            approvedPayload = payload;
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(Buffer.alloc(0), headers));
            return;
          }
          if (method === 'GetCascadeTrajectoryGeneratorMetadata') {
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(Buffer.alloc(0), headers));
            return;
          }
          stream.respond({ ':status': 404 });
          stream.end();
        });
      }, async (port) => {
        const { WindsurfClient } = await import('../src/client.js');
        const client = new WindsurfClient('test-api-key', port, 'csrf-token');
        const chunks = await client.cascadeChat([{ role: 'user', content: 'fetch it' }], 0, 'claude-4.5-haiku', {
          nativeMode: true,
          nativeAllowlist: ['read_url_content'],
        });
        assert.equal(chunks.toolCalls.length, 1);
        assert.equal(chunks.toolCalls[0].name, 'read_url_content');
        assert.match(chunks.toolCalls[0].argumentsJson, /https:\/\/example\.com\//);
        assert.equal(chunks.toolCalls[0].result, 'Example Domain fetched body');
      });
      assert.equal(approvals, 1);
      const top = parseFields(approvedPayload);
      assert.equal(getField(top, 1, 2).value.toString('utf8'), 'webfetch-cascade');
      const interaction = parseFields(getField(top, 2, 2).value);
      assert.equal(getField(interaction, 1, 2).value.toString('utf8'), 'trajectory-webfetch');
      assert.equal(getField(interaction, 2, 0).value, 0);
      const readUrl = parseFields(getField(interaction, 15, 2).value);
      assert.equal(getField(readUrl, 1, 0).value, 1);
      assert.equal(getField(readUrl, 2, 2).value.toString('utf8'), 'https://example.com/');
      assert.equal(getField(readUrl, 3, 2).value.toString('utf8'), 'https://example.com');
    } finally {
      restoreEnv();
    }
  });

  it('does not treat completed WebFetch with requested interaction echo as a proposal', async () => {
    const restoreEnv = setEnvForTest({
      WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE: '1',
      WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE_ORIGINS: 'https://example.com',
      WINDSURFAPI_NATIVE_TOOL_BRIDGE_POLL_AFTER_TOOL: '1',
      CASCADE_POLL_INTERVAL_MS: '10',
      CASCADE_IDLE_GRACE_MS: '1',
      CASCADE_MAX_WAIT_MS: '500',
      CASCADE_COLD_STALL_BASE_MS: '500',
      GRPC_PROTOCOL: 'connect',
    });

    let approvals = 0;
    const streamed = [];
    try {
      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 = requestPayload(Buffer.concat(chunks), headers);
          if (method === 'StartCascade') {
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(startCascadeResponse('webfetch-completed-cascade'), headers));
            return;
          }
          if (method === 'SendUserCascadeMessage') {
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(Buffer.alloc(0), headers));
            return;
          }
          if (method === 'GetCascadeTrajectorySteps') {
            // Return the two steps once; the client advances step_offset, so a
            // second batch must be empty or the planner text is replayed.
            const stepOffset = readStepOffset(payload);
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(stepOffset ? Buffer.alloc(0) : Buffer.concat([
              readUrlContentDoneWithEchoStepResponse(),
              trajectoryStepsResponse('Natural answer using Example Domain.'),
            ]), headers));
            return;
          }
          if (method === 'HandleCascadeUserInteraction') {
            approvals++;
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(Buffer.alloc(0), headers));
            return;
          }
          if (method === 'GetCascadeTrajectory') {
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(trajectoryInfoResponse(1, 'trajectory-webfetch', 'webfetch-completed-cascade'), headers));
            return;
          }
          if (method === 'GetCascadeTrajectoryGeneratorMetadata') {
            stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
            stream.end(responseBody(Buffer.alloc(0), headers));
            return;
          }
          stream.respond({ ':status': 404 });
          stream.end();
        });
      }, async (port) => {
        const { WindsurfClient } = await import('../src/client.js');
        const client = new WindsurfClient('test-api-key', port, 'csrf-token');
        const chunks = await client.cascadeChat([{ role: 'user', content: 'fetch it' }], 0, 'claude-4.5-haiku', {
          nativeMode: true,
          nativeAllowlist: ['read_url_content'],
          onChunk: c => streamed.push(c),
        });
        assert.equal(approvals, 0);
        assert.equal(chunks.toolCalls.length, 1);
        assert.equal(chunks.toolCalls[0].name, 'read_url_content');
        assert.equal(chunks.toolCalls[0].result, 'Example Domain fetched body');
        assert.equal(chunks.toolCalls[0].hasWebDocument, true);
        assert.equal(streamed.filter(c => c.nativeToolCall).length, 0);
        assert.equal(streamed.filter(c => c.nativeToolResult).length, 1);
        assert.match(chunks.map(c => c.text || '').join(''), /Natural answer using Example Domain/);
      });
    } finally {
      restoreEnv();
    }
  });
});