Skip to content

Commit 1c47aef

Browse files
dwgxclaude
andcommitted
fix: surface completed native WebFetch documents (#183)
The native WebFetch path actually works end-to-end: when the language server already executed read_url_content and returned a real web_document, Cascade also produced a final assistant answer. But the proxy mislabeled the completed step (a co-present requested_interaction echo was checked first) and surfaced a dead read_url_content tool-call proposal, dropping both the model answer and the fetched document. - proto-trace.js: classify completed_web_document before pending_permission - windsurf.js: flag read_url_content steps that carry a real web_document - client.js: split completed WebFetch results from proposals; do not re-approve or early-stop on an already-executed result - handlers/chat.js: completed read_url_content is not converted to OpenAI tool_calls; preserve final model text (finish_reason=stop), fall back to the document body when the model produced no text. Stream path guards against duplicate output. Bash/Read/Grep and document-less pending WebFetch keep existing proposal semantics. Verified on a memory-safe lab instance: native WebFetch fetches https://example.com and the document content is confirmed present. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7c151e1 commit 1c47aef

9 files changed

Lines changed: 330 additions & 17 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
## v2.0.144 - WebFetch completed document handling
2+
3+
This release fixes the lab-gated native WebFetch path when the language server
4+
has already executed `read_url_content` and returned a real `web_document`.
5+
6+
- Completed `read_url_content.web_document` steps are no longer surfaced to
7+
OpenAI clients as dead tool-call proposals. The proxy now preserves Cascade's
8+
final assistant text with `finish_reason="stop"`.
9+
- If Cascade returns a completed WebFetch document but no final text, the proxy
10+
falls back to the fetched document body as assistant content.
11+
- Pending WebFetch permission steps without a document keep the existing
12+
proposal/wait behavior, and Bash/Read/Grep native proposal semantics are
13+
unchanged.
14+
- Proto trace classification now checks `completed_web_document` before
15+
`pending_permission`, so steps that contain both `web_document` and a
16+
requested-interaction echo are classified as completed.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "windsurf-api",
3-
"version": "2.0.143",
3+
"version": "2.0.144",
44
"description": "Windsurf to OpenAI + Anthropic compatible API proxy. Turns Windsurf's 107 AI models (Claude, GPT, Gemini, DeepSeek, Grok, Qwen, Kimi, GLM, SWE) into dual-protocol API endpoints. Zero npm deps.",
55
"type": "module",
66
"main": "src/index.js",

src/client.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,14 @@ export function shouldColdStall({ elapsed, coldStallMs, sawActive, sawText, tota
288288
return elapsed > coldStallMs && sawActive && !sawText && (totalThinking || 0) === 0 && (toolCallCount || 0) === 0;
289289
}
290290

291+
function isCompletedReadUrlNativeResult(tc) {
292+
return !!(tc?.cascade_native
293+
&& tc.name === 'read_url_content'
294+
&& tc.hasWebDocument
295+
&& typeof tc.result === 'string'
296+
&& tc.result.length > 0);
297+
}
298+
291299
// v2.0.74 (#122). Three-tier ceiling picker for warm-stall detection.
292300
// Exported so the regression test can assert that:
293301
// - tool-active beats thinking beats text-only
@@ -918,12 +926,13 @@ export class WindsurfClient {
918926
const steps = parseTrajectorySteps(stepsResp);
919927
const hasNativeProposalInBatch = nativeMode && !nativeBridgePollAfterTool
920928
&& steps.some(step => Array.isArray(step?.toolCalls)
921-
&& step.toolCalls.some(tc => tc?.cascade_native));
929+
&& step.toolCalls.some(tc => tc?.cascade_native && !isCompletedReadUrlNativeResult(tc)));
922930

923931
if (nativeMode && (nativeAllowlist || []).includes('read_url_content')) {
924932
for (let i = 0; i < steps.length; i++) {
925933
const pending = steps[i]?.requestedInteraction;
926934
if (pending?.kind !== 'read_url_content') continue;
935+
if (steps[i]?.toolCalls?.some(isCompletedReadUrlNativeResult)) continue;
927936
const url = pending.url || '';
928937
const origin = pending.origin || '';
929938
if (!isReadUrlAutoApproveAllowed(url, origin)) continue;
@@ -1058,7 +1067,9 @@ export class WindsurfClient {
10581067
// ChatToolCall variants are dropped in chat.js anyway —
10591068
// see "Built-in Cascade tool calls ... DROPPED" comment).
10601069
if (tc.cascade_native) {
1061-
const chunk = { text: '', thinking: '', isError: false, nativeToolCall: tc };
1070+
const chunk = isCompletedReadUrlNativeResult(tc)
1071+
? { text: '', thinking: '', isError: false, nativeToolResult: tc }
1072+
: { text: '', thinking: '', isError: false, nativeToolCall: tc };
10621073
chunks.push(chunk);
10631074
onChunk?.(chunk);
10641075
}
@@ -1067,7 +1078,8 @@ export class WindsurfClient {
10671078
// Cascade-native IDE step has been surfaced as a tool_call, stop
10681079
// polling immediately so the LS does not keep executing the
10691080
// built-in tool in the remote workspace while the client waits.
1070-
if (nativeMode && step.toolCalls.some(tc => tc.cascade_native)) {
1081+
const hasNativeProposal = step.toolCalls.some(tc => tc.cascade_native && !isCompletedReadUrlNativeResult(tc));
1082+
if (nativeMode && hasNativeProposal) {
10711083
if (!nativeBridgePollAfterTool) {
10721084
endReason = 'native_tool_call';
10731085
break;

src/handlers/chat.js

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ function appendAssistantTurn(messages, allText, toolCalls) {
8787
return [...(messages || []), m];
8888
}
8989

90+
function isCompletedReadUrlNativeResult(raw) {
91+
return !!(raw?.cascade_native
92+
&& raw.name === 'read_url_content'
93+
&& raw.hasWebDocument
94+
&& typeof raw.result === 'string'
95+
&& raw.result.length > 0);
96+
}
97+
9098
// Cap exponential backoff before falling over to the next account when
9199
// upstream Cascade returns "internal error occurred". Without this a
92100
// 9-account pool hammers the upstream within ~10s and every attempt
@@ -2516,6 +2524,7 @@ async function nonStreamResponse(client, id, created, model, modelKey, messages,
25162524
let allThinking = '';
25172525
let cascadeMeta = null;
25182526
let toolCalls = [];
2527+
let hasCompletedNativeReadUrlResult = false;
25192528
const bridgeDiag = {
25202529
bridgeEnabled: nativeBridgeOn,
25212530
requestedTools: Array.isArray(tools) && tools.length > 0,
@@ -2566,11 +2575,18 @@ async function nonStreamResponse(client, id, created, model, modelKey, messages,
25662575
// can't be safely surfaced (caller wouldn't know how to execute).
25672576
const lookup = nativeOpts?.callerLookup || new Map();
25682577
const nativeCalls = [];
2578+
const nativeResults = [];
2579+
let usedNativeReadUrlFallback = false;
25692580
for (const raw of (chunks.toolCalls || [])) {
25702581
if (!raw?.cascade_native) continue;
25712582
bridgeDiag.cascadeToolCalls++;
25722583
bridgeDiag.cascadeKinds.push(raw.name);
25732584
recordNativeBridgeCascadeToolCall(raw.name);
2585+
if (isCompletedReadUrlNativeResult(raw)) {
2586+
nativeResults.push(raw);
2587+
hasCompletedNativeReadUrlResult = true;
2588+
continue;
2589+
}
25742590
const candidates = lookup.get(raw.name) || [];
25752591
const callerName = candidates[0];
25762592
if (!callerName) {
@@ -2595,7 +2611,11 @@ async function nonStreamResponse(client, id, created, model, modelKey, messages,
25952611
}
25962612
toolCalls = filterToolCallsByAllowlist(nativeCalls, tools);
25972613
for (const tc of toolCalls) recordNativeBridgeEmittedToolCall(tc.name, { source: 'cascade' });
2598-
if (toolCalls.length === 0 && allText) {
2614+
if (!allText && toolCalls.length === 0 && nativeResults.length) {
2615+
allText = nativeResults.map(raw => raw.result).filter(Boolean).join('\n');
2616+
usedNativeReadUrlFallback = !!allText;
2617+
}
2618+
if (toolCalls.length === 0 && allText && !usedNativeReadUrlFallback) {
25992619
const fallback = parseNativeFunctionCallsFromText(allText, lookup);
26002620
const filteredFallback = filterToolCallsByAllowlist(fallback.toolCalls, tools);
26012621
allText = fallback.text || '';
@@ -2612,10 +2632,10 @@ async function nonStreamResponse(client, id, created, model, modelKey, messages,
26122632
// emitting the cascade step, and the caller doesn't want that
26132633
// noise.
26142634
allText = stripToolMarkupFromText(allText);
2615-
if (toolCalls.length === 0 && (chunks.toolCalls || []).length > 0) {
2635+
if (toolCalls.length === 0 && nativeResults.length === 0 && (chunks.toolCalls || []).length > 0) {
26162636
log.info(`Chat[non-stream]: nativeBridge=true received ${chunks.toolCalls.length} cascade tool calls but none mapped to caller tools (kinds=${chunks.toolCalls.map(tc => tc.name).join(',')})`);
26172637
}
2618-
if (toolCalls.length === 0) recordNativeBridgeNoToolCallResponse();
2638+
if (toolCalls.length === 0 && nativeResults.length === 0) recordNativeBridgeNoToolCallResponse();
26192639
} else if (emulateTools) {
26202640
// Capture pre-parse text once for diagnostic logging — useful when
26212641
// non-Claude models emit a tool call in a format the parser missed.
@@ -2844,7 +2864,7 @@ async function nonStreamResponse(client, id, created, model, modelKey, messages,
28442864
allThinking = '';
28452865
}
28462866
bridgeDiag.totalToolCalls = toolCalls.length;
2847-
bridgeDiag.noToolCalls = bridgeDiag.requestedTools && toolCalls.length === 0;
2867+
bridgeDiag.noToolCalls = bridgeDiag.requestedTools && toolCalls.length === 0 && !hasCompletedNativeReadUrlResult;
28482868
logBridgeResultDiagnostics(reqId, bridgeDiag);
28492869

28502870
// Check the cascade back into the pool under the *post-turn* fingerprint
@@ -3226,6 +3246,7 @@ function streamResponse(id, created, model, modelKey, provider, messages, cascad
32263246
route: deps?.route || 'chat',
32273247
}) : null;
32283248
const collectedToolCalls = [];
3249+
const completedNativeReadUrlResults = [];
32293250
const bridgeDiagCascadeSeen = new Set();
32303251
const bridgeDiag = {
32313252
bridgeEnabled: nativeBridgeOn,
@@ -3325,6 +3346,18 @@ function streamResponse(id, created, model, modelKey, provider, messages, cascad
33253346
// back into the caller's OpenAI tool name via callerLookup, then
33263347
// emit as a tool_call delta. Old behaviour batched these at
33273348
// turn end (release notes for v2.0.65 documented the gap).
3349+
if (nativeBridgeOn && chunk.nativeToolResult) {
3350+
const raw = chunk.nativeToolResult;
3351+
if (markBridgeDiagCascadeRaw({ ...raw, cascade_native: true })) {
3352+
recordNativeBridgeCascadeToolCall(raw.name);
3353+
}
3354+
if (isCompletedReadUrlNativeResult(raw)
3355+
&& !completedNativeReadUrlResults.some(existing => (existing.id || '') === (raw.id || ''))) {
3356+
completedNativeReadUrlResults.push(raw);
3357+
}
3358+
return;
3359+
}
3360+
33283361
if (nativeBridgeOn && chunk.nativeToolCall) {
33293362
const raw = chunk.nativeToolCall;
33303363
markBridgeDiagCascadeRaw({ ...raw, cascade_native: true });
@@ -3695,8 +3728,15 @@ function streamResponse(id, created, model, modelKey, provider, messages, cascad
36953728
const nativeRaw = [];
36963729
for (const raw of cascadeResult.toolCalls) {
36973730
if (!raw?.cascade_native) continue;
3698-
markBridgeDiagCascadeRaw(raw);
3699-
recordNativeBridgeCascadeToolCall(raw.name);
3731+
if (markBridgeDiagCascadeRaw(raw)) {
3732+
recordNativeBridgeCascadeToolCall(raw.name);
3733+
}
3734+
if (isCompletedReadUrlNativeResult(raw)) {
3735+
if (!completedNativeReadUrlResults.some(existing => (existing.id || '') === (raw.id || ''))) {
3736+
completedNativeReadUrlResults.push(raw);
3737+
}
3738+
continue;
3739+
}
37003740
const candidates = lookup.get(raw.name) || [];
37013741
const callerName = candidates[0];
37023742
if (!callerName) {
@@ -3727,13 +3767,18 @@ function streamResponse(id, created, model, modelKey, provider, messages, cascad
37273767
recordNativeBridgeEmittedToolCall(tc.name, { source: 'cascade' });
37283768
emitToolCallDelta(tc, idx);
37293769
}
3730-
if (filteredNative.length === 0 && cascadeResult.toolCalls.some(tc => tc.cascade_native)) {
3770+
if (filteredNative.length === 0 && completedNativeReadUrlResults.length === 0 && cascadeResult.toolCalls.some(tc => tc.cascade_native)) {
37313771
log.info(`Chat[stream]: nativeBridge=true received cascade tool calls but none mapped to caller tools (kinds=${cascadeResult.toolCalls.filter(tc => tc.cascade_native).map(tc => tc.name).join(',')})`);
37323772
}
37333773
}
3734-
if (nativeBridgeOn && collectedToolCalls.length === 0) recordNativeBridgeNoToolCallResponse();
3774+
if (accText.length === 0 && collectedToolCalls.length === 0 && completedNativeReadUrlResults.length) {
3775+
const fallbackText = completedNativeReadUrlResults.map(raw => raw.result).filter(Boolean).join('\n');
3776+
if (fallbackText) emitContent(pathStreamText.feed(fallbackText));
3777+
emitContent(pathStreamText.flush());
3778+
}
3779+
if (nativeBridgeOn && collectedToolCalls.length === 0 && completedNativeReadUrlResults.length === 0) recordNativeBridgeNoToolCallResponse();
37353780
bridgeDiag.totalToolCalls = collectedToolCalls.length;
3736-
bridgeDiag.noToolCalls = bridgeDiag.requestedTools && collectedToolCalls.length === 0;
3781+
bridgeDiag.noToolCalls = bridgeDiag.requestedTools && collectedToolCalls.length === 0 && completedNativeReadUrlResults.length === 0;
37373782
logBridgeResultDiagnostics(reqId, bridgeDiag);
37383783
// Pool check-in on success (cascade only)
37393784
if (reuseEnabled && cascadeResult?.cascadeId && (accText || collectedToolCalls.length)) {

src/proto-trace.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -700,8 +700,8 @@ function summarizeWebFetchTrajectoryBranch({ type, status, nativeOneofs, request
700700
const hasLegacySummary = legacySummaryBytes > 0;
701701
const autoRunDecision = readUrlOneof?.body?.autoRunDecision ?? null;
702702
let state = null;
703-
if (pendingReadUrl) state = 'pending_permission';
704-
else if (readUrlOneof && hasWebDocument) state = 'completed_web_document';
703+
if (readUrlOneof && hasWebDocument) state = 'completed_web_document';
704+
else if (pendingReadUrl) state = 'pending_permission';
705705
else if (readUrlOneof && autoRunDecision != null && !hasWebDocument && !hasLegacySummary) state = 'auto_run_decision_only';
706706
else if (readUrlOneof && hasLegacySummary && !hasWebDocument) state = 'legacy_summary_only';
707707
else if (readUrlOneof) state = 'native_oneof_no_document';

src/windsurf.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1264,11 +1264,12 @@ export function parseTrajectorySteps(buf) {
12641264
};
12651265
argumentsJson = JSON.stringify(args);
12661266
const webDocument = getField(body, 2, 2);
1267-
result = webDocument ? decodeKnowledgeBaseItemText(webDocument.value) : '';
1267+
const hasWebDocument = !!webDocument;
1268+
result = hasWebDocument ? decodeKnowledgeBaseItemText(webDocument.value) : '';
12681269
if (!result && readUrlLegacySummaryFallbackEnabled()) {
12691270
result = getField(body, 5, 2)?.value?.toString('utf8') || '';
12701271
}
1271-
if (!result && readUrlRequestedInteraction) continue;
1272+
if (!hasWebDocument && !result && readUrlRequestedInteraction) continue;
12721273
} else if (kind === 'search_web') {
12731274
const args = {
12741275
query: getField(body, 1, 2)?.value?.toString('utf8') || '',
@@ -1289,6 +1290,7 @@ export function parseTrajectorySteps(buf) {
12891290
name: kind,
12901291
argumentsJson,
12911292
result,
1293+
...(kind === 'read_url_content' && getField(body, 2, 2) ? { hasWebDocument: true } : {}),
12921294
cascade_native: true,
12931295
});
12941296
}

test/client-panel-retry.test.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,32 @@ function readUrlContentDoneStepResponse() {
148148
return writeMessageField(1, step);
149149
}
150150

151+
function readUrlContentDoneWithEchoStepResponse() {
152+
const spec = Buffer.concat([
153+
writeStringField(1, 'https://example.com/'),
154+
writeStringField(2, 'https://example.com'),
155+
]);
156+
const doc = Buffer.concat([
157+
writeStringField(2, 'Example Domain fetched body'),
158+
writeStringField(3, 'https://example.com/'),
159+
writeStringField(7, 'Example Domain summary'),
160+
]);
161+
const body = Buffer.concat([
162+
writeStringField(1, 'https://example.com/'),
163+
writeMessageField(2, doc),
164+
writeStringField(3, 'https://example.com/'),
165+
writeVarintField(4, 123),
166+
writeVarintField(7, 8),
167+
]);
168+
const step = Buffer.concat([
169+
writeVarintField(1, 31),
170+
writeVarintField(4, 3),
171+
writeMessageField(56, writeMessageField(14, spec)),
172+
writeMessageField(40, body),
173+
]);
174+
return writeMessageField(1, step);
175+
}
176+
151177
function setEnvForTest(values) {
152178
const previous = new Map(Object.keys(values).map(key => [key, process.env[key]]));
153179
for (const [key, value] of Object.entries(values)) {
@@ -713,4 +739,83 @@ describe('WindsurfClient cascade panel retry', () => {
713739
restoreEnv();
714740
}
715741
});
742+
743+
it('does not treat completed WebFetch with requested interaction echo as a proposal', async () => {
744+
const restoreEnv = setEnvForTest({
745+
WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE: '1',
746+
WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE_ORIGINS: 'https://example.com',
747+
WINDSURFAPI_NATIVE_TOOL_BRIDGE_POLL_AFTER_TOOL: '1',
748+
CASCADE_POLL_INTERVAL_MS: '10',
749+
CASCADE_IDLE_GRACE_MS: '1',
750+
CASCADE_MAX_WAIT_MS: '500',
751+
CASCADE_COLD_STALL_BASE_MS: '500',
752+
GRPC_PROTOCOL: 'connect',
753+
});
754+
755+
let approvals = 0;
756+
const streamed = [];
757+
try {
758+
await withFakeLanguageServer((stream, headers) => {
759+
const chunks = [];
760+
stream.on('data', chunk => chunks.push(chunk));
761+
stream.on('end', () => {
762+
const method = String(headers[':path'] || '').split('/').pop();
763+
if (method === 'StartCascade') {
764+
stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
765+
stream.end(responseBody(startCascadeResponse('webfetch-completed-cascade'), headers));
766+
return;
767+
}
768+
if (method === 'SendUserCascadeMessage') {
769+
stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
770+
stream.end(responseBody(Buffer.alloc(0), headers));
771+
return;
772+
}
773+
if (method === 'GetCascadeTrajectorySteps') {
774+
stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
775+
stream.end(responseBody(Buffer.concat([
776+
readUrlContentDoneWithEchoStepResponse(),
777+
trajectoryStepsResponse('Natural answer using Example Domain.'),
778+
]), headers));
779+
return;
780+
}
781+
if (method === 'HandleCascadeUserInteraction') {
782+
approvals++;
783+
stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
784+
stream.end(responseBody(Buffer.alloc(0), headers));
785+
return;
786+
}
787+
if (method === 'GetCascadeTrajectory') {
788+
stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
789+
stream.end(responseBody(trajectoryInfoResponse(1, 'trajectory-webfetch', 'webfetch-completed-cascade'), headers));
790+
return;
791+
}
792+
if (method === 'GetCascadeTrajectoryGeneratorMetadata') {
793+
stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
794+
stream.end(responseBody(Buffer.alloc(0), headers));
795+
return;
796+
}
797+
stream.respond({ ':status': 404 });
798+
stream.end();
799+
});
800+
}, async (port) => {
801+
const { WindsurfClient } = await import('../src/client.js');
802+
const client = new WindsurfClient('test-api-key', port, 'csrf-token');
803+
const chunks = await client.cascadeChat([{ role: 'user', content: 'fetch it' }], 0, 'claude-4.5-haiku', {
804+
nativeMode: true,
805+
nativeAllowlist: ['read_url_content'],
806+
onChunk: c => streamed.push(c),
807+
});
808+
assert.equal(approvals, 0);
809+
assert.equal(chunks.toolCalls.length, 1);
810+
assert.equal(chunks.toolCalls[0].name, 'read_url_content');
811+
assert.equal(chunks.toolCalls[0].result, 'Example Domain fetched body');
812+
assert.equal(chunks.toolCalls[0].hasWebDocument, true);
813+
assert.equal(streamed.filter(c => c.nativeToolCall).length, 0);
814+
assert.equal(streamed.filter(c => c.nativeToolResult).length, 1);
815+
assert.match(chunks.map(c => c.text || '').join(''), /Natural answer using Example Domain/);
816+
});
817+
} finally {
818+
restoreEnv();
819+
}
820+
});
716821
});

0 commit comments

Comments
 (0)