feat(attachments): add agent attachment foundation

This commit is contained in:
777genius 2026-05-09 01:09:48 +03:00
parent 7b88d495d0
commit c2cb84607a
22 changed files with 950 additions and 10 deletions

View file

@ -155,6 +155,7 @@
"motion": "12.38.0",
"node-diff3": "^3.2.0",
"node-pty": "^1.1.0",
"pica": "9.0.1",
"pidusage": "4.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View file

@ -239,6 +239,9 @@ importers:
node-pty:
specifier: ^1.1.0
version: 1.1.0
pica:
specifier: 9.0.1
version: 9.0.1
pidusage:
specifier: 4.0.1
version: 4.0.1
@ -492,6 +495,9 @@ importers:
'@shikijs/transformers':
specifier: 3.22.0
version: 3.22.0
autoprefixer:
specifier: ^10.5.0
version: 10.5.0(postcss@8.5.8)
eslint:
specifier: ^9.39.2
version: 9.39.2(jiti@2.6.1)
@ -504,6 +510,9 @@ importers:
sass:
specifier: ^1.97.2
version: 1.98.0
tailwindcss:
specifier: ^3.4.19
version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
vite-plugin-vuetify:
specifier: ^2.1.3
version: 2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3)
@ -5243,8 +5252,8 @@ packages:
peerDependencies:
postcss: ^8.1.0
autoprefixer@10.4.27:
resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==}
autoprefixer@10.5.0:
resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==}
engines: {node: ^10 || ^12 || >=14}
hasBin: true
peerDependencies:
@ -5327,6 +5336,11 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.28:
resolution: {integrity: sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA==}
engines: {node: '>=6.0.0'}
hasBin: true
baseline-browser-mapping@2.9.14:
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
hasBin: true
@ -5384,6 +5398,11 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
@ -5490,6 +5509,9 @@ packages:
caniuse-lite@1.0.30001781:
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@ -6257,6 +6279,9 @@ packages:
electron-to-chromium@1.5.267:
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
electron-to-chromium@1.5.352:
resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==}
electron-updater@6.7.3:
resolution: {integrity: sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==}
@ -7111,6 +7136,9 @@ packages:
resolution: {integrity: sha512-dW7vl+yiAJSp6aCekaVnVJxurRv7DCOLyXqEG3RYMYUg7AuJ2jCqPkZTA8ooqC2vtnkaMcV5WfFBMuEnTu1OQg==}
engines: {node: '>=20'}
glur@1.1.2:
resolution: {integrity: sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@ -8352,6 +8380,9 @@ packages:
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
multimath@2.0.0:
resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==}
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
@ -8450,6 +8481,9 @@ packages:
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
node-releases@2.0.38:
resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
nopt@8.1.0:
resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==}
engines: {node: ^18.17.0 || >=20.5.0}
@ -8757,6 +8791,9 @@ packages:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pica@9.0.1:
resolution: {integrity: sha512-v0U4vY6Z3ztz9b4jBIhCD3WYoecGXCQeCsYep+sXRefViL+mVVoTL+wqzdPeE+GpBFsRUtQZb6dltvAt2UkMtQ==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -10948,6 +10985,9 @@ packages:
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
webworkify@1.5.0:
resolution: {integrity: sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==}
whatwg-mimetype@3.0.0:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
engines: {node: '>=12'}
@ -13100,7 +13140,7 @@ snapshots:
'@rollup/plugin-replace': 6.0.3(rollup@4.60.0)
'@vitejs/plugin-vue': 6.0.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
'@vitejs/plugin-vue-jsx': 5.1.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
autoprefixer: 10.4.27(postcss@8.5.8)
autoprefixer: 10.5.0(postcss@8.5.8)
consola: 3.4.2
cssnano: 7.1.3(postcss@8.5.8)
defu: 6.1.4
@ -16245,10 +16285,10 @@ snapshots:
postcss: 8.5.6
postcss-value-parser: 4.2.0
autoprefixer@10.4.27(postcss@8.5.8):
autoprefixer@10.5.0(postcss@8.5.8):
dependencies:
browserslist: 4.28.1
caniuse-lite: 1.0.30001781
browserslist: 4.28.2
caniuse-lite: 1.0.30001792
fraction.js: 5.3.4
picocolors: 1.1.1
postcss: 8.5.8
@ -16318,6 +16358,8 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.28: {}
baseline-browser-mapping@2.9.14: {}
bcrypt-pbkdf@1.0.2:
@ -16388,6 +16430,14 @@ snapshots:
node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1)
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.28
caniuse-lite: 1.0.30001792
electron-to-chromium: 1.5.352
node-releases: 2.0.38
update-browserslist-db: 1.2.3(browserslist@4.28.2)
buffer-crc32@0.2.13: {}
buffer-crc32@1.0.0: {}
@ -16535,6 +16585,8 @@ snapshots:
caniuse-lite@1.0.30001781: {}
caniuse-lite@1.0.30001792: {}
ccount@2.0.1: {}
chai@5.3.3:
@ -17324,6 +17376,8 @@ snapshots:
electron-to-chromium@1.5.267: {}
electron-to-chromium@1.5.352: {}
electron-updater@6.7.3:
dependencies:
builder-util-runtime: 9.5.1
@ -18633,6 +18687,8 @@ snapshots:
slash: 5.1.0
unicorn-magic: 0.4.0
glur@1.1.2: {}
gopd@1.2.0: {}
got@11.8.6:
@ -20213,6 +20269,11 @@ snapshots:
muggle-string@0.4.1: {}
multimath@2.0.0:
dependencies:
glur: 1.1.2
object-assign: 4.1.1
mz@2.7.0:
dependencies:
any-promise: 1.3.0
@ -20391,6 +20452,8 @@ snapshots:
node-releases@2.0.27: {}
node-releases@2.0.38: {}
nopt@8.1.0:
dependencies:
abbrev: 3.0.1
@ -20918,6 +20981,13 @@ snapshots:
postgres-date: 1.0.7
postgres-interval: 1.2.0
pica@9.0.1:
dependencies:
glur: 1.1.2
multimath: 2.0.0
object-assign: 4.1.1
webworkify: 1.5.0
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@ -22988,6 +23058,12 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies:
browserslist: 4.28.2
escalade: 3.2.0
picocolors: 1.1.1
uqr@0.1.2: {}
uri-js@4.4.1:
@ -23446,6 +23522,8 @@ snapshots:
webpack-virtual-modules@0.6.2: {}
webworkify@1.5.0: {}
whatwg-mimetype@3.0.0: {}
whatwg-url@5.0.0:

