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) {