diff --git a/src/bridge-pool.ts b/src/bridge-pool.ts index 9259084..a050a31 100644 --- a/src/bridge-pool.ts +++ b/src/bridge-pool.ts @@ -3,12 +3,13 @@ * (Cursor bridge-pool pattern). */ import type { ClaudeQueryHandle } from "./query.js"; +import type { McpToolResultContent } from "./prompt.js"; export type ParkedToolCall = { id: string; name: string; arguments: string; - resolve: (result: string) => void; + resolve: (result: McpToolResultContent[]) => void; reject: (error: Error) => void; }; diff --git a/src/prompt.ts b/src/prompt.ts index 6eab0ec..5e984e1 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -23,6 +23,10 @@ export type SdkUserPrompt = { parent_tool_use_id: null; }; +export type McpToolResultContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string }; + function parseDataUrl(url: string): { mediaType: string; data: string; @@ -390,6 +394,31 @@ export function openaiContentToAnthropicBlocks( return blocks; } +/** + * Convert OpenAI-compatible tool result content into MCP result blocks. + * OpenCode's read tool returns images as file attachments on the tool result; + * those must stay attached when the parked Claude SDK tool call resumes. + */ +export function openaiToolResultToMcpContent( + content: unknown, +): McpToolResultContent[] { + const result: McpToolResultContent[] = []; + for (const block of openaiContentToAnthropicBlocks(content)) { + if (block.type === "text") { + result.push(block); + continue; + } + if (block.type === "image" && block.source.type === "base64") { + result.push({ + type: "image", + data: block.source.data, + mimeType: block.source.media_type, + }); + } + } + return result; +} + /** * Latest user turn as a Claude Agent SDK prompt (string when text-only). */ diff --git a/src/proxy.ts b/src/proxy.ts index 8aae174..ffeff72 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -56,10 +56,12 @@ import { buildConversationTranscript, extractTextContent, latestUserPrompt, + openaiToolResultToMcpContent, priorMessagesOf, promptAsStream, withConversationContext, type SdkUserPrompt, + type McpToolResultContent, } from "./prompt.js"; import { detectMetaRequestKind, @@ -325,11 +327,14 @@ async function handleRequest(req: Request): Promise { function collectToolResults( messages: OpenAIMessage[], -): Map { - const results = new Map(); +): Map { + const results = new Map(); for (const msg of messages) { if (msg.role !== "tool" || !msg.tool_call_id) continue; - results.set(msg.tool_call_id, extractTextContent(msg.content)); + results.set( + msg.tool_call_id, + openaiToolResultToMcpContent(msg.content), + ); } return results; } @@ -869,16 +874,18 @@ async function buildOpenCodeMcpServer( resolve: () => {}, reject: () => {}, }; - const resultPromise = new Promise((resolve, reject) => { - pending.resolve = resolve; - pending.reject = reject; - }); + const resultPromise = new Promise( + (resolve, reject) => { + pending.resolve = resolve; + pending.reject = reject; + }, + ); // Register before notifying so the stream consumer sees the tool. pendingTools.set(id, pending); onPark(); const result = await resultPromise; return { - content: [{ type: "text", text: result }], + content: result, }; }, { alwaysLoad: true }, diff --git a/test/smoke.ts b/test/smoke.ts index 048b4cb..4e6aecb 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -354,6 +354,7 @@ async function main() { // Multimodal prompt conversion const { openaiContentToAnthropicBlocks, + openaiToolResultToMcpContent, latestUserPrompt: buildPrompt, contentHasAttachments, } = await import("../src/prompt.ts"); @@ -448,6 +449,24 @@ async function main() { ]); assert.equal(typeof multi === "object" && multi !== null && multi.type === "user", true); + // OpenCode read-tool attachments must survive the parked Claude SDK tool + // bridge. The MCP result shape uses { data, mimeType }, not Anthropic's + // message-level { source } shape. + const mcpToolResult = openaiToolResultToMcpContent([ + { type: "text", text: "Image read successfully" }, + { + type: "file", + file: { + filename: "pixel.png", + file_data: `data:image/png;base64,${png}`, + }, + }, + ]); + assert.deepEqual(mcpToolResult, [ + { type: "text", text: "Image read successfully" }, + { type: "image", data: png, mimeType: "image/png" }, + ]); + // Conversation key stability const key = conversationKeyFromMessages([ { role: "user", content: "hello world" },