diff --git a/src/features/agent-attachments/main/index.ts b/src/features/agent-attachments/main/index.ts index f2f414c1..5a5dd085 100644 --- a/src/features/agent-attachments/main/index.ts +++ b/src/features/agent-attachments/main/index.ts @@ -1,3 +1,4 @@ export * from './infrastructure/attachmentArtifactStore'; export * from './providers/claudeAttachmentAdapter'; export * from './providers/codexNativeAttachmentAdapter'; +export * from './providers/opencodeAttachmentAdapter'; diff --git a/src/features/agent-attachments/main/providers/opencodeAttachmentAdapter.test.ts b/src/features/agent-attachments/main/providers/opencodeAttachmentAdapter.test.ts new file mode 100644 index 00000000..2749d3bc --- /dev/null +++ b/src/features/agent-attachments/main/providers/opencodeAttachmentAdapter.test.ts @@ -0,0 +1,105 @@ +import { + buildOpenCodeAttachmentDeliveryParts, + redactOpenCodeFilePartsForDiagnostics, +} from './opencodeAttachmentAdapter'; + +import type { AttachmentPayload } from '@shared/types'; + +function attachment(overrides: Partial = {}): AttachmentPayload { + return { + id: 'att_1', + filename: 'red.png', + mimeType: 'image/png', + size: 3, + data: Buffer.from([1, 2, 3]).toString('base64'), + ...overrides, + }; +} + +describe('OpenCode attachment adapter', () => { + it('keeps text-only messages on the legacy text path', () => { + expect( + buildOpenCodeAttachmentDeliveryParts({ + text: 'hello', + model: 'openrouter/moonshotai/kimi-k2.6', + }) + ).toEqual({ + kind: 'legacy_text', + text: 'hello', + fileParts: [], + diagnostics: [], + }); + }); + + it('serializes verified OpenCode vision models as file parts', () => { + const result = buildOpenCodeAttachmentDeliveryParts({ + text: 'What color?', + model: 'openrouter/moonshotai/kimi-k2.6', + attachments: [attachment()], + }); + + expect(result.kind).toBe('text_with_file_parts'); + expect(result.fileParts).toEqual([ + { + type: 'file', + mime: 'image/png', + url: `data:image/png;base64,${attachment().data}`, + filename: 'red.png', + }, + ]); + expect(result.diagnostics.join('\n')).not.toContain(attachment().data); + }); + + it('allows verified GLM 4.5V image delivery', () => { + expect(() => + buildOpenCodeAttachmentDeliveryParts({ + text: 'What color?', + model: 'openrouter/z-ai/glm-4.5v', + attachments: [attachment()], + }) + ).not.toThrow(); + }); + + it('blocks known non-vision OpenCode models before runtime send', () => { + expect(() => + buildOpenCodeAttachmentDeliveryParts({ + text: 'What color?', + model: 'openrouter/z-ai/glm-5.1', + attachments: [attachment()], + }) + ).toThrow(/not verified for image attachments/); + }); + + it('blocks unknown OpenCode model image delivery by default', () => { + expect(() => + buildOpenCodeAttachmentDeliveryParts({ + text: 'What color?', + model: 'openrouter/example/new-model', + attachments: [attachment()], + }) + ).toThrow(/unknown image support/); + }); + + it('rejects non-image attachments before provider send', () => { + expect(() => + buildOpenCodeAttachmentDeliveryParts({ + text: 'Read PDF', + model: 'openrouter/moonshotai/kimi-k2.6', + attachments: [attachment({ filename: 'a.pdf', mimeType: 'application/pdf' })], + }) + ).toThrow(/OpenCode currently supports image attachments only/); + }); + + it('redacts data URLs from diagnostics', () => { + const redacted = redactOpenCodeFilePartsForDiagnostics([ + { + type: 'file', + mime: 'image/png', + url: `data:image/png;base64,${attachment().data}`, + filename: 'red.png', + }, + ]); + + expect(redacted[0]!.url).toBe('[redacted data URL: image/png]'); + }); +}); diff --git a/src/features/agent-attachments/main/providers/opencodeAttachmentAdapter.ts b/src/features/agent-attachments/main/providers/opencodeAttachmentAdapter.ts new file mode 100644 index 00000000..d544f09a --- /dev/null +++ b/src/features/agent-attachments/main/providers/opencodeAttachmentAdapter.ts @@ -0,0 +1,122 @@ +import { + AgentAttachmentError, + resolveAgentAttachmentCapability, +} from '@features/agent-attachments/core/domain'; + +import type { AttachmentPayload } from '@shared/types'; + +export type OpenCodeFilePartMimeType = 'image/png' | 'image/jpeg' | 'image/webp'; + +export interface OpenCodeFilePart { + type: 'file'; + mime: OpenCodeFilePartMimeType; + url: string; + filename: string; +} + +export interface OpenCodeAttachmentDeliveryParts { + kind: 'legacy_text' | 'text_with_file_parts'; + text: string; + fileParts: OpenCodeFilePart[]; + diagnostics: string[]; +} + +export interface BuildOpenCodeAttachmentDeliveryPartsInput { + text: string; + model: string; + attachments?: AttachmentPayload[]; +} + +function assertOpenCodeImageMimeType( + mimeType: string +): asserts mimeType is OpenCodeFilePartMimeType { + if (mimeType === 'image/png' || mimeType === 'image/jpeg' || mimeType === 'image/webp') { + return; + } + + throw new AgentAttachmentError( + 'attachment_type_unsupported', + `OpenCode currently supports image attachments only; unsupported MIME: ${mimeType}`, + { providerId: 'opencode', retryable: false } + ); +} + +function assertOpenCodeVisionCapability(model: string): void { + const capability = resolveAgentAttachmentCapability({ + providerId: 'opencode', + model, + }); + if (capability.supportsImages) { + return; + } + + const code = + capability.reason === 'known_non_vision_model' || capability.reason === 'unknown_model' + ? 'attachment_model_unsupported' + : 'attachment_type_unsupported'; + throw new AgentAttachmentError(code, capability.displayText, { + providerId: 'opencode', + model, + retryable: false, + safeDetails: { + reason: capability.reason, + }, + }); +} + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + const kib = bytes / 1024; + if (kib < 1024) return `${kib.toFixed(1)} KB`; + return `${(kib / 1024).toFixed(1)} MB`; +} + +export function buildOpenCodeAttachmentDeliveryParts( + input: BuildOpenCodeAttachmentDeliveryPartsInput +): OpenCodeAttachmentDeliveryParts { + const attachments = input.attachments ?? []; + if (attachments.length === 0) { + return { + kind: 'legacy_text', + text: input.text, + fileParts: [], + diagnostics: [], + }; + } + + assertOpenCodeVisionCapability(input.model); + + const fileParts: OpenCodeFilePart[] = []; + const diagnostics: string[] = []; + for (const attachment of attachments) { + assertOpenCodeImageMimeType(attachment.mimeType); + fileParts.push({ + type: 'file', + mime: attachment.mimeType, + url: `data:${attachment.mimeType};base64,${attachment.data}`, + filename: attachment.filename, + }); + diagnostics.push( + `prepared OpenCode image file part ${attachment.filename} (${attachment.mimeType}, ${formatBytes( + attachment.size + )}) for ${input.model}` + ); + } + + return { + kind: 'text_with_file_parts', + text: input.text, + fileParts, + diagnostics, + }; +} + +export function redactOpenCodeFilePartsForDiagnostics( + parts: OpenCodeFilePart[] +): OpenCodeFilePart[] { + return parts.map((part) => ({ + ...part, + url: `[redacted data URL: ${part.mime}]`, + })); +}