View file

@ -0,0 +1,13 @@
export type {
AgentAttachmentCapability,
AgentAttachmentCapabilityTarget,
AgentAttachmentErrorJson,
AgentAttachmentPayload,
AttachmentDeliveryFailureCode,
AttachmentValidationResult,
AttachmentWarning,
AttachmentWarningCode,
ImageOptimizationBudget,
} from '../core/domain';
export { AGENT_ATTACHMENT_SCHEMA_VERSION } from '../core/domain';

View file

@ -0,0 +1,39 @@
import { allocateImageBudgets, planResizeDimensions, sortAttachmentsForDelivery } from './budgets';
describe('agent attachment budgets', () => {
it('does not upscale small images', () => {
expect(planResizeDimensions({ width: 320, height: 200 }, { maxEdge: 1600 })).toEqual({
width: 320,
height: 200,
});
});
it('downscales by longest edge', () => {
expect(planResizeDimensions({ width: 4000, height: 2000 }, { maxEdge: 2000 })).toEqual({
width: 2000,
height: 1000,
});
});
it('allocates fair per-image budgets within total cap', () => {
expect(
allocateImageBudgets({
images: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
totalMaxBytes: 900,
perImageMaxBytes: 500,
})
).toEqual([
{ imageId: 'a', targetBytes: 300 },
{ imageId: 'b', targetBytes: 300 },
{ imageId: 'c', targetBytes: 300 },
]);
});
it('preserves explicit attachment order for delivery', () => {
const sorted = sortAttachmentsForDelivery([
{ id: 'b', order: 2 },
{ id: 'a', order: 1 },
]);
expect(sorted.map((item) => item.id)).toEqual(['a', 'b']);
});
});

View file

