diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 47ef32499b..8b7e098618 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -145,21 +145,66 @@ describe("web_search grok config resolution", () => { }); describe("web_search grok response parsing", () => { - it("extracts content from Responses API output blocks", () => { - expect( - extractGrokContent({ - output: [ - { - content: [{ text: "hello from output" }], - }, - ], - }), - ).toBe("hello from output"); + it("extracts content from Responses API message blocks", () => { + const result = extractGrokContent({ + output: [ + { + type: "message", + content: [{ type: "output_text", text: "hello from output" }], + }, + ], + }); + expect(result.text).toBe("hello from output"); + expect(result.annotationCitations).toEqual([]); + }); + + it("extracts url_citation annotations from content blocks", () => { + const result = extractGrokContent({ + output: [ + { + type: "message", + content: [ + { + type: "output_text", + text: "hello with citations", + annotations: [ + { + type: "url_citation", + url: "https://example.com/a", + start_index: 0, + end_index: 5, + }, + { + type: "url_citation", + url: "https://example.com/b", + start_index: 6, + end_index: 10, + }, + { + type: "url_citation", + url: "https://example.com/a", + start_index: 11, + end_index: 15, + }, // duplicate + ], + }, + ], + }, + ], + }); + expect(result.text).toBe("hello with citations"); + expect(result.annotationCitations).toEqual(["https://example.com/a", "https://example.com/b"]); }); it("falls back to deprecated output_text", () => { - expect(extractGrokContent({ output_text: "hello from output_text" })).toBe( - "hello from output_text", - ); + const result = extractGrokContent({ output_text: "hello from output_text" }); + expect(result.text).toBe("hello from output_text"); + expect(result.annotationCitations).toEqual([]); + }); + + it("returns undefined text when no content found", () => { + const result = extractGrokContent({}); + expect(result.text).toBeUndefined(); + expect(result.annotationCitations).toEqual([]); }); }); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 428c37f8a8..bc6904e758 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -109,6 +109,12 @@ type GrokSearchResponse = { content?: Array<{ type?: string; text?: string; + annotations?: Array<{ + type?: string; + url?: string; + start_index?: number; + end_index?: number; + }>; }>; }>; output_text?: string; // deprecated field - kept for backwards compatibility @@ -131,13 +137,28 @@ type PerplexitySearchResponse = { type PerplexityBaseUrlHint = "direct" | "openrouter"; -function extractGrokContent(data: GrokSearchResponse): string | undefined { - // xAI Responses API format: output[0].content[0].text - const fromResponses = data.output?.[0]?.content?.[0]?.text; - if (typeof fromResponses === "string" && fromResponses) { - return fromResponses; +function extractGrokContent(data: GrokSearchResponse): { + text: string | undefined; + annotationCitations: string[]; +} { + // xAI Responses API format: find the message output with text content + for (const output of data.output ?? []) { + if (output.type !== "message") { + continue; + } + for (const block of output.content ?? []) { + if (block.type === "output_text" && typeof block.text === "string" && block.text) { + // Extract url_citation annotations from this content block + const urls = (block.annotations ?? []) + .filter((a) => a.type === "url_citation" && typeof a.url === "string") + .map((a) => a.url as string); + return { text: block.text, annotationCitations: [...new Set(urls)] }; + } + } } - return typeof data.output_text === "string" ? data.output_text : undefined; + // Fallback: deprecated output_text field + const text = typeof data.output_text === "string" ? data.output_text : undefined; + return { text, annotationCitations: [] }; } function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig { @@ -494,8 +515,10 @@ async function runGrokSearch(params: { } const data = (await res.json()) as GrokSearchResponse; - const content = extractGrokContent(data) ?? "No response"; - const citations = data.citations ?? []; + const { text: extractedText, annotationCitations } = extractGrokContent(data); + const content = extractedText ?? "No response"; + // Prefer top-level citations; fall back to annotation-derived ones + const citations = (data.citations ?? []).length > 0 ? data.citations! : annotationCitations; const inlineCitations = data.inline_citations; return { content, citations, inlineCitations };