From bc702caff2c60db2c288f99fc23af5c8ce7b7688 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 9 May 2026 01:11:28 +0300 Subject: [PATCH] feat(attachments): add claude delivery adapter --- src/features/agent-attachments/main/index.ts | 1 + .../providers/claudeAttachmentAdapter.test.ts | 83 ++++++++++++ .../main/providers/claudeAttachmentAdapter.ts | 127 ++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 src/features/agent-attachments/main/providers/claudeAttachmentAdapter.test.ts create mode 100644 src/features/agent-attachments/main/providers/claudeAttachmentAdapter.ts diff --git a/src/features/agent-attachments/main/index.ts b/src/features/agent-attachments/main/index.ts index 1aed976d..792040b1 100644 --- a/src/features/agent-attachments/main/index.ts +++ b/src/features/agent-attachments/main/index.ts @@ -1 +1,2 @@ export * from './infrastructure/attachmentArtifactStore'; +export * from './providers/claudeAttachmentAdapter'; diff --git a/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.test.ts b/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.test.ts new file mode 100644 index 00000000..3fa62034 --- /dev/null +++ b/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.test.ts @@ -0,0 +1,83 @@ +import { + buildClaudeAttachmentDeliveryParts, + redactClaudeBlocksForDiagnostics, +} from './claudeAttachmentAdapter'; + +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('Claude attachment adapter', () => { + it('keeps text-only messages on the legacy text path', () => { + expect(buildClaudeAttachmentDeliveryParts({ text: 'hello' })).toEqual({ + kind: 'legacy_text', + blocks: [{ type: 'text', text: 'hello' }], + }); + }); + + it('serializes png images as structured image blocks', () => { + const result = buildClaudeAttachmentDeliveryParts({ + text: 'What color?', + attachments: [attachment()], + }); + + expect(result.kind).toBe('structured_blocks'); + expect(result.blocks[0]).toEqual({ type: 'text', text: 'What color?' }); + expect(result.blocks[1]).toMatchObject({ + type: 'image', + source: { type: 'base64', media_type: 'image/png' }, + }); + }); + + it('serializes UTF-8 text files as text document blocks', () => { + const result = buildClaudeAttachmentDeliveryParts({ + text: 'Read this', + attachments: [ + attachment({ + filename: 'note.txt', + mimeType: 'text/plain', + data: Buffer.from('hello', 'utf8').toString('base64'), + }), + ], + }); + + expect(result.blocks[1]).toEqual({ + type: 'document', + source: { type: 'text', media_type: 'text/plain', data: 'hello' }, + title: 'note.txt', + }); + }); + + it('rejects unsupported image mime types before provider send', () => { + expect(() => + buildClaudeAttachmentDeliveryParts({ + text: 'see gif', + attachments: [attachment({ mimeType: 'image/gif' })], + }) + ).toThrow(/Claude image MIME unsupported/); + }); + + it('redacts image and document bytes in diagnostics', () => { + const result = buildClaudeAttachmentDeliveryParts({ + text: 'What color?', + attachments: [ + attachment(), + attachment({ id: 'att_2', filename: 'a.pdf', mimeType: 'application/pdf' }), + ], + }); + + const redacted = redactClaudeBlocksForDiagnostics(result.blocks); + expect(JSON.stringify(redacted)).not.toContain(attachment().data); + expect(JSON.stringify(redacted)).toContain('[redacted image bytes: image/png]'); + expect(JSON.stringify(redacted)).toContain('[redacted document bytes: application/pdf]'); + }); +}); diff --git a/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.ts b/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.ts new file mode 100644 index 00000000..aee60e2d --- /dev/null +++ b/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.ts @@ -0,0 +1,127 @@ +import { AgentAttachmentError } from '@features/agent-attachments/core/domain'; + +import type { AttachmentPayload } from '@shared/types'; + +export type ClaudeInputBlock = + | { type: 'text'; text: string } + | { + type: 'image'; + source: { type: 'base64'; media_type: string; data: string }; + } + | { + type: 'document'; + source: + | { type: 'base64'; media_type: string; data: string } + | { type: 'text'; media_type: 'text/plain'; data: string }; + title?: string; + }; + +export interface ClaudeAttachmentDeliveryParts { + kind: 'legacy_text' | 'structured_blocks'; + blocks: ClaudeInputBlock[]; +} + +function decodeBase64Text(data: string): { ok: true; text: string } | { ok: false } { + const decoded = Buffer.from(data, 'base64').toString('utf-8'); + if (decoded.includes('\uFFFD')) return { ok: false }; + return { ok: true, text: decoded }; +} + +export function buildClaudeAttachmentDeliveryParts(input: { + text: string; + attachments?: AttachmentPayload[]; +}): ClaudeAttachmentDeliveryParts { + const contentBlocks: ClaudeInputBlock[] = [{ type: 'text', text: input.text }]; + const attachments = input.attachments ?? []; + + if (attachments.length === 0) { + return { kind: 'legacy_text', blocks: contentBlocks }; + } + + for (const attachment of attachments) { + if (attachment.mimeType === 'application/pdf') { + contentBlocks.push({ + type: 'document', + source: { + type: 'base64', + media_type: 'application/pdf', + data: attachment.data, + }, + title: attachment.filename, + }); + continue; + } + + if (attachment.mimeType === 'text/plain') { + const decoded = decodeBase64Text(attachment.data); + contentBlocks.push( + decoded.ok + ? { + type: 'document', + source: { + type: 'text', + media_type: 'text/plain', + data: decoded.text, + }, + title: attachment.filename, + } + : { + type: 'document', + source: { + type: 'base64', + media_type: 'text/plain', + data: attachment.data, + }, + title: attachment.filename, + } + ); + continue; + } + + if (attachment.mimeType === 'image/png' || attachment.mimeType === 'image/jpeg') { + contentBlocks.push({ + type: 'image', + source: { + // Claude expects image bytes inside the structured image block as base64. + // This is provider-native payload data, not text appended to the user prompt. + type: 'base64', + media_type: attachment.mimeType, + data: attachment.data, + }, + }); + continue; + } + + throw new AgentAttachmentError( + 'attachment_type_unsupported', + `Claude image MIME unsupported: ${attachment.mimeType}`, + { attachmentId: attachment.id, retryable: false } + ); + } + + return { kind: 'structured_blocks', blocks: contentBlocks }; +} + +export function redactClaudeBlocksForDiagnostics(blocks: ClaudeInputBlock[]): ClaudeInputBlock[] { + return blocks.map((block) => { + if (block.type === 'image') { + return { + ...block, + source: { + ...block.source, + data: `[redacted image bytes: ${block.source.media_type}]`, + }, + }; + } + if (block.type === 'document' && block.source.type === 'base64') { + return { + ...block, + source: { + ...block.source, + data: `[redacted document bytes: ${block.source.media_type}]`, + }, + }; + } + return block; + }); +}