feat(attachments): add codex native adapter

This commit is contained in:
777genius 2026-05-09 01:16:08 +03:00
parent bc702caff2
commit 935c522846
4 changed files with 235 additions and 0 deletions

View file

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

View file

@ -6,8 +6,10 @@ import * as path from 'path';
export type AgentAttachmentArtifactFileName =
| 'original.png'
| 'original.jpg'
| 'original.webp'
| 'optimized.png'
| 'optimized.jpg'
| 'optimized.webp'
| 'thumb.jpg'
| 'meta.json';

View file

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

View file

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