feat(attachments): add claude delivery adapter
This commit is contained in:
parent
c2cb84607a
commit
bc702caff2
3 changed files with 211 additions and 0 deletions
|
|
@ -1 +1,2 @@
|
||||||
export * from './infrastructure/attachmentArtifactStore';
|
export * from './infrastructure/attachmentArtifactStore';
|
||||||
|
export * from './providers/claudeAttachmentAdapter';
|
||||||
|
|
|
||||||
|
|
@ -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]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue