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.
This commit is contained in:
@@ -315,6 +315,23 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("OpenCode adapter context-window normalization", () => {
|
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", () => {
|
test("preserves provider catalog context limit in model metadata", () => {
|
||||||
const definition = __openCodeInternals.buildOpenCodeModelDefinition(
|
const definition = __openCodeInternals.buildOpenCodeModelDefinition(
|
||||||
{ id: "openai", name: "OpenAI" },
|
{ id: "openai", name: "OpenAI" },
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
createOpencodeClient,
|
createOpencodeClient,
|
||||||
type AssistantMessage as OpenCodeAssistantMessage,
|
type AssistantMessage as OpenCodeAssistantMessage,
|
||||||
type Event as OpenCodeEvent,
|
type Event as OpenCodeEvent,
|
||||||
|
type FilePartInput as OpenCodeFilePartInput,
|
||||||
type OpencodeClient,
|
type OpencodeClient,
|
||||||
type Part as OpenCodePart,
|
type Part as OpenCodePart,
|
||||||
|
type TextPartInput as OpenCodeTextPartInput,
|
||||||
} from "@opencode-ai/sdk/v2/client";
|
} from "@opencode-ai/sdk/v2/client";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
@@ -494,7 +496,66 @@ function hasNormalizedOpenCodeUsage(usage: AgentUsage): boolean {
|
|||||||
].some((value) => typeof value === "number" && Number.isFinite(value));
|
].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<OpenCodeTextPartInput | OpenCodeFilePartInput> {
|
||||||
|
if (typeof prompt === "string") {
|
||||||
|
return [{ type: "text", text: prompt }];
|
||||||
|
}
|
||||||
|
let attachmentOrdinal = 0;
|
||||||
|
const output: Array<OpenCodeTextPartInput | OpenCodeFilePartInput> = [];
|
||||||
|
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 = {
|
export const __openCodeInternals = {
|
||||||
|
buildOpenCodePromptParts,
|
||||||
buildOpenCodeModelContextWindowLookup,
|
buildOpenCodeModelContextWindowLookup,
|
||||||
buildOpenCodeModelDefinition,
|
buildOpenCodeModelDefinition,
|
||||||
buildOpenCodeModelLookupKey,
|
buildOpenCodeModelLookupKey,
|
||||||
@@ -1436,7 +1497,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
this.accumulatedUsage =
|
this.accumulatedUsage =
|
||||||
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
|
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
|
||||||
|
|
||||||
const parts = this.buildPromptParts(prompt);
|
const parts = buildOpenCodePromptParts(prompt);
|
||||||
const model = this.parseModel(this.config.model);
|
const model = this.parseModel(this.config.model);
|
||||||
const thinkingOptionId = this.config.thinkingOptionId;
|
const thinkingOptionId = this.config.thinkingOptionId;
|
||||||
const effectiveVariant = thinkingOptionId ?? undefined;
|
const effectiveVariant = thinkingOptionId ?? undefined;
|
||||||
@@ -1862,15 +1923,6 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
this.activeForegroundTurnId = null;
|
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 {
|
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed.startsWith("/") || trimmed.length <= 1) {
|
if (!trimmed.startsWith("/") || trimmed.length <= 1) {
|
||||||
|
|||||||
Reference in New Issue
Block a user