feat(attachments): add claude delivery adapter

This commit is contained in:
777genius 2026-05-09 01:11:28 +03:00
parent c2cb84607a
commit bc702caff2
3 changed files with 211 additions and 0 deletions

View file

@ -1 +1,2 @@
export * from './infrastructure/attachmentArtifactStore';
export * from './providers/claudeAttachmentAdapter';

View file

@ -0,0 +1,83 @@
import {
buildClaudeAttachmentDeliveryParts,
redactClaudeBlocksForDiagnostics,
} from './claudeAttachmentAdapter';
import type { AttachmentPayload } from '@shared/types';
function attachment(overrides: Partial<AttachmentPayload> = {}): 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]');
});
});

View file

@ -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;
});
}