diff --git a/src/features/agent-attachments/main/index.ts b/src/features/agent-attachments/main/index.ts index 792040b1..f2f414c1 100644 --- a/src/features/agent-attachments/main/index.ts +++ b/src/features/agent-attachments/main/index.ts @@ -1,2 +1,3 @@ export * from './infrastructure/attachmentArtifactStore'; export * from './providers/claudeAttachmentAdapter'; +export * from './providers/codexNativeAttachmentAdapter'; diff --git a/src/features/agent-attachments/main/infrastructure/attachmentArtifactStore.ts b/src/features/agent-attachments/main/infrastructure/attachmentArtifactStore.ts index 9da34172..823bd0d5 100644 --- a/src/features/agent-attachments/main/infrastructure/attachmentArtifactStore.ts +++ b/src/features/agent-attachments/main/infrastructure/attachmentArtifactStore.ts @@ -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'; diff --git a/src/features/agent-attachments/main/providers/codexNativeAttachmentAdapter.test.ts b/src/features/agent-attachments/main/providers/codexNativeAttachmentAdapter.test.ts new file mode 100644 index 00000000..ceee11cb --- /dev/null +++ b/src/features/agent-attachments/main/providers/codexNativeAttachmentAdapter.test.ts @@ -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 { + 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]'); + }); +}); diff --git a/src/features/agent-attachments/main/providers/codexNativeAttachmentAdapter.ts b/src/features/agent-attachments/main/providers/codexNativeAttachmentAdapter.ts new file mode 100644 index 00000000..125873d9 --- /dev/null +++ b/src/features/agent-attachments/main/providers/codexNativeAttachmentAdapter.ts @@ -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 { + 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 & { path: string }> { + return parts.map((part) => ({ + ...part, + path: `[managed attachment artifact: ${part.filename}]`, + })); +}