From bf91ea2cc987c6bcaf57465c744383bfeed2e48e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 10:23:24 +0700 Subject: [PATCH] feat(server): support image attachments in OpenCode agent prompts Adds logic to process file parts and convert image attachments into data URLs for the OpenCode provider, replacing the previous behavior that stripped non-text parts. --- .../agent/providers/opencode-agent.test.ts | 17 +++++ .../server/agent/providers/opencode-agent.ts | 72 ++++++++++++++++--- 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 72f3fbdb1..d31648607 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -315,6 +315,23 @@ const hasOpenCode = isBinaryInstalled("opencode"); }); describe("OpenCode adapter context-window normalization", () => { + test("builds OpenCode file parts for image prompt blocks", () => { + expect( + __openCodeInternals.buildOpenCodePromptParts([ + { type: "text", text: "Describe this image." }, + { type: "image", mimeType: "image/png", data: "YWJjMTIz" }, + ]), + ).toEqual([ + { type: "text", text: "Describe this image." }, + { + type: "file", + mime: "image/png", + filename: "attachment-1.png", + url: "data:image/png;base64,YWJjMTIz", + }, + ]); + }); + test("preserves provider catalog context limit in model metadata", () => { const definition = __openCodeInternals.buildOpenCodeModelDefinition( { id: "openai", name: "OpenAI" }, diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 126003918..4cae22707 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -4,8 +4,10 @@ import { createOpencodeClient, type AssistantMessage as OpenCodeAssistantMessage, type Event as OpenCodeEvent, + type FilePartInput as OpenCodeFilePartInput, type OpencodeClient, type Part as OpenCodePart, + type TextPartInput as OpenCodeTextPartInput, } from "@opencode-ai/sdk/v2/client"; import net from "node:net"; import type { Logger } from "pino"; @@ -494,7 +496,66 @@ function hasNormalizedOpenCodeUsage(usage: AgentUsage): boolean { ].some((value) => typeof value === "number" && Number.isFinite(value)); } +function getOpenCodeAttachmentExtension(mimeType: string): string { + switch (mimeType) { + case "image/png": + return "png"; + case "image/jpeg": + return "jpg"; + case "image/webp": + return "webp"; + case "image/gif": + return "gif"; + case "image/svg+xml": + return "svg"; + default: + return "bin"; + } +} + +function toOpenCodeDataUrl(mimeType: string, data: string): { mimeType: string; url: string } { + const match = data.match(/^data:([^;,]+);base64,(.+)$/); + if (match) { + return { + mimeType: match[1] ?? mimeType, + url: data, + }; + } + return { + mimeType, + url: `data:${mimeType};base64,${data}`, + }; +} + +function buildOpenCodePromptParts( + prompt: AgentPromptInput, +): Array { + if (typeof prompt === "string") { + return [{ type: "text", text: prompt }]; + } + let attachmentOrdinal = 0; + const output: Array = []; + for (const part of prompt) { + if (part.type === "text") { + output.push({ type: "text", text: part.text }); + continue; + } + attachmentOrdinal += 1; + const normalized = toOpenCodeDataUrl(part.mimeType, part.data); + output.push({ + type: "file", + mime: normalized.mimeType, + filename: `attachment-${attachmentOrdinal}.${getOpenCodeAttachmentExtension( + normalized.mimeType, + )}`, + url: normalized.url, + }); + } + return output; +} + export const __openCodeInternals = { + buildOpenCodePromptParts, buildOpenCodeModelContextWindowLookup, buildOpenCodeModelDefinition, buildOpenCodeModelLookupKey, @@ -1436,7 +1497,7 @@ class OpenCodeAgentSession implements AgentSession { this.accumulatedUsage = contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {}; - const parts = this.buildPromptParts(prompt); + const parts = buildOpenCodePromptParts(prompt); const model = this.parseModel(this.config.model); const thinkingOptionId = this.config.thinkingOptionId; const effectiveVariant = thinkingOptionId ?? undefined; @@ -1862,15 +1923,6 @@ class OpenCodeAgentSession implements AgentSession { this.activeForegroundTurnId = null; } - private buildPromptParts(prompt: AgentPromptInput): Array<{ type: "text"; text: string }> { - if (typeof prompt === "string") { - return [{ type: "text", text: prompt }]; - } - return prompt - .filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => ({ type: "text", text: p.text })); - } - private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null { const trimmed = text.trim(); if (!trimmed.startsWith("/") || trimmed.length <= 1) {