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(); } }); });