diff --git a/extensions/xai/code-execution.test.ts b/extensions/xai/code-execution.test.ts index dc91cf27308c..fd2004736abc 100644 --- a/extensions/xai/code-execution.test.ts +++ b/extensions/xai/code-execution.test.ts @@ -165,6 +165,60 @@ describe("xai code_execution tool", () => { ).toBe(true); }); + it("returns every response answer and citation from the code execution HTTP boundary", async () => { + const mockFetch = installCodeExecutionFetch({ + output: [ + { type: "code_interpreter_call" }, + { + type: "message", + content: [ + { + type: "output_text", + text: "Mean: ", + annotations: [{ type: "url_citation", url: "https://example.com/input.csv" }], + }, + { + type: "output_text", + text: "42", + annotations: [ + { type: "url_citation", url: "https://example.com/result.csv" }, + { type: "url_citation", url: "https://example.com/input.csv" }, + ], + }, + ], + }, + { + type: "message", + content: [{ type: "output_text", text: ". Verified." }], + }, + ], + }); + const tool = createCodeExecutionTool({ + config: { + plugins: { + entries: { + xai: { + config: { + webSearch: { apiKey: "xai-plugin-key" }, // pragma: allowlist secret + }, + }, + }, + }, + }, + }); + + const result = await tool?.execute?.("code-execution:multi-block", { + task: "Calculate and verify the mean.", + }); + + expect(firstFetchUrl(mockFetch)).toContain("api.x.ai/v1/responses"); + expect(result?.details).toMatchObject({ + content: "Mean: 42. Verified.", + citations: ["https://example.com/input.csv", "https://example.com/result.csv"], + usedCodeExecution: true, + }); + }); + it("reuses the xAI plugin web search key for code_execution requests", async () => { const mockFetch = installCodeExecutionFetch(); const tool = createCodeExecutionTool({ diff --git a/extensions/xai/src/responses-tool-shared.test.ts b/extensions/xai/src/responses-tool-shared.test.ts index 882ad8636de2..9f40e3191126 100644 --- a/extensions/xai/src/responses-tool-shared.test.ts +++ b/extensions/xai/src/responses-tool-shared.test.ts @@ -67,6 +67,49 @@ describe("xai responses tool helpers", () => { }); }); + it("collects every response text block and deduplicates citations across output items", () => { + expect( + requireXaiResponseTextAndCitations( + { + output: [ + { type: "web_search_call" }, + { + type: "message", + content: [ + { + type: "output_text", + text: "First ", + annotations: [{ type: "url_citation", url: "https://example.com/a" }], + }, + { + type: "output_text", + text: "second", + annotations: [ + { type: "url_citation", url: "https://example.com/b" }, + { type: "url_citation", url: "https://example.com/a" }, + ], + }, + ], + }, + { + type: "output_text", + text: " and ", + annotations: [{ type: "url_citation", url: "https://example.com/c" }], + }, + { + type: "message", + content: [{ type: "output_text", text: "third" }], + }, + ], + }, + "xAI tool failed", + ), + ).toEqual({ + content: "First second and third", + citations: ["https://example.com/a", "https://example.com/b", "https://example.com/c"], + }); + }); + it("ignores malformed output, content, and annotation entries", () => { expect( extractXaiWebSearchContent({ diff --git a/extensions/xai/src/responses-tool-shared.ts b/extensions/xai/src/responses-tool-shared.ts index 2c565411a407..390b2c31bd0a 100644 --- a/extensions/xai/src/responses-tool-shared.ts +++ b/extensions/xai/src/responses-tool-shared.ts @@ -1,14 +1,11 @@ // Xai plugin module implements responses tool shared behavior. import { + isRecord, normalizeOptionalString as trimString, uniqueStrings, } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { XaiWebSearchResponse } from "./web-search-response.types.js"; -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object"; -} - function extractUrlCitations(annotations: unknown): string[] { if (!Array.isArray(annotations)) { return []; @@ -51,32 +48,34 @@ export function extractXaiWebSearchContent(data: XaiWebSearchResponse): { text: string | undefined; annotationCitations: string[]; } { + const textParts: string[] = []; + const annotationCitations: string[] = []; for (const output of data.output ?? []) { if (!isRecord(output)) { continue; } - if (output.type === "message") { - const content = Array.isArray(output.content) ? output.content : []; - for (const block of content) { - if (!isRecord(block)) { - continue; - } - if (block.type === "output_text" && typeof block.text === "string" && block.text) { - const urls = extractUrlCitations(block.annotations); - return { text: block.text, annotationCitations: uniqueStrings(urls) }; - } + const blocks = + output.type === "message" && Array.isArray(output.content) + ? output.content + : output.type === "output_text" + ? [output] + : []; + for (const block of blocks) { + if (!isRecord(block) || block.type !== "output_text" || typeof block.text !== "string") { + continue; + } + if (block.text) { + textParts.push(block.text); + annotationCitations.push(...extractUrlCitations(block.annotations)); } - } - - if (output.type === "output_text" && typeof output.text === "string" && output.text) { - const urls = extractUrlCitations(output.annotations); - return { text: output.text, annotationCitations: uniqueStrings(urls) }; } } + // Match the Responses SDK: adjacent output text blocks have no separator. + const text = textParts.join(""); return { - text: typeof data.output_text === "string" ? data.output_text : undefined, - annotationCitations: [], + text: text || (typeof data.output_text === "string" ? data.output_text : undefined), + annotationCitations: uniqueStrings(annotationCitations), }; }