diff --git a/src/claude/types.ts b/src/claude/types.ts index 356ea09..9c6e7ca 100644 --- a/src/claude/types.ts +++ b/src/claude/types.ts @@ -47,7 +47,8 @@ interface ClaudeImageBlock { export interface ClaudeToolResultBlock { type: "tool_result" tool_use_id: string - content: string + // Claude Code sends an array when a tool returns more than text, such as an image (#150). + content: string | Array is_error?: boolean } diff --git a/src/claude/web-search.ts b/src/claude/web-search.ts index 1f9e4d0..a67f374 100644 --- a/src/claude/web-search.ts +++ b/src/claude/web-search.ts @@ -7,6 +7,7 @@ import type { ClaudeResponse, ClaudeTextBlock, ClaudeTool, + ClaudeToolResultBlock, ClaudeWebSearchResultBlock, } from "~/claude/types" import type { ClaudeToolNameMapper } from "~/claude/tool-names" @@ -127,6 +128,23 @@ export const prepareClaudeWebSearchDecisionPayload = ( } } +// A tool result holds a string or an array of blocks, such as text and an image (#150). Each text +// block keeps its text and any other block is named by its type, so an image reads as "[image]" +// rather than "[object Object]" (#161). A client can omit the content. +const toolResultText = (content: ClaudeToolResultBlock["content"] | undefined): string => { + if (typeof content === "string") { + return content + } + + if (!Array.isArray(content)) { + return "" + } + + return content + .map((block) => (block.type === "text" ? block.text : `[${block.type}]`)) + .join("\n\n") +} + const textFromMessageContent = (content: ClaudeMessage["content"]): string => { if (typeof content === "string") { return content @@ -139,7 +157,7 @@ const textFromMessageContent = (content: ClaudeMessage["content"]): string => { } if (block.type === "tool_result") { - return [block.content] + return [toolResultText(block.content)] } return [] diff --git a/tests/unit/web-search.test.ts b/tests/unit/web-search.test.ts index 53cf1cb..5251193 100644 --- a/tests/unit/web-search.test.ts +++ b/tests/unit/web-search.test.ts @@ -731,3 +731,54 @@ test("passes prior conversation history through unchanged", () => { assert.deepEqual(result.messages.slice(0, -1), history) }) + +// #161: a tool result can hold an array of blocks, such as text and an image. Its text reaches the +// search request as text, not as "[object Object]". +test("a tool result with array content reaches the search request as text", async () => { + const { withRecordedTransport } = await import("../../src/lib/request-trace") + const baseUrl = "https://search-tool-result.invalid" + const backend: CopilotModel = { supportedEndpoints: ["/responses"], reasoningEfforts: [] } + const config = { + ...createConfig(baseUrl), + webSearchBackend: "future-search", + modelCatalog: { baseUrl, models: new Map([["future-search", backend]]) }, + } + const toolResultPayload: ClaudeMessagesPayload = { + ...payload, + messages: [ + { role: "user", content: "List the fixtures." }, + { role: "assistant", content: [{ type: "tool_use", id: "toolu_1", name: "Glob", input: { pattern: "*.ts" } }] }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_1", + content: [ + { type: "text", text: "Found 2 files" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgo=" } }, + ], + }, + { type: "text", text: "search the web for copilot docs" }, + ], + }, + ], + } + const inputs: Array = [] + + await withRecordedTransport({ + fetch: async (request) => { + const { input } = JSON.parse(request.body!) as { input: unknown } + assert.equal(typeof input, "string") + inputs.push(input as string) + return Response.json({ id: "resp_search", model: "future-search", status: "completed", output: [] }) + }, + refresh: async () => {}, + }, async () => { + await createClaudeWebSearchExecution(config, toolResultPayload, "copilot docs") + }) + + assert.equal(inputs.length, 1) + assert.ok(inputs[0].includes("user: Found 2 files\n\n[image]\n\nsearch the web for copilot docs")) + assert.equal(inputs[0].includes("[object Object]"), false) +})