@ -0,0 +1,64 @@
import type { ImageBudgetAllocation, ImageDimensions, ImageOptimizationBudget } from './types';
export const DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET: ImageOptimizationBudget = {
maxInputBytes: 20 * 1024 * 1024,
maxInputPixels: 32_000_000,
maxOutputBytesPerImage: 4 * 1024 * 1024,
maxOutputBytesTotal: 8 * 1024 * 1024,
maxOutputEdge: 2400,
jpegQualityAttempts: [0.86, 0.82, 0.78, 0.74, 0.72],
};
export function calculatePixelCount(dimensions: ImageDimensions): number {
return dimensions.width * dimensions.height;
}
export function planResizeDimensions(
dimensions: ImageDimensions,
options: { maxEdge: number; allowUpscale?: boolean }
): ImageDimensions {
const width = Math.max(1, Math.floor(dimensions.width));
const height = Math.max(1, Math.floor(dimensions.height));
const maxEdge = Math.max(1, Math.floor(options.maxEdge));
const longest = Math.max(width, height);
if (longest <= maxEdge && options.allowUpscale !== true) {
return { width, height };
}
const scale = maxEdge / longest;
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
};
}
export function allocateImageBudgets(input: {
images: { id: string }[];
totalMaxBytes: number;
perImageMaxBytes: number;
}): ImageBudgetAllocation[] {
const count = Math.max(1, input.images.length);
const fairShare = Math.floor(input.totalMaxBytes / count);
const targetBytes = Math.max(1, Math.min(input.perImageMaxBytes, fairShare));
return input.images.map((image) => ({ imageId: image.id, targetBytes }));
}
export function assertImageInputWithinBudget(input: {
sizeBytes: number;
dimensions: ImageDimensions;
budget?: ImageOptimizationBudget;
}): void {
const budget = input.budget ?? DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET;
if (input.sizeBytes > budget.maxInputBytes) {
throw new Error('Image input exceeds byte budget');
}
if (calculatePixelCount(input.dimensions) > budget.maxInputPixels) {
throw new Error('Image input exceeds pixel budget');
}
}
export function sortAttachmentsForDelivery<T extends { order: number }>(attachments: T[]): T[] {
return [...attachments].sort((left, right) => left.order - right.order);
}

View file

