feat(attachments): add codex native adapter
This commit is contained in:
parent
bc702caff2
commit
935c522846
4 changed files with 235 additions and 0 deletions
|
|
@ -1,2 +1,3 @@
|
||||||
export * from './infrastructure/attachmentArtifactStore';
|
export * from './infrastructure/attachmentArtifactStore';
|
||||||
export * from './providers/claudeAttachmentAdapter';
|
export * from './providers/claudeAttachmentAdapter';
|
||||||
|
export * from './providers/codexNativeAttachmentAdapter';
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ import * as path from 'path';
|
||||||
export type AgentAttachmentArtifactFileName =
|
export type AgentAttachmentArtifactFileName =
|
||||||
| 'original.png'
|
| 'original.png'
|
||||||
| 'original.jpg'
|
| 'original.jpg'
|
||||||
|
| 'original.webp'
|
||||||
| 'optimized.png'
|
| 'optimized.png'
|
||||||
| 'optimized.jpg'
|
| 'optimized.jpg'
|
||||||
|
| 'optimized.webp'
|
||||||
| 'thumb.jpg'
|
| 'thumb.jpg'
|
||||||
| 'meta.json';
|
| 'meta.json';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildCodexNativeAttachmentDeliveryParts,
|
||||||
|
redactCodexNativeAttachmentPartsForDiagnostics,
|
||||||
|
} from './codexNativeAttachmentAdapter';
|
||||||
|
|
||||||
|
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('Codex native attachment adapter', () => {
|
||||||
|
it('keeps text-only messages on the legacy text path', async () => {
|
||||||
|
await expect(
|
||||||
|
buildCodexNativeAttachmentDeliveryParts({
|
||||||
|
teamName: 'team_1',
|
||||||
|
messageId: 'msg_1',
|
||||||
|
text: 'hello',
|
||||||
|
})
|
||||||
|
).resolves.toEqual({
|
||||||
|
kind: 'legacy_text',
|
||||||
|
promptText: 'hello',
|
||||||
|
imageParts: [],
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('materializes image attachments as managed files for --image args', async () => {
|
||||||
|
const appDataPath = await fs.mkdtemp(path.join(os.tmpdir(), 'codex-attachments-'));
|
||||||
|
const result = await buildCodexNativeAttachmentDeliveryParts({
|
||||||
|
appDataPath,
|
||||||
|
teamName: 'team_1',
|
||||||
|
messageId: 'msg_1',
|
||||||
|
text: 'What color?',
|
||||||
|
attachments: [attachment()],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.kind).toBe('text_with_images');
|
||||||
|
expect(result.imageParts).toHaveLength(1);
|
||||||
|
expect(result.imageParts[0]).toMatchObject({
|
||||||
|
kind: 'codex-image-arg',
|
||||||
|
attachmentId: 'att_1',
|
||||||
|
filename: 'red.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
sizeBytes: 3,
|
||||||
|
});
|
||||||
|
await expect(fs.readFile(result.imageParts[0]!.path)).resolves.toEqual(Buffer.from([1, 2, 3]));
|
||||||
|
expect(result.diagnostics.join('\n')).not.toContain(attachment().data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves image order for multiple attachments', async () => {
|
||||||
|
const appDataPath = await fs.mkdtemp(path.join(os.tmpdir(), 'codex-attachments-'));
|
||||||
|
const result = await buildCodexNativeAttachmentDeliveryParts({
|
||||||
|
appDataPath,
|
||||||
|
teamName: 'team_1',
|
||||||
|
messageId: 'msg_1',
|
||||||
|
text: 'Compare',
|
||||||
|
attachments: [
|
||||||
|
attachment({ id: 'att_1', filename: 'a.jpg', mimeType: 'image/jpeg' }),
|
||||||
|
attachment({ id: 'att_2', filename: 'b.webp', mimeType: 'image/webp' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.imageParts.map((part) => part.filename)).toEqual(['a.jpg', 'b.webp']);
|
||||||
|
expect(result.imageParts.map((part) => part.mimeType)).toEqual(['image/jpeg', 'image/webp']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-image attachments before provider send', async () => {
|
||||||
|
await expect(
|
||||||
|
buildCodexNativeAttachmentDeliveryParts({
|
||||||
|
teamName: 'team_1',
|
||||||
|
messageId: 'msg_1',
|
||||||
|
text: 'Read PDF',
|
||||||
|
attachments: [attachment({ filename: 'a.pdf', mimeType: 'application/pdf' })],
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/Codex native supports image attachments only/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts managed artifact paths from diagnostics', () => {
|
||||||
|
const redacted = redactCodexNativeAttachmentPartsForDiagnostics([
|
||||||
|
{
|
||||||
|
kind: 'codex-image-arg',
|
||||||
|
attachmentId: 'att_1',
|
||||||
|
filename: 'red.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
path: '/Users/me/.claude/attachments/team/msg/att/optimized.png',
|
||||||
|
sizeBytes: 3,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(redacted[0]!.path).toBe('[managed attachment artifact: red.png]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { AgentAttachmentError } from '@features/agent-attachments/core/domain';
|
||||||
|
import {
|
||||||
|
resolveAgentAttachmentArtifactPath,
|
||||||
|
writeFileAtomic,
|
||||||
|
type AgentAttachmentArtifactFileName,
|
||||||
|
} from '@features/agent-attachments/main/infrastructure/attachmentArtifactStore';
|
||||||
|
|
||||||
|
import type { AttachmentPayload } from '@shared/types';
|
||||||
|
|
||||||
|
export type CodexNativeImageMimeType = 'image/png' | 'image/jpeg' | 'image/webp';
|
||||||
|
|
||||||
|
export interface CodexNativeImageArgPart {
|
||||||
|
kind: 'codex-image-arg';
|
||||||
|
attachmentId: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: CodexNativeImageMimeType;
|
||||||
|
path: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexNativeAttachmentDeliveryParts {
|
||||||
|
kind: 'legacy_text' | 'text_with_images';
|
||||||
|
promptText: string;
|
||||||
|
imageParts: CodexNativeImageArgPart[];
|
||||||
|
diagnostics: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildCodexNativeAttachmentDeliveryPartsInput {
|
||||||
|
teamName: string;
|
||||||
|
messageId: string;
|
||||||
|
text: string;
|
||||||
|
attachments?: AttachmentPayload[];
|
||||||
|
appDataPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCodexImageMimeType(mimeType: string): asserts mimeType is CodexNativeImageMimeType {
|
||||||
|
if (mimeType === 'image/png' || mimeType === 'image/jpeg' || mimeType === 'image/webp') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AgentAttachmentError(
|
||||||
|
'attachment_type_unsupported',
|
||||||
|
`Codex native supports image attachments only; unsupported MIME: ${mimeType}`,
|
||||||
|
{ retryable: false }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function codexArtifactFileName(
|
||||||
|
mimeType: CodexNativeImageMimeType
|
||||||
|
): AgentAttachmentArtifactFileName {
|
||||||
|
switch (mimeType) {
|
||||||
|
case 'image/png':
|
||||||
|
return 'optimized.png';
|
||||||
|
case 'image/jpeg':
|
||||||
|
return 'optimized.jpg';
|
||||||
|
case 'image/webp':
|
||||||
|
return 'optimized.webp';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 async function buildCodexNativeAttachmentDeliveryParts(
|
||||||
|
input: BuildCodexNativeAttachmentDeliveryPartsInput
|
||||||
|
): Promise<CodexNativeAttachmentDeliveryParts> {
|
||||||
|
const attachments = input.attachments ?? [];
|
||||||
|
if (attachments.length === 0) {
|
||||||
|
return {
|
||||||
|
kind: 'legacy_text',
|
||||||
|
promptText: input.text,
|
||||||
|
imageParts: [],
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageParts: CodexNativeImageArgPart[] = [];
|
||||||
|
const diagnostics: string[] = [];
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
assertCodexImageMimeType(attachment.mimeType);
|
||||||
|
|
||||||
|
const filePath = resolveAgentAttachmentArtifactPath({
|
||||||
|
teamName: input.teamName,
|
||||||
|
messageId: input.messageId,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
fileName: codexArtifactFileName(attachment.mimeType),
|
||||||
|
appDataPath: input.appDataPath,
|
||||||
|
});
|
||||||
|
const bytes = Buffer.from(attachment.data, 'base64');
|
||||||
|
await writeFileAtomic(filePath, bytes);
|
||||||
|
|
||||||
|
imageParts.push({
|
||||||
|
kind: 'codex-image-arg',
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
filename: attachment.filename,
|
||||||
|
mimeType: attachment.mimeType,
|
||||||
|
path: filePath,
|
||||||
|
sizeBytes: bytes.byteLength,
|
||||||
|
});
|
||||||
|
diagnostics.push(
|
||||||
|
`prepared Codex native image ${attachment.filename} (${attachment.mimeType}, ${formatBytes(
|
||||||
|
bytes.byteLength
|
||||||
|
)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'text_with_images',
|
||||||
|
promptText: input.text,
|
||||||
|
imageParts,
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactCodexNativeAttachmentPartsForDiagnostics(
|
||||||
|
parts: CodexNativeImageArgPart[]
|
||||||
|
): Array<Omit<CodexNativeImageArgPart, 'path'> & { path: string }> {
|
||||||
|
return parts.map((part) => ({
|
||||||
|
...part,
|
||||||
|
path: `[managed attachment artifact: ${part.filename}]`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue