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 './providers/claudeAttachmentAdapter';
|
||||
export * from './providers/codexNativeAttachmentAdapter';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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