From a20f3d6946d9cc421e80af282af40a9509a5d59f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 11 Feb 2026 11:58:51 +0100 Subject: [PATCH] fix: add grok live parser coverage (#13547) (thanks @0xRaini) --- CHANGELOG.md | 1 + src/agents/tools/web-search.grok.live.test.ts | 113 ++++++++++++++++++ src/agents/tools/web-search.test.ts | 17 +++ 3 files changed, 131 insertions(+) create mode 100644 src/agents/tools/web-search.grok.live.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 008ea5db98..82093a9f61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Version alignment: bump manifests and package versions to `2026.2.10`; keep `appcast.xml` unchanged until the next macOS release cut. +- Tools/web_search: handle xAI Responses API message/output parsing and citation extraction for Grok provider. (#13547) Thanks @0xRaini. ## 2026.2.9 diff --git a/src/agents/tools/web-search.grok.live.test.ts b/src/agents/tools/web-search.grok.live.test.ts new file mode 100644 index 0000000000..4b301b8057 --- /dev/null +++ b/src/agents/tools/web-search.grok.live.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { isTruthyEnvValue } from "../../infra/env.js"; +import { __testing } from "./web-search.js"; + +const LIVE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE); +const XAI_KEY = process.env.XAI_API_KEY?.trim() ?? ""; +const GROK_MODEL = process.env.OPENCLAW_LIVE_GROK_MODEL?.trim() || "grok-4-1-fast"; +const XAI_RESPONSES_API = "https://api.x.ai/v1/responses"; + +type ParsedResponse = { + text?: string; + annotationCitations?: string[]; +}; + +const describeLive = LIVE && XAI_KEY ? describe : describe.skip; + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((item): item is string => typeof item === "string" && item.length > 0); +} + +function legacyExtractText(data: unknown): string | undefined { + if (!data || typeof data !== "object") { + return undefined; + } + const value = (data as { output?: Array<{ content?: Array<{ text?: unknown }> }> }).output?.[0] + ?.content?.[0]?.text; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function normalizeExtractResult(raw: unknown): { + text: string | undefined; + annotationCitations: string[]; +} { + if (typeof raw === "string") { + return { text: raw, annotationCitations: [] }; + } + if (!raw || typeof raw !== "object") { + return { text: undefined, annotationCitations: [] }; + } + const parsed = raw as ParsedResponse; + return { + text: typeof parsed.text === "string" ? parsed.text : undefined, + annotationCitations: asStringArray(parsed.annotationCitations), + }; +} + +async function callXaiResponses(params: { + body: Record; + timeoutMs: number; +}): Promise<{ status: number; ok: boolean; data?: Record; detail?: string }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), params.timeoutMs); + timeout.unref?.(); + try { + const res = await fetch(XAI_RESPONSES_API, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${XAI_KEY}`, + }, + body: JSON.stringify(params.body), + signal: controller.signal, + }); + if (!res.ok) { + return { status: res.status, ok: false, detail: await res.text() }; + } + return { status: res.status, ok: true, data: (await res.json()) as Record }; + } finally { + clearTimeout(timeout); + } +} + +describeLive("web_search grok live", () => { + it("extracts text from xAI Responses API payloads", async () => { + const request: Record = { + model: GROK_MODEL, + input: [ + { + role: "user", + content: + "Search the web for the latest OpenAI API docs URL. Reply in one sentence and include source links.", + }, + ], + tools: [{ type: "web_search" }], + include: ["inline_citations"], + }; + + let result = await callXaiResponses({ body: request, timeoutMs: 45_000 }); + if ( + !result.ok && + result.status === 400 && + typeof result.detail === "string" && + result.detail.includes("Argument not supported: include") + ) { + const retryRequest = { ...request }; + delete retryRequest.include; + result = await callXaiResponses({ body: retryRequest, timeoutMs: 45_000 }); + } + + expect(result.ok, result.detail ?? "xAI request failed").toBe(true); + const data = result.data as Record; + const parsed = normalizeExtractResult(__testing.extractGrokContent(data as never)); + const legacyText = legacyExtractText(data); + + expect(parsed.text && parsed.text.trim().length > 0).toBe(true); + if (!legacyText) { + expect(parsed.text && parsed.text.trim().length > 0).toBe(true); + } + }, 60_000); +}); diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 8b7e098618..03b1fbba56 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -145,6 +145,23 @@ describe("web_search grok config resolution", () => { }); describe("web_search grok response parsing", () => { + it("skips non-message output entries and extracts from message output", () => { + const result = extractGrokContent({ + output: [ + { + type: "reasoning", + content: [], + }, + { + type: "message", + content: [{ type: "output_text", text: "hello from message output" }], + }, + ], + }); + expect(result.text).toBe("hello from message output"); + expect(result.annotationCitations).toEqual([]); + }); + it("extracts content from Responses API message blocks", () => { const result = extractGrokContent({ output: [