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 './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