@ -0,0 +1,89 @@
import type { AgentAttachmentCapability, AgentAttachmentCapabilityTarget } from './types';
const DEFAULT_IMAGE_BYTES_PER_PROVIDER = 4 * 1024 * 1024;
const DEFAULT_IMAGE_BYTES_TOTAL = 8 * 1024 * 1024;
function supported(displayText: string): AgentAttachmentCapability {
return {
supportsImages: true,
supportedImageMimeTypes: ['image/png', 'image/jpeg'],
maxImages: 5,
maxBytesPerImage: DEFAULT_IMAGE_BYTES_PER_PROVIDER,
maxBytesTotal: DEFAULT_IMAGE_BYTES_TOTAL,
reason: 'known_provider_support',
displayText,
};
}
function unsupported(
reason: AgentAttachmentCapability['reason'],
displayText: string
): AgentAttachmentCapability {
return {
supportsImages: false,
supportedImageMimeTypes: [],
maxImages: 0,
maxBytesPerImage: 0,
maxBytesTotal: 0,
reason,
displayText,
};
}
export function canonicalizeOpenCodeModel(input: { providerId: string; model?: string | null }): {
providerId: string;
model: string;
} {
const providerId = input.providerId.trim().toLowerCase();
const model = (input.model ?? '')
.trim()
.toLowerCase()
.replace(/^openrouter\//, '')
.replace(/^openai\//, '');
return { providerId, model };
}
export function resolveAgentAttachmentCapability(
target: AgentAttachmentCapabilityTarget
): AgentAttachmentCapability {
const providerId = target.providerId.trim().toLowerCase();
if (providerId === 'anthropic') {
return supported('Claude can receive image attachments through structured image blocks.');
}
if (providerId === 'codex') {
return supported('Codex can receive image attachments through the native image channel.');
}
if (providerId === 'opencode') {
const { model } = canonicalizeOpenCodeModel(target);
if (model === 'gpt-5.4-mini') {
return {
...supported('OpenCode model openai/gpt-5.4-mini is verified for image attachments.'),
reason: 'known_vision_model',
};
}
if (model === 'moonshotai/kimi-k2.6' || model === 'z-ai/glm-4.5v') {
return {
...supported(`OpenCode model ${model} is verified for image attachments.`),
reason: 'known_vision_model',
};
}
if (model === 'z-ai/glm-5.1') {
return unsupported(
'known_non_vision_model',
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
);
}
return unsupported(
'unknown_model',
'This OpenCode model has unknown image support. Image delivery is blocked for reliability.'
);
}
return unsupported(
'unsupported_provider',
'Selected provider does not support image attachments through this delivery path.'
);
}

View file

@ -0,0 +1,30 @@
import type { AgentAttachmentErrorJson, AttachmentDeliveryFailureCode } from './types';
export class AgentAttachmentError extends Error {
constructor(
readonly code: AttachmentDeliveryFailureCode,
message: string,
readonly options: {
providerId?: string;
model?: string;
attachmentId?: string;
retryable?: boolean;
safeDetails?: Record<string, string | number | boolean | null>;
} = {}
) {
super(message);
this.name = 'AgentAttachmentError';
}
toJSON(): AgentAttachmentErrorJson {
return {
code: this.code,
message: this.message,
retryable: this.options.retryable ?? false,
...(this.options.providerId ? { providerId: this.options.providerId } : {}),
...(this.options.model ? { model: this.options.model } : {}),
...(this.options.attachmentId ? { attachmentId: this.options.attachmentId } : {}),
...(this.options.safeDetails ? { safeDetails: this.options.safeDetails } : {}),
};
}
}

View file

@ -0,0 +1,6 @@
export * from './budgets';
export * from './capabilities';
export * from './errors';
export * from './storageIds';
export * from './types';
export * from './validation';

View file

@ -0,0 +1,16 @@
import { assertSafeAttachmentStorageId, isSafeAttachmentStorageId } from './storageIds';
describe('agent attachment storage ids', () => {
it('accepts compact stable ids', () => {
expect(isSafeAttachmentStorageId('msg_abc-123')).toBe(true);
});
it('rejects traversal-like ids', () => {
expect(() => assertSafeAttachmentStorageId('messageId', '../secret')).toThrow(
/Invalid messageId/
);
expect(() => assertSafeAttachmentStorageId('attachmentId', 'a/b')).toThrow(
/Invalid attachmentId/
);
});
});

View file

@ -0,0 +1,11 @@
const SAFE_ATTACHMENT_STORAGE_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,120}$/;
export function isSafeAttachmentStorageId(value: string): boolean {
return SAFE_ATTACHMENT_STORAGE_ID_RE.test(value);
}
export function assertSafeAttachmentStorageId(label: string, value: string): void {
if (!isSafeAttachmentStorageId(value)) {
throw new Error(`Invalid ${label}`);
}
}

View file

@ -0,0 +1,119 @@
export const AGENT_ATTACHMENT_SCHEMA_VERSION = 1 as const;
export type AgentAttachmentKind = 'image' | 'file' | 'unsupported';
export type AgentImageMimeType = 'image/png' | 'image/jpeg' | 'image/webp';
export type ProviderImageMimeType = 'image/png' | 'image/jpeg';
export type AttachmentDeliveryFailureCode =
| 'attachment_too_large'
| 'attachment_type_unsupported'
| 'attachment_model_unsupported'
| 'attachment_optimization_failed'
| 'attachment_artifact_missing'
| 'attachment_artifact_path_unsafe'
| 'attachment_provider_rejected'
| 'attachment_runtime_transport_failed';
export type AttachmentWarningCode =
| 'image_was_resized'
| 'image_was_reencoded'
| 'image_quality_reduced'
| 'model_support_unknown'
| 'model_does_not_support_images'
| 'file_type_not_supported';
export interface AttachmentWarning {
code: AttachmentWarningCode;
message: string;
attachmentId?: string;
}
export interface AgentAttachmentErrorJson {
code: AttachmentDeliveryFailureCode;
message: string;
providerId?: string;
model?: string;
attachmentId?: string;
retryable: boolean;
safeDetails?: Record<string, string | number | boolean | null>;
}
export interface AgentImageMetadata {
width?: number;
height?: number;
animated?: boolean;
optimizedWidth?: number;
optimizedHeight?: number;
optimization: 'none' | 'lossless' | 'resized' | 'jpeg-reencoded' | 'unsupported';
}
export interface AgentAttachmentStorageReference {
originalArtifactId?: string;
optimizedArtifactId?: string;
thumbnailArtifactId?: string;
}
export interface AgentAttachmentPayload {
schemaVersion: typeof AGENT_ATTACHMENT_SCHEMA_VERSION;
id: string;
originalName: string;
mimeType: string;
sizeBytes: number;
kind: AgentAttachmentKind;
source: 'composer' | 'clipboard' | 'drag-drop' | 'task' | 'inbox';
order: number;
storage: AgentAttachmentStorageReference;
image?: AgentImageMetadata;
warnings: AttachmentWarning[];
}
export interface ImageOptimizationBudget {
maxInputBytes: number;
maxInputPixels: number;
maxOutputBytesPerImage: number;
maxOutputBytesTotal: number;
maxOutputEdge: number;
jpegQualityAttempts: readonly number[];
}
export interface ImageDimensions {
width: number;
height: number;
}
export interface ImageBudgetAllocation {
imageId: string;
targetBytes: number;
}
export type AgentAttachmentProviderId = 'anthropic' | 'codex' | 'opencode' | 'unknown';
export interface AgentAttachmentCapabilityTarget {
providerId: AgentAttachmentProviderId | string;
model?: string | null;
}
export interface AgentAttachmentCapability {
supportsImages: boolean;
supportedImageMimeTypes: ProviderImageMimeType[];
maxImages: number;
maxBytesPerImage: number;
maxBytesTotal: number;
reason:
| 'known_provider_support'
| 'known_vision_model'
| 'known_non_vision_model'
| 'unknown_model'
| 'unsupported_provider';
displayText: string;
}
export type AttachmentValidationResult =
| { ok: true; warnings: AttachmentWarning[] }
| {
ok: false;
code: AttachmentDeliveryFailureCode;
message: string;
warnings: AttachmentWarning[];
};

View file

@ -0,0 +1,73 @@
import { resolveAgentAttachmentCapability } from './capabilities';
import { validateAttachmentForCapability, validateImageOptimizationInput } from './validation';
import type { AgentAttachmentPayload } from './types';
function fakeImageAttachment(
overrides: Partial<AgentAttachmentPayload> = {}
): AgentAttachmentPayload {
return {
schemaVersion: 1,
id: 'att_1',
originalName: 'red-square.png',
mimeType: 'image/png',
sizeBytes: 1024,
kind: 'image',
source: 'composer',
order: 1,
storage: { originalArtifactId: 'art_original_1', optimizedArtifactId: 'art_optimized_1' },
image: { width: 64, height: 64, optimizedWidth: 64, optimizedHeight: 64, optimization: 'none' },
warnings: [],
...overrides,
};
}
describe('agent attachment validation', () => {
it('accepts a small png optimization input', () => {
expect(
validateImageOptimizationInput({
mimeType: 'image/png',
sizeBytes: 1000,
width: 64,
height: 64,
})
).toEqual({ ok: true, warnings: [] });
});
it('rejects unsupported image optimization input', () => {
const result = validateImageOptimizationInput({
mimeType: 'image/gif',
sizeBytes: 1000,
width: 64,
height: 64,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('attachment_type_unsupported');
});
it('blocks known non-vision OpenCode models', () => {
const capability = resolveAgentAttachmentCapability({
providerId: 'opencode',
model: 'openrouter/z-ai/glm-5.1',
});
const result = validateAttachmentForCapability({
attachment: fakeImageAttachment(),
capability,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('attachment_model_unsupported');
});
it('allows known vision OpenCode models', () => {
const capability = resolveAgentAttachmentCapability({
providerId: 'opencode',
model: 'openrouter/moonshotai/kimi-k2.6',
});
expect(
validateAttachmentForCapability({ attachment: fakeImageAttachment(), capability })
).toEqual({
ok: true,
warnings: [],
});
});
});

View file

@ -0,0 +1,119 @@
import { DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET } from './budgets';
import type {
AgentAttachmentCapability,
AgentAttachmentKind,
AgentAttachmentPayload,
AgentImageMimeType,
AttachmentValidationResult,
ImageOptimizationBudget,
ProviderImageMimeType,
} from './types';
const AGENT_IMAGE_MIME_TYPES = new Set<AgentImageMimeType>([
'image/png',
'image/jpeg',
'image/webp',
]);
const PROVIDER_IMAGE_MIME_TYPES = new Set<ProviderImageMimeType>(['image/png', 'image/jpeg']);
export function isAgentImageMimeType(mimeType: string): mimeType is AgentImageMimeType {
return AGENT_IMAGE_MIME_TYPES.has(mimeType as AgentImageMimeType);
}
export function isProviderImageMimeType(mimeType: string): mimeType is ProviderImageMimeType {
return PROVIDER_IMAGE_MIME_TYPES.has(mimeType as ProviderImageMimeType);
}
export function classifyAttachmentMime(mimeType: string): AgentAttachmentKind {
if (isAgentImageMimeType(mimeType)) return 'image';
if (mimeType === 'application/pdf' || mimeType === 'text/plain' || mimeType.startsWith('text/')) {
return 'file';
}
return 'unsupported';
}
export function validateImageOptimizationInput(input: {
mimeType: string;
sizeBytes: number;
width: number;
height: number;
budget?: ImageOptimizationBudget;
}): AttachmentValidationResult {
const budget = input.budget ?? DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET;
if (!isAgentImageMimeType(input.mimeType)) {
return {
ok: false,
code: 'attachment_type_unsupported',
message: 'This file type is not supported for agent image delivery.',
warnings: [],
};
}
if (input.sizeBytes <= 0) {
return {
ok: false,
code: 'attachment_type_unsupported',
message: 'Image file is empty.',
warnings: [],
};
}
if (input.sizeBytes > budget.maxInputBytes) {
return {
ok: false,
code: 'attachment_too_large',
message: 'Image is too large to prepare for sending.',
warnings: [],
};
}
if (input.width * input.height > budget.maxInputPixels) {
return {
ok: false,
code: 'attachment_too_large',
message: 'Image dimensions are too large to prepare for sending.',
warnings: [],
};
}
return { ok: true, warnings: [] };
}
export function validateAttachmentForCapability(input: {
attachment: AgentAttachmentPayload;
capability: AgentAttachmentCapability;
}): AttachmentValidationResult {
const { attachment, capability } = input;
const warnings = [...attachment.warnings];
if (attachment.kind !== 'image') {
return { ok: true, warnings };
}
if (!capability.supportsImages) {
return {
ok: false,
code: 'attachment_model_unsupported',
message: capability.displayText,
warnings,
};
}
if (!isProviderImageMimeType(attachment.mimeType)) {
return {
ok: false,
code: 'attachment_type_unsupported',
message: 'This image type is not supported by the selected provider.',
warnings,
};
}
if (attachment.sizeBytes > capability.maxBytesPerImage) {
return {
ok: false,
code: 'attachment_too_large',
message: 'Image is too large after optimization. Remove it or use a smaller image.',
warnings,
};
}
return { ok: true, warnings };
}

View file

@ -0,0 +1 @@
export * from './contracts';

View file

@ -0,0 +1 @@
export * from './infrastructure/attachmentArtifactStore';

View file

@ -0,0 +1,40 @@
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { resolveAgentAttachmentArtifactPath, writeFileAtomic } from './attachmentArtifactStore';
describe('agent attachment artifact store helpers', () => {
it('resolves paths under the managed attachment directory', () => {
const root = path.join(os.tmpdir(), 'agent-attachments-test');
const resolved = resolveAgentAttachmentArtifactPath({
appDataPath: root,
teamName: 'team_1',
messageId: 'msg_1',
attachmentId: 'att_1',
fileName: 'optimized.png',
});
expect(resolved).toBe(
path.join(root, 'attachments', 'team_1', 'msg_1', 'att_1', 'optimized.png')
);
});
it('rejects unsafe ids before path construction', () => {
expect(() =>
resolveAgentAttachmentArtifactPath({
appDataPath: '/tmp/root',
teamName: 'team_1',
messageId: '../msg',
attachmentId: 'att_1',
fileName: 'optimized.png',
})
).toThrow(/Invalid messageId/);
});
it('writes files atomically', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-attachments-'));
const filePath = path.join(dir, 'file.txt');
await writeFileAtomic(filePath, 'hello');
await expect(fs.readFile(filePath, 'utf8')).resolves.toBe('hello');
});
});

View file

@ -0,0 +1,55 @@
import { getAppDataPath } from '@main/utils/pathDecoder';
import { assertSafeAttachmentStorageId } from '@features/agent-attachments/core/domain';
import * as fs from 'fs/promises';
import * as path from 'path';
export type AgentAttachmentArtifactFileName =
| 'original.png'
| 'original.jpg'
| 'optimized.png'
| 'optimized.jpg'
| 'thumb.jpg'
| 'meta.json';
export interface ResolveAgentAttachmentArtifactPathInput {
teamName: string;
messageId: string;
attachmentId: string;
fileName: AgentAttachmentArtifactFileName;
appDataPath?: string;
}
export function resolveAgentAttachmentArtifactPath(
input: ResolveAgentAttachmentArtifactPathInput
): string {
assertSafeAttachmentStorageId('teamName', input.teamName);
assertSafeAttachmentStorageId('messageId', input.messageId);
assertSafeAttachmentStorageId('attachmentId', input.attachmentId);
const root = input.appDataPath ?? getAppDataPath();
const base = path.resolve(
root,
'attachments',
input.teamName,
input.messageId,
input.attachmentId
);
const resolved = path.resolve(base, input.fileName);
if (!resolved.startsWith(base + path.sep)) {
throw new Error('Attachment artifact path escaped managed directory');
}
return resolved;
}
export async function writeFileAtomic(filePath: string, bytes: Buffer | string): Promise<void> {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
const tmpPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
try {
await fs.writeFile(tmpPath, bytes);
await fs.rename(tmpPath, filePath);
} catch (error) {
await fs.rm(tmpPath, { force: true }).catch(() => undefined);
throw error;
}
}

View file

@ -0,0 +1 @@
export * from './optimizeImageForAgent';

View file

@ -0,0 +1,171 @@
import createPica from 'pica';
import {
DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET,
planResizeDimensions,
validateImageOptimizationInput,
type AttachmentWarning,
type ImageDimensions,
type ImageOptimizationBudget,
} from '@features/agent-attachments/core/domain';
export interface OptimizeImageForAgentInput {
file: File;
budget?: ImageOptimizationBudget;
}
export interface OptimizeImageForAgentResult {
original: {
blob: Blob;
mimeType: string;
sizeBytes: number;
width: number;
height: number;
};
optimized: {
blob: Blob;
mimeType: 'image/png' | 'image/jpeg';
sizeBytes: number;
width: number;
height: number;
};
warnings: AttachmentWarning[];
}
function canvasToBlob(
canvas: HTMLCanvasElement,
mimeType: string,
quality?: number
): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) reject(new Error('Could not encode image canvas'));
else resolve(blob);
},
mimeType,
quality
);
});
}
async function drawBitmapToCanvas(
bitmap: ImageBitmap,
dimensions: ImageDimensions
): Promise<HTMLCanvasElement> {
const canvas = document.createElement('canvas');
canvas.width = dimensions.width;
canvas.height = dimensions.height;
const context = canvas.getContext('2d');
if (!context) throw new Error('Could not create image canvas context');
context.drawImage(bitmap, 0, 0, dimensions.width, dimensions.height);
return canvas;
}
async function resizeCanvas(
source: HTMLCanvasElement,
dimensions: ImageDimensions
): Promise<HTMLCanvasElement> {
const target = document.createElement('canvas');
target.width = dimensions.width;
target.height = dimensions.height;
const pica = createPica();
await pica.resize(source, target);
return target;
}
async function encodeJpegWithinBudget(
canvas: HTMLCanvasElement,
budget: ImageOptimizationBudget,
targetBytes: number,
warnings: AttachmentWarning[]
): Promise<Blob> {
for (const quality of budget.jpegQualityAttempts) {
const blob = await canvasToBlob(canvas, 'image/jpeg', quality);
if (blob.size <= targetBytes) {
if (quality < budget.jpegQualityAttempts[0]) {
warnings.push({
code: 'image_quality_reduced',
message: 'Image quality was reduced to fit the provider budget.',
});
}
return blob;
}
}
throw new Error('Image is too large after optimization. Remove it or use a smaller image.');
}
export async function optimizeImageForAgent(
input: OptimizeImageForAgentInput
): Promise<OptimizeImageForAgentResult> {
const budget = input.budget ?? DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET;
const bitmap = await createImageBitmap(input.file);
const originalDimensions = { width: bitmap.width, height: bitmap.height };
const validation = validateImageOptimizationInput({
mimeType: input.file.type,
sizeBytes: input.file.size,
width: originalDimensions.width,
height: originalDimensions.height,
budget,
});
if (!validation.ok) {
throw new Error(validation.message);
}
const targetDimensions = planResizeDimensions(originalDimensions, {
maxEdge: budget.maxOutputEdge,
});
const warnings: AttachmentWarning[] = [...validation.warnings];
if (
targetDimensions.width !== originalDimensions.width ||
targetDimensions.height !== originalDimensions.height
) {
warnings.push({ code: 'image_was_resized', message: 'Image was resized before sending.' });
}
const sourceCanvas = await drawBitmapToCanvas(bitmap, originalDimensions);
const outputCanvas =
targetDimensions.width === originalDimensions.width &&
targetDimensions.height === originalDimensions.height
? sourceCanvas
: await resizeCanvas(sourceCanvas, targetDimensions);
let optimizedBlob: Blob;
let optimizedMimeType: 'image/png' | 'image/jpeg';
if (input.file.type === 'image/png') {
optimizedBlob = await canvasToBlob(outputCanvas, 'image/png');
optimizedMimeType = 'image/png';
if (optimizedBlob.size > budget.maxOutputBytesPerImage) {
throw new Error(
'PNG image is too large after optimization. Use a smaller screenshot or JPEG image.'
);
}
} else {
optimizedBlob = await encodeJpegWithinBudget(
outputCanvas,
budget,
budget.maxOutputBytesPerImage,
warnings
);
optimizedMimeType = 'image/jpeg';
if (input.file.type !== 'image/jpeg') {
warnings.push({ code: 'image_was_reencoded', message: 'Image was converted to JPEG.' });
}
}
return {
original: {
blob: input.file,
mimeType: input.file.type,
sizeBytes: input.file.size,
...originalDimensions,
},
optimized: {
blob: optimizedBlob,
mimeType: optimizedMimeType,
sizeBytes: optimizedBlob.size,
...targetDimensions,
},
warnings,
};
}

View file

@ -3,6 +3,8 @@ import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
import type { AttachmentFileData, AttachmentPayload } from '@shared/types';
const logger = createLogger('Service:TeamAttachmentStore');
@ -108,7 +110,7 @@ export class TeamAttachmentStore {
const storedPath = this.getStoredFilePath(teamName, messageId, att.id, att.filename);
try {
await fs.promises.writeFile(storedPath, buffer);
await atomicWriteAsync(storedPath, buffer);
} catch (writeError) {
logger.warn(`[${teamName}] Failed to write attachment ${att.id}: ${writeError}`);
continue;
@ -125,7 +127,7 @@ export class TeamAttachmentStore {
// Write metadata index for successful files (mimeType, original filename)
if (indexEntries.length > 0) {
const indexPath = this.getIndexPath(teamName, messageId);
await fs.promises.writeFile(indexPath, JSON.stringify(indexEntries, null, 2));
await atomicWriteAsync(indexPath, JSON.stringify(indexEntries, null, 2));
}
logger.debug(

View file

@ -34,13 +34,13 @@ async function renameWithRetry(src: string, dest: string): Promise<void> {
* Async atomic write: write tmp file then rename over target.
* Uses best-effort fsync and EXDEV/EPERM fallback for safety.
*/
export async function atomicWriteAsync(targetPath: string, data: string): Promise<void> {
export async function atomicWriteAsync(targetPath: string, data: string | Buffer): Promise<void> {
const dir = path.dirname(targetPath);
const tmpPath = path.join(dir, `.tmp.${randomUUID()}`);
try {
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(tmpPath, data, 'utf8');
await fs.promises.writeFile(tmpPath, data, typeof data === 'string' ? 'utf8' : undefined);
let fd: fs.promises.FileHandle | null = null;
try {

11
src/types/pica.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
declare module 'pica' {
export interface PicaInstance {
resize(
from: HTMLCanvasElement,
to: HTMLCanvasElement,
options?: Record<string, unknown>
): Promise<HTMLCanvasElement>;
}
export default function createPica(options?: Record<string, unknown>): PicaInstance;
}