From 205db66b2bb1eb3e60fbea45ab6acd2ffb7b03d6 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Thu, 17 Sep 2026 16:41:35 +0800 Subject: [PATCH 1/2] fix(proxy): stop folding search findings into the text on /v1/messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn that mixed a locally executed web search with a client-owned tool call could not continue in the loop, so `buildMixedTurnPayload` folded the search findings into the assistant text alongside the outstanding calls. On /v1/messages those findings were already on the wire: the route emits `server_tool_use` and `web_search_tool_result` blocks for the same search. The client therefore received every result twice — once as a result block and once as prose reading as if the model had written it, including the "Cite the URL of any result you rely on" line, which is an instruction to the model rather than something the user asked to see. Replaying that transcript put both copies back on the wire to the upstream model. The fold exists because a mixed turn cannot be continued locally: the client owns the outstanding calls, and re-issuing the transcript with only server-tool results would leave them unanswered. It is still needed by routes with no other way to carry the findings, so it is now opted out of rather than removed: routes that render findings structurally pass `findingsAsStructuredBlocks`, and the text is left alone. /v1/messages sets the flag on both its streaming and non-streaming paths. /v1/chat/completions and /v1/responses keep folding — neither has a structured channel for the results, so the fold is their only way to surface them. --- lib/server/proxy/anthropic.ts | 5 +- lib/server/proxy/web-search-loop.ts | 31 +++++- tests/server/web-search.test.ts | 141 ++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 5 deletions(-) diff --git a/lib/server/proxy/anthropic.ts b/lib/server/proxy/anthropic.ts index 19f6dc7..a8d94f0 100644 --- a/lib/server/proxy/anthropic.ts +++ b/lib/server/proxy/anthropic.ts @@ -1285,7 +1285,9 @@ const createAnthropicServerToolEventStream = ( undefined, debugTrace, '/v1/messages', - { emitStreamEvents: true }, + // The findings reach the client as `web_search_tool_result` blocks, + // so the loop must not also fold them into the assistant text. + { emitStreamEvents: true, findingsAsStructuredBlocks: true }, ); if (cancelled) { @@ -1404,6 +1406,7 @@ export const handleMessagesRequest = async ( undefined, debugTrace, '/v1/messages', + { findingsAsStructuredBlocks: true }, ); if (!upstreamResponse.ok) { diff --git a/lib/server/proxy/web-search-loop.ts b/lib/server/proxy/web-search-loop.ts index 11c75cc..aca7c17 100644 --- a/lib/server/proxy/web-search-loop.ts +++ b/lib/server/proxy/web-search-loop.ts @@ -496,14 +496,22 @@ const sumUsage = (accumulated: unknown, incoming: unknown): unknown => { * outstanding tool calls unchanged, so a turn that mixed search with * client-side calls stays a valid transcript. The client sees its own calls * come back as if upstream had returned them directly. + * + * The findings are folded only when the route has no other way to carry them. + * A route that renders them structurally passes + * `findingsAsStructuredBlocks`, and the text is left alone: the results are + * already on the wire as a result block, and a second copy in the prose is + * what the user reads as the model reciting its own search output. */ const buildMixedTurnPayload = ({ + findingsAsStructuredBlocks = false, message, payload, remainingCalls, searchResults, usage, }: { + findingsAsStructuredBlocks?: boolean; message: ChatCompletionMessage | undefined; payload: ChatCompletionPayload; remainingCalls: ChatCompletionToolCall[]; @@ -514,7 +522,9 @@ const buildMixedTurnPayload = ({ typeof message?.content === 'string' && message.content.trim() ? message.content.trim() : ''; - const findings = searchResults.filter(Boolean).join('\n\n'); + const findings = findingsAsStructuredBlocks + ? '' + : searchResults.filter(Boolean).join('\n\n'); const content = [existingText, findings].filter(Boolean).join('\n\n'); return { @@ -634,6 +644,14 @@ export type ServerToolExecution = export interface ServerToolCallbacks { emitStreamEvents?: boolean; + /** + * Set by routes that render a server tool's findings structurally — + * Anthropic's `web_search_tool_result` block — instead of as prose. Those + * routes must not also fold the same findings into the assistant text, or + * the user sees the results twice: once as a result block and once as if + * the model had written them. + */ + findingsAsStructuredBlocks?: boolean; onCall?: (invocation: ServerToolInvocation) => void; onResult?: (execution: ServerToolExecution) => void; } @@ -1334,6 +1352,7 @@ const createInlineServerToolStream = async ({ if (nextRemainingCalls.length) { finalPayload = buildMixedTurnPayload({ + findingsAsStructuredBlocks: callbacks?.findingsAsStructuredBlocks, message, payload: { choices: [{ message }], @@ -1420,6 +1439,8 @@ const createInlineServerToolStream = async ({ }; finalPayload = buildMixedTurnPayload({ + findingsAsStructuredBlocks: + callbacks?.findingsAsStructuredBlocks, message: fallbackMessage, payload: { choices: [{ message: fallbackMessage }], @@ -1665,15 +1686,17 @@ export const executeWebSearchLoop = async ({ // A turn mixing server tools with client-side calls cannot be continued // locally: the client owns those calls, and re-issuing the transcript with // only server-tool results would leave them unanswered, which upstream - // rejects as an invalid tool-call transcript. Run the server tools, fold the - // findings into the message text, and hand the outstanding calls back so the - // client resolves them on its next turn. + // rejects as an invalid tool-call transcript. Run the server tools and + // hand the outstanding calls back so the client resolves them on its next + // turn. The findings ride along in the message text only for routes that + // cannot render them structurally; see `buildMixedTurnPayload`. if (remainingCalls.length) { return { body: loopBody, executions, response: Response.json( buildMixedTurnPayload({ + findingsAsStructuredBlocks: callbacks?.findingsAsStructuredBlocks, // `buildMixedTurnPayload` reads this iteration's text and reasoning // off `message`, so only the earlier iterations go on top; the // current one is folded in by the helper itself. diff --git a/tests/server/web-search.test.ts b/tests/server/web-search.test.ts index c2e7e35..a1e1e83 100644 --- a/tests/server/web-search.test.ts +++ b/tests/server/web-search.test.ts @@ -5298,6 +5298,147 @@ describe('chat proxy web search integration', () => { expect(upstreamCalls).toBe(2); }); + it('does not fold findings into the text when a structured block carries them', async () => { + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); + + let upstreamCalls = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('/agenttool/v1/search')) { + return makeJsonResponse({ + results: [ + { snippet: 'snip', title: 'Result', url: 'https://r.test' }, + ], + }); + } + + upstreamCalls++; + // One turn mixing a local search with a client-owned call, so the loop + // cannot continue and has to hand the outstanding call back. + return makeJsonResponse({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + tool_calls: [ + { + id: 'call_search', + function: { + arguments: '{"query":"two results"}', + name: 'web_search', + }, + }, + { + id: 'call_client', + function: { arguments: '{}', name: 'client_tool' }, + type: 'function', + }, + ], + }, + }, + ], + }); + }); + + const response = await handleMessagesRequest( + makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), + { + max_tokens: 1024, + messages: [{ role: 'user', content: 'Mixed turn' }], + tools: [ + { + type: 'web_search_20260209', + name: 'web_search', + input_schema: {}, + }, + ], + }, + ); + const payload = (await response.json()) as { + content: Array<{ text?: string; type: string }>; + }; + + // The result block is how this route reports the findings, so the prose + // must not repeat them: a second copy reads as the model reciting its own + // search output, and the "Cite the URL" line is an instruction to the + // model rather than something the user ever asked to see. + expect(upstreamCalls).toBeGreaterThan(0); + expect(payload.content.map((block) => block.type)).toContain( + 'web_search_tool_result', + ); + expect( + payload.content + .filter((block) => block.type === 'text') + .map((block) => block.text ?? '') + .join(''), + ).not.toContain('https://r.test'); + expect(JSON.stringify(payload)).not.toContain('Cite the URL'); + }); + + it('keeps folding findings for routes without a structured channel', async () => { + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); + + let upstreamCalls = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('/agenttool/v1/search')) { + return makeJsonResponse({ + results: [ + { snippet: 'snip', title: 'Result', url: 'https://r.test' }, + ], + }); + } + + upstreamCalls++; + return makeJsonResponse({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + tool_calls: [ + { + id: 'call_search', + function: { + arguments: '{"query":"two results"}', + name: 'web_search', + }, + }, + { + id: 'call_client', + function: { arguments: '{}', name: 'client_tool' }, + type: 'function', + }, + ], + }, + }, + ], + }); + }); + + const response = await proxyChatCompletions( + makeNextRequest('http://localhost/v1/chat/completions', { + method: 'POST', + }), + { + messages: [{ content: 'Mixed turn', role: 'user' }], + model: 'glm-5.1', + tools: [{ type: 'web_search_preview' }], + } as never, + ); + const payload = (await response.json()) as { + choices: Array<{ message: { content: string | null } }>; + }; + + // /v1/chat/completions has no structured channel for the findings, so the + // fold has to stay: it is the only way the results reach the caller. + expect(upstreamCalls).toBeGreaterThan(0); + expect(payload.choices[0]?.message.content).toContain('https://r.test'); + }); + it('maps a completed fetch to a Responses open_page call', async () => { await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); let upstreamCalls = 0; From 283e3edc0d5a49cac93a9a56644db2c3776a761a Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Thu, 17 Sep 2026 16:54:02 +0800 Subject: [PATCH 2/2] fix(proxy): honour structured findings on the first streamed mixed turn The opt-out added in the previous commit was only consulted by `buildMixedTurnPayload`. A streaming /v1/messages request whose *first* upstream turn mixed a local search with a client-owned call never reaches that helper: `createInlineServerToolStream` emits its own text delta from the search results before handing the outstanding calls back, so the findings were still sent as prose alongside the `web_search_tool_result` block on the most common streaming path. Apply the same flag there. The result event carries the findings once, so a text copy is the second. --- lib/server/proxy/web-search-loop.ts | 6 +- tests/server/web-search.test.ts | 93 +++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/lib/server/proxy/web-search-loop.ts b/lib/server/proxy/web-search-loop.ts index aca7c17..07a8ea7 100644 --- a/lib/server/proxy/web-search-loop.ts +++ b/lib/server/proxy/web-search-loop.ts @@ -1144,7 +1144,11 @@ const createInlineServerToolStream = async ({ executions.push(...results.map((result) => result.execution)); if (remainingCalls.length) { - const findings = results.map((result) => result.content).join('\n\n'); + // Same opt-out as `buildMixedTurnPayload`: the result event above + // already carries these findings, so a text copy would be the second. + const findings = callbacks.findingsAsStructuredBlocks + ? '' + : results.map((result) => result.content).join('\n\n'); if (findings) { emitJson(controller, { choices: [{ delta: { content: findings }, index: 0 }], diff --git a/tests/server/web-search.test.ts b/tests/server/web-search.test.ts index a1e1e83..14c4a07 100644 --- a/tests/server/web-search.test.ts +++ b/tests/server/web-search.test.ts @@ -5377,6 +5377,99 @@ describe('chat proxy web search integration', () => { expect(JSON.stringify(payload)).not.toContain('Cite the URL'); }); + it('does not stream folded findings when a structured block carries them', async () => { + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('/agenttool/v1/search')) { + return makeJsonResponse({ + results: [ + { snippet: 'snip', title: 'Result', url: 'https://r.test' }, + ], + }); + } + + // The very first upstream turn mixes the local search with a client + // call. That branch emits its own text delta rather than going through + // `buildMixedTurnPayload`, so it needs the same opt-out. + return makeSseResponse({ + choices: [ + { + delta: { + tool_calls: [ + { + id: 'call_search', + index: 0, + function: { + arguments: '{"query":"two results"}', + name: 'web_search', + }, + }, + { + id: 'call_client', + index: 1, + function: { arguments: '{}', name: 'client_tool' }, + type: 'function', + }, + ], + }, + finish_reason: 'tool_calls', + index: 0, + }, + ], + }); + }); + + const response = await handleMessagesRequest( + makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), + { + max_tokens: 1024, + messages: [{ role: 'user', content: 'Mixed turn' }], + stream: true, + tools: [ + { + type: 'web_search_20260209', + name: 'web_search', + input_schema: {}, + }, + ], + }, + ); + const text = await response.text(); + + // Structured result block present, findings not repeated as prose. + expect(text).toContain('"type":"web_search_tool_result"'); + + // `handleMessagesRequest` answers in Anthropic SSE, so the text lives in + // `content_block_delta` frames as `text_delta` — not in `choices`. + const contentDeltas = ( + await readSseEvents( + new Response(text, { + headers: { 'Content-Type': 'text/event-stream' }, + }), + ) + ) + .flatMap((payload) => { + try { + const parsed = JSON.parse(payload) as { + delta?: { text?: string; type?: string }; + }; + + return parsed.delta?.type === 'text_delta' && parsed.delta.text + ? [parsed.delta.text] + : []; + } catch { + return []; + } + }) + .join(''); + + expect(contentDeltas).not.toContain('https://r.test'); + expect(contentDeltas).not.toContain('Cite the URL'); + }); + it('keeps folding findings for routes without a structured channel', async () => { await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' });