54 KiB
Phase 1 - Attachment normalization, image optimization, budgets, and UI warnings
⚠️ Историческая документация: Этот файл содержит дизайн-документацию для Phase 1. Фактическая реализация в src/features/agent-attachments/ может отличаться от описанной архитектуры. Для актуального состояния см. код в src/features/agent-attachments/core/domain/ (budgets.ts, errors.ts, capabilities.ts, types.ts и т.д.).
Summary
Goal: make attachment intake safe before changing provider delivery paths.
Chosen approach: new agent-attachments feature skeleton + renderer pica optimizer + backend budget validator + capability warnings, with current runtime delivery behavior preserved.
🎯 9.4 🛡️ 9.3 🧠 5.8
Estimated change size: 260-420 LOC.
This phase is intentionally conservative. It reduces crash risk from oversized image payloads without changing Claude/Codex/OpenCode runtime launch or delivery semantics.
Why this phase first
Current attachment handling stores images as base64 in renderer and validates decoded file size only. This misses the real risk:
image bytes -> base64 expands by ~33% -> JSON wrapper -> stream-json stdin line
A 20MB decoded total can become a much larger single-line JSON payload and can destabilize a long-lived lead process.
Phase 1 creates the safety foundation:
- normalize attachments;
- optimize screenshots;
- calculate estimated serialized payload size;
- block too-large sends before stdin write;
- show clear UI warnings;
- do not change runtime adapter logic yet.
Scope
In scope:
- new
src/features/agent-attachmentscontracts/core shell; - renderer image optimization using
pica@9.0.1; - new normalized attachment DTOs;
- backend validation for image dimensions, bytes, base64 size, and estimated serialized payload;
- UI warnings in composer;
- tests for optimizer decisions and validation.
Out of scope:
- Codex
--imagewiring; - OpenCode file parts;
- model capability catalog beyond basic warnings;
- document/PDF optimization;
- live provider calls.
Dependency decision
Add:
pnpm add pica@9.0.1
Rationale:
- pure browser-side high-quality resize;
- no native Electron packaging risk;
- good quality for screenshots and UI text;
- safer before release than
sharpin Electron main.
Do not add:
sharpin Electron main in this phase;@squoosh/libdue staleness/complexity;jimpdue lower quality/performance for screenshots.
New feature layout
src/features/agent-attachments/
contracts/
api.ts
dto.ts
channels.ts
core/
domain/
AttachmentBudget.ts
AttachmentModel.ts
AttachmentValidation.ts
application/
AttachmentIntakePolicy.ts
AttachmentBudgetEstimator.ts
main/
composition/
createAgentAttachmentsFeature.ts
adapters/
input/ipc/registerAgentAttachmentIpc.ts
infrastructure/
ServerAttachmentValidator.ts
preload/
createAgentAttachmentsBridge.ts
renderer/
hooks/useAttachmentPreparation.ts
ui/AttachmentCapabilityNotice.tsx
utils/picaImageOptimizer.ts
If this feels too much for phase 1, contracts/domain/application can be created first and IPC can be deferred. But the boundaries should be established now.
Contract DTOs
export type AgentAttachmentKind = 'image' | 'document' | 'text' | 'unsupported';
export interface AgentAttachmentDraftDto {
id: string;
filename: string;
mimeType: string;
kind: AgentAttachmentKind;
originalBytes: number;
dataBase64: string;
width?: number;
height?: number;
optimized?: AgentAttachmentOptimizedVariantDto;
warnings: AgentAttachmentWarningDto[];
}
export interface AgentAttachmentOptimizedVariantDto {
mimeType: 'image/jpeg' | 'image/png' | 'image/webp';
dataBase64: string;
bytes: number;
width: number;
height: number;
quality?: number;
strategy: 'unchanged' | 'resized' | 'converted' | 'resized-and-converted';
}
export interface AgentAttachmentWarningDto {
code:
| 'image_resized'
| 'image_quality_reduced'
| 'image_too_large'
| 'animated_gif_unchanged'
| 'unsupported_mime_type'
| 'serialized_payload_too_large';
severity: 'info' | 'warning' | 'error';
message: string;
}
Budget constants
Start conservative. These can be tuned after e2e.
export const AGENT_ATTACHMENT_BUDGETS = {
maxFiles: 5,
maxOriginalFileBytes: 10 * 1024 * 1024,
maxTotalOriginalBytes: 20 * 1024 * 1024,
maxOptimizedImageBytes: 1_500_000,
maxTotalOptimizedBytes: 4_000_000,
maxEstimatedStreamJsonPayloadBytes: 7_500_000,
maxDecodedMegapixels: 24,
maxLongEdgePx: 2000,
minJpegQuality: 0.72,
initialJpegQuality: 0.88,
} as const;
Rationale:
- Claude Code docs mention 10MB stdin limit for headless input modes. Use
7.5MBapp budget to leave JSON/base64 overhead headroom. - Multiple images need a total optimized budget, not only per-image limits.
- Screenshots need enough resolution to read text, so do not crush quality below
0.72silently.
Renderer optimizer policy
Use pica only for images where this is safe.
export async function optimizeImageForAgentAttachment(
input: BrowserImageInput,
policy = DEFAULT_IMAGE_OPTIMIZATION_POLICY,
): Promise<AgentAttachmentOptimizedVariantDto> {
if (input.mimeType === 'image/gif') {
return keepOriginalWithWarning('animated_gif_unchanged');
}
if (input.hasAlpha) {
return resizePngPreservingAlpha(input, policy);
}
return resizeRgbScreenshotToJpeg(input, policy);
}
Rules:
- Preserve aspect ratio.
- Preserve alpha by staying PNG unless output exceeds budget and user must choose a lower-fidelity conversion explicitly later.
- Do not silently convert animated GIF to a still image.
- Prefer JPEG for large RGB screenshots.
- Try qualities in bounded steps:
0.88,0.82,0.76,0.72. - If still too large, show error instead of making unreadable images.
Payload size estimator
Do not rely only on decoded bytes.
export function estimateStreamJsonPayloadBytes(input: {
text: string;
attachments: AgentAttachmentDraftDto[];
}): number {
const contentBlocks = input.attachments.map(attachment => ({
type: attachment.kind === 'image' ? 'image' : 'document',
source: {
type: 'base64',
media_type: attachment.optimized?.mimeType ?? attachment.mimeType,
data: attachment.optimized?.dataBase64 ?? attachment.dataBase64,
},
}));
return Buffer.byteLength(JSON.stringify({
type: 'user',
message: {
role: 'user',
content: [{ type: 'text', text: input.text }, ...contentBlocks],
},
}), 'utf8');
}
This estimator lives in shared/core if it avoids Node-only APIs, or duplicated as pure helper with TextEncoder for renderer and Buffer.byteLength for main. Prefer pure TextEncoder for cross-process reuse.
Backend validation
The backend must revalidate everything because renderer optimization is not a security boundary.
export function validateAgentAttachmentsForSend(input: {
text: string;
attachments: AgentAttachmentDraftDto[];
runtimeHint: RuntimeAttachmentHint;
}): ValidationResult {
if (input.attachments.length > AGENT_ATTACHMENT_BUDGETS.maxFiles) {
return error('Too many attachments.');
}
const estimatedBytes = estimateStreamJsonPayloadBytes(input);
if (estimatedBytes > AGENT_ATTACHMENT_BUDGETS.maxEstimatedStreamJsonPayloadBytes) {
return error(
`Attachments are too large after optimization (${formatBytes(estimatedBytes)} serialized). ` +
`Remove an image or reduce screenshot size.`,
);
}
return ok();
}
For phase 1, wire this into existing validateAttachments before sendMessageToTeam accepts attachments.
Composer UI behavior
Add a small notice near attachment previews.
Examples:
Screenshot optimized to 1920x1080 JPEG, 612 KB.
Attachments are too large after optimization. Remove one image or use a smaller screenshot.
Animated GIFs are not optimized yet and may be too large for agent delivery.
Do not mention provider-specific capability in Phase 1 unless the target runtime is already known in composer state. The main blocker in Phase 1 is size/budget safety.
Integration points
Existing code to adjust carefully:
src/renderer/utils/attachmentUtils.ts
src/renderer/hooks/useComposerDraft.ts
src/main/ipc/teams.ts
src/main/services/team/TeamProvisioningService.ts
Do not move all logic at once. Add wrappers and leave current API shape compatible.
Edge cases
Multiple high-resolution screenshots
Expected behavior:
- optimize each image;
- if total serialized payload still too large, block send with clear error;
- do not partially send only some images.
Transparent PNG
Expected behavior:
- preserve PNG/alpha;
- if too large, ask user to reduce or confirm future lossy conversion in a later phase;
- do not silently flatten transparency.
Animated GIF
Expected behavior:
- keep original if within budget;
- otherwise block with clear message;
- do not silently first-frame it.
Corrupt image
Expected behavior:
- show
Cannot read image file; - do not pass corrupt base64 to runtime.
Old draft with base64-only attachment
Expected behavior:
- load draft;
- if no optimized variant exists, optimize on send;
- if optimization fails, block send.
Unsupported file type
Expected behavior:
- existing path fallback for local files can remain;
- unsupported binary file is not converted to base64 attachment.
Test plan
Unit
estimateStreamJsonPayloadBytesincludes base64 and JSON overhead.- RGB PNG screenshot converts/resizes to JPEG under budget.
- Small PNG remains unchanged if already safe.
- Alpha PNG does not become JPEG silently.
- Animated GIF is not converted silently.
- Corrupt image returns error.
- Total optimized bytes over budget blocks send.
Renderer
- composer shows optimization notice;
- composer shows too-large error;
- removing an attachment clears budget error;
- old drafts trigger optimization before send.
Main/IPС
- IPC rejects too many attachments;
- IPC rejects payload above serialized budget;
- IPC accepts safe optimized image;
- error messages are user-readable and do not include base64 data.
Suggested focused checks:
pnpm vitest run src/features/agent-attachments/**/*.test.ts test/main/ipc/teams.test.ts test/renderer/components/team/messages/MessageComposer.test.tsx
pnpm typecheck --pretty false
Safety checklist
- No provider runtime path changed.
- No launch/provisioning path changed.
- Text-only messages still use old path.
- Attachments are blocked before send if unsafe.
- Backend validation cannot be bypassed by renderer state.
- No secrets or base64 blobs in diagnostics.
Deep implementation details
Step-by-step implementation sequence
- Add feature contracts and pure budget estimator.
- Add renderer-only
picaImageOptimizerwith no imports from main. - Add backend
ServerAttachmentValidatorthat can validate legacy payloads. - Wire backend validator into existing IPC send path before
TeamProvisioningService.sendMessageToTeam(). - Add composer warnings from renderer optimization state.
- Add tests for estimator and validator.
This order avoids changing provider delivery until validation is proven.
Pure byte estimator
Use a runtime-neutral helper so both renderer and main can compute comparable values.
export function utf8Bytes(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
export function estimateBase64JsonStringBytes(base64: string): number {
// JSON string escaping is normally small for base64, but include quotes.
return utf8Bytes(JSON.stringify(base64));
}
export function estimateClaudeStreamJsonPayloadBytes(input: {
text: string;
attachments: Array<{ mimeType: string; base64: string; kind: 'image' | 'document' }>;
}): number {
const payload = {
type: 'user',
message: {
role: 'user',
content: [
{ type: 'text', text: input.text },
...input.attachments.map(att => ({
type: att.kind === 'image' ? 'image' : 'document',
source: {
type: 'base64',
media_type: att.mimeType,
data: att.base64,
},
})),
],
},
};
return utf8Bytes(JSON.stringify(payload));
}
Avoid using Buffer in shared/renderer code.
Renderer optimizer pseudo-code
export async function prepareImageAttachmentDraft(file: File): Promise<AgentAttachmentDraftDto> {
const originalBase64 = await readFileAsBase64(file);
const metadata = await readImageMetadata(file);
if (metadata.megapixels > AGENT_ATTACHMENT_BUDGETS.maxDecodedMegapixels) {
return errorDraft(file, 'Image resolution is too large to process safely.');
}
const optimized = await optimizeImageForAgent(file, metadata);
const warnings = buildOptimizationWarnings(file, optimized);
return {
id: stableBrowserDraftId(file, originalBase64),
filename: file.name,
mimeType: file.type,
kind: 'image',
originalBytes: file.size,
dataBase64: originalBase64,
width: metadata.width,
height: metadata.height,
optimized,
warnings,
};
}
Pica resize pseudo-code
async function resizeRgbToJpeg(input: ImageBitmap, policy: ImagePolicy) {
const { width, height } = fitWithinLongEdge(input.width, input.height, policy.maxLongEdgePx);
const canvas = new OffscreenCanvas(width, height);
await pica().resize(input, canvas, {
quality: 3,
alpha: false,
unsharpAmount: 80,
unsharpRadius: 0.6,
unsharpThreshold: 2,
});
for (const quality of [0.88, 0.82, 0.76, 0.72]) {
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality });
if (blob.size <= policy.maxOptimizedImageBytes) {
return toVariant(blob, { width, height, quality, strategy: 'resized-and-converted' });
}
}
throw new AttachmentTooLargeError('Image is still too large after resizing.');
}
Fallback if OffscreenCanvas is unavailable:
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
await pica().resize(sourceCanvasOrImage, canvas);
Alpha detection
Do not decode full huge images on main thread just to check alpha. In renderer, after image bitmap decode and drawing to a small sampling canvas:
function likelyHasAlpha(ctx: CanvasRenderingContext2D, width: number, height: number): boolean {
const sampleWidth = Math.min(width, 256);
const sampleHeight = Math.min(height, 256);
const data = ctx.getImageData(0, 0, sampleWidth, sampleHeight).data;
for (let i = 3; i < data.length; i += 4) {
if (data[i] !== 255) return true;
}
return false;
}
If uncertain, prefer PNG and warn rather than silently flattening.
Backend legacy payload normalization
export function normalizeLegacyAttachmentPayload(input: {
data: string;
mimeType: string;
filename?: string;
}): NormalizedLegacyAttachment {
const decodedBytes = estimateDecodedBase64Bytes(input.data);
const kind = classifyMimeType(input.mimeType);
if (decodedBytes > AGENT_ATTACHMENT_BUDGETS.maxOriginalFileBytes) {
throw new AttachmentValidationError({
code: 'attachment_too_large_original',
userMessage: `${input.filename ?? 'Attachment'} is too large.`,
});
}
return {
id: stableAttachmentId(input),
filename: sanitizeAttachmentFilename(input.filename),
mimeType: input.mimeType,
kind,
decodedBytes,
base64: input.data,
};
}
Filename sanitization
Never use attachment filenames directly as filesystem paths.
export function sanitizeAttachmentFilename(name: string | undefined): string {
const fallback = 'attachment';
const base = (name ?? fallback)
.replace(/[\\/\0\r\n\t]/g, '_')
.replace(/^\.+$/, fallback)
.slice(0, 120)
.trim();
return base || fallback;
}
More edge cases
| Edge case | Expected behavior |
|---|---|
| Browser cannot decode HEIC pasted from iPhone | show unsupported image format, suggest PNG/JPEG screenshot |
| User attaches 5 images each individually under budget but combined over budget | block whole send, show combined payload size |
| Image has huge dimensions but tiny compressed bytes | block before decode if dimensions exceed safe megapixels |
File extension says .jpg but MIME says PNG |
trust detected MIME if available, otherwise validate magic bytes in backend later |
| Renderer optimization fails due memory pressure | keep draft but mark send-blocked with retry/remove action |
| User edits message text after optimization | do not recompress image, only recompute serialized payload estimate |
| User removes image | revoke object URLs and release ImageBitmap/canvas refs |
| User switches team while optimization running | cancel or ignore stale optimization result by draft id |
| SVG image | treat as unsupported in v1 unless converted explicitly later |
| WebP | allow if runtime supports, otherwise convert to JPEG/PNG if safe |
Bug-prevention checklist
- All async optimizer results must check current draft id before writing state.
- Object URLs must be revoked on unmount/remove.
- Do not store huge base64 in React error messages.
- Do not include base64 in Zustand dev logs if avoidable.
- Do not throw raw DOMException to user.
- Backend validation must run even if renderer says optimized.
- Tests should include both
data.lengthand decoded byte calculations.
File-by-file implementation plan
1. Contracts
Create:
src/features/agent-attachments/contracts/dto.ts
src/features/agent-attachments/contracts/api.ts
src/features/agent-attachments/contracts/index.ts
Keep contracts serializable. Do not expose classes or functions that require DOM/Node.
Example:
export interface AgentAttachmentBudgetDto {
maxFiles: number;
maxOriginalFileBytes: number;
maxTotalOriginalBytes: number;
maxOptimizedImageBytes: number;
maxEstimatedSerializedBytes: number;
}
2. Core domain
Create:
src/features/agent-attachments/core/domain/AttachmentBudget.ts
src/features/agent-attachments/core/domain/AttachmentMime.ts
src/features/agent-attachments/core/domain/AttachmentErrors.ts
This layer must be pure. No fs, no Electron, no React, no Buffer if it needs renderer reuse.
3. Renderer optimizer
Create:
src/features/agent-attachments/renderer/utils/picaImageOptimizer.ts
This file may import pica, DOM APIs, and browser canvas APIs. It must not import main process modules.
4. Existing renderer integration
Update carefully:
src/renderer/utils/attachmentUtils.ts
src/renderer/hooks/useComposerDraft.ts
Do not replace the whole draft flow. Add a narrow call:
const prepared = await prepareAgentAttachmentDraft(file);
5. Main validation
Create:
src/features/agent-attachments/main/infrastructure/ServerAttachmentValidator.ts
Then call it from existing IPC validation. Do not move all IPC into the new feature in Phase 1 unless it is trivial.
6. UI warnings
Add small rendering components only if existing composer can consume warnings without a broad refactor.
Potential target:
src/renderer/components/team/messages/MessageComposer.tsx
Keep UI changes minimal.
Additional code examples
Domain error class
export class AgentAttachmentError extends Error {
constructor(readonly failure: AttachmentFailure) {
super(failure.userMessage);
this.name = 'AgentAttachmentError';
}
}
export function isAgentAttachmentError(error: unknown): error is AgentAttachmentError {
return error instanceof AgentAttachmentError;
}
MIME classifier
export function classifyAttachmentMimeType(mimeType: string): AgentAttachmentKind {
const normalized = mimeType.toLowerCase();
if (['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(normalized)) return 'image';
if (normalized === 'application/pdf') return 'document';
if (normalized.startsWith('text/')) return 'text';
return 'unsupported';
}
Base64 decoded byte estimator
export function estimateDecodedBase64Bytes(base64: string): number {
const clean = base64.replace(/\s/g, '');
const padding = clean.endsWith('==') ? 2 : clean.endsWith('=') ? 1 : 0;
return Math.floor((clean.length * 3) / 4) - padding;
}
Do not decode huge base64 just to estimate size.
Safe async draft update pattern
const generation = ++attachmentPreparationGenerationRef.current;
const result = await prepareAttachment(file);
if (generation !== attachmentPreparationGenerationRef.current) {
return; // stale result after team/message switch
}
setDraftAttachments(prev => [...prev, result]);
More detailed test cases
Budget estimator table
| Input | Expected |
|---|---|
| no attachments, short text | under budget |
| one 1MB base64 image | serialized estimate greater than decoded bytes |
| five 1MB images | total serialized limit can fail |
| base64 with whitespace | decoded byte estimator handles it |
| empty base64 | invalid attachment error |
Optimizer table
| Input | Expected |
|---|---|
| 320x240 PNG under budget | unchanged or tiny optimized variant |
| 6000x4000 screenshot | resized to max long edge |
| transparent PNG | stays PNG |
| animated GIF | not converted, warning |
| corrupt PNG | error draft |
| WebP | accepted if browser decodes, otherwise unsupported |
UI state table
| Action | Expected |
|---|---|
| attach image then remove | warning disappears, object URL revoked |
| attach too-large image | send disabled with specific reason |
| edit text after attach | only serialized estimate recalculated |
| switch team during optimization | stale result ignored |
| attach unsupported binary | existing path/link fallback or blocked, no base64 blob |
Extra risk controls
- Keep old constants temporarily and map them to new budget constants to avoid conflicting limits.
- If
picaimport increases renderer bundle unexpectedly, keep it lazy-loaded only when image attachment is selected. - If optimization fails unexpectedly, fail closed for attachments but do not affect text-only sends.
- Add analytics/log event only with counts/bytes, never filenames if privacy-sensitive.
Phase 1 exit criteria
Phase 1 is complete only when:
- text-only composer send is unchanged;
- image drafts show optimized size or clear error;
- backend rejects oversized serialized payloads;
- renderer and backend use consistent budget constants;
- no runtime provider delivery code is changed;
- old legacy payload shape still works;
- no base64/data URL appears in UI errors or logs.
Migration seam from existing code
Existing code should be wrapped, not replaced wholesale.
Current likely call chain:
MessageComposer -> useComposerDraft -> attachmentUtils.fileToAttachmentPayload -> teams IPC -> validateAttachments -> sendMessageToTeam
Phase 1 seam:
attachmentUtils.fileToAttachmentPayload
-> prepareAgentAttachmentDraft
-> returns legacy-compatible payload plus metadata/warnings
main validateAttachments
-> ServerAttachmentValidator.validateLegacyPayloads
Do not change sendMessageToTeam signature in Phase 1.
More concrete backend validator
export interface ServerAttachmentValidationInput {
messageText: string;
attachments: Array<{ data: string; mimeType: string; filename?: string }>;
budget?: Partial<AgentAttachmentBudget>;
}
export interface ServerAttachmentValidationOutput {
ok: true;
normalized: NormalizedLegacyAttachment[];
estimatedSerializedBytes: number;
warnings: AttachmentWarning[];
} | {
ok: false;
failure: AttachmentFailure;
};
Usage:
const validation = serverAttachmentValidator.validateLegacyPayloads({
messageText,
attachments,
});
if (!validation.ok) {
throw new Error(validation.failure.userMessage);
}
Validation order
Order matters for predictable user errors.
- attachment count;
- base64 validity;
- decoded bytes per file;
- total decoded bytes;
- MIME support;
- estimated serialized payload bytes;
- warning collection.
Do not compute JSON payload with unbounded decoded buffers.
Renderer optimizer cancellation
export interface AttachmentPreparationJob {
id: string;
cancel(): void;
promise: Promise<AgentAttachmentDraftDto>;
}
If using AbortController:
const controller = new AbortController();
const promise = prepareAgentAttachmentDraft(file, { signal: controller.signal });
return { id, cancel: () => controller.abort(), promise };
If pica cannot fully abort, still ignore stale results by generation id.
Memory safety
Large images can pressure renderer memory. Keep rules strict.
- Reject dimensions above max megapixels before full resize when possible.
- Release
ImageBitmapwithimageBitmap.close()after resize. - Revoke object URLs.
- Avoid storing duplicate base64 strings if optimized variant replaces original for send.
- Do not put raw base64 in React component props beyond draft state if avoidable.
Phase 1 bug traps and prevention
| Trap | Prevention |
|---|---|
| Backend accepts unsafe payload because renderer already warned | backend validator is mandatory |
| UI warning says optimized but send uses original huge base64 | send path chooses optimized variant or blocks |
| GIF silently becomes static image | explicit GIF policy, test it |
| transparent PNG becomes white/black JPEG | alpha test and PNG preservation |
| stale optimization adds attachment to wrong team draft | generation id check |
| file name path traversal appears in future artifact path | sanitize filenames now |
| tests rely on browser-only APIs in Node | keep optimizer tests in jsdom/browser-compatible environment or mock pica |
Extra test skeletons
describe('ServerAttachmentValidator', () => {
it('rejects payload by serialized size even when decoded bytes are under old limit', () => {
const image = makeBase64OfSize(6_000_000);
const result = validator.validateLegacyPayloads({
messageText: 'x',
attachments: [{ data: image, mimeType: 'image/png', filename: 'large.png' }],
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.failure.code).toBe('attachment_serialized_payload_too_large');
});
});
describe('picaImageOptimizer', () => {
it('does not flatten transparent PNG to JPEG', async () => {
const result = await optimizeImageForAgentAttachment(transparentPngFile);
expect(result.mimeType).toBe('image/png');
});
});
Detailed Implementation Checklist
Step 1 - Add pure domain module
Create a feature-local domain module with no Electron, DOM, or filesystem dependency.
Suggested files:
src/features/agent-attachments/shared/types.ts
src/features/agent-attachments/shared/budgets.ts
src/features/agent-attachments/shared/capabilities.ts
src/features/agent-attachments/shared/validation.ts
src/features/agent-attachments/shared/index.ts
The first implementation should be intentionally boring:
export function classifyAttachmentMime(mimeType: string): AttachmentKind {
if (mimeType === 'image/png') return 'image';
if (mimeType === 'image/jpeg') return 'image';
if (mimeType === 'image/webp') return 'image';
if (mimeType === 'application/pdf') return 'file';
if (mimeType.startsWith('text/')) return 'file';
return 'unsupported';
}
Avoid clever extension guessing in v1. Extension fallback can be a later hardening step if real users need it.
Step 2 - Add renderer optimizer behind composer-only call site
Keep optimization in renderer because browser APIs are good at image decode and pica is browser-oriented.
export interface OptimizeImageForAgentInput {
file: File;
budget: ImageOptimizationBudget;
}
export interface OptimizeImageForAgentResult {
original: BrowserAttachmentArtifact;
optimized: BrowserAttachmentArtifact;
warnings: string[];
}
Implementation order:
- Decode image dimensions with
createImageBitmapwhere available. - Compute target dimensions from total batch budget and per-image max dimension.
- Use
pica.resizeinto canvas. - Encode JPEG/PNG based on input and transparency.
- Return warnings, not thrown errors, for non-fatal resize degradation.
Step 3 - Add backend validation
Main process must re-check everything received from renderer.
export function validateNormalizedAttachmentForSend(input: {
attachment: AgentAttachmentPayload;
target: ProviderTarget;
}): AttachmentValidationResult {
const capability = resolveAgentAttachmentCapability(input.target);
const sizeResult = validateAttachmentBudget(input.attachment, capability);
if (!sizeResult.ok) return sizeResult;
return { ok: true, warnings: [] };
}
Backend validation should never trust:
- MIME type from browser alone.
- File name extension.
- Renderer-provided optimized dimensions.
- Renderer-provided
supported: truecapability result.
Step 4 - Wire UI warnings without changing delivery
Before provider adapters are implemented, UI can show warnings but must not pretend unsupported providers work.
Safe UX:
- Allow attach, show preview.
- On send, block if selected target cannot receive the attachment yet.
- Explain which phase/provider support is missing.
- Keep text-only send untouched.
Phase 1 Exit Criteria
Phase 1 is complete when:
- Text-only composer behavior is unchanged.
- Image preview still works for small images.
- Large image preview shows optimized size and warnings.
- Unsupported file type produces a clear local validation error.
- Backend rejects forged oversized attachment metadata.
- No provider delivery path receives new attachment data yet unless explicitly wired in later phases.
Edge Case Matrix
| Case | Expected behavior |
|---|---|
| Animated GIF | Treat as unsupported for image delivery in v1, or convert first frame only with explicit warning if implemented. |
| Transparent PNG | Prefer PNG if small, JPEG only if transparency is absent or user accepts flattened background. |
| Huge panorama | Downscale by max edge and total pixel budget. |
| Tiny image | Do not upscale. |
| Corrupt image | Show decode failed, do not send attachment. |
| HEIC on macOS | Do not promise support unless decode pipeline is explicitly tested. |
| Clipboard image with no filename | Generate stable display name like clipboard-image.png. |
| Same image attached twice | Keep two attachment ids, do not dedupe content silently. |
| Multiple images exceed total budget | Optimize all proportionally, then block if still over cap. |
| User switches target after attaching | Recompute warnings for new provider/model before send. |
Common Bug Patterns to Avoid
- Storing base64 in message JSON. This can bloat inboxes and break process stdin.
- Mutating the original attachment when optimization runs.
- Letting renderer decide final support without backend validation.
- Showing “sent” when attachment was dropped from provider payload.
- Collapsing all failures into generic “delivery failed”.
- Running optimization in Electron main with a new native dependency right before release.
- Changing current file attachment behavior for text/PDF before image flow is stable.
Focused Test Examples
describe('attachment budgets', () => {
it('blocks a single optimized image over hard cap', () => {
const result = validateAttachmentBudget(
fakeImage({ sizeBytes: 12 * 1024 * 1024 }),
codexVisionCapability()
);
expect(result.ok).toBe(false);
expect(result.code).toBe('attachment_too_large');
});
it('does not upscale small images', () => {
const plan = planImageResize({ width: 320, height: 200 }, { maxEdge: 1600 });
expect(plan.width).toBe(320);
expect(plan.height).toBe(200);
});
});
Manual QA Script
Use a clean dev profile and verify:
- Attach a 200 KB PNG screenshot - preview appears, no warning.
- Attach a 12 MB PNG screenshot - preview appears, optimized size shown.
- Attach three screenshots - total budget warning is deterministic.
- Attach a
.txtfile - current file behavior remains unchanged. - Attach a corrupt image renamed
.png- decode error appears. - Switch target from Claude to non-vision OpenCode model - unsupported warning appears.
- Remove attachment - warnings clear.
Rollback Plan
If Phase 1 causes UI instability:
- Keep domain files, but remove composer call to optimizer.
- Leave backend validation unused.
- No persisted migration is needed if message schema was not changed.
Implementation Safeguards
Keep Phase 1 read-only for provider delivery
Phase 1 should not change how messages are sent to agents. It should only add normalization, previews, warnings, and backend validation primitives.
Safe call sites:
- Composer attachment selection.
- Composer warning rendering.
- Draft serialization of normalized metadata.
- Backend validation helper tests.
Unsafe call sites for Phase 1:
- Claude delivery transport.
- Codex runtime invocation.
- OpenCode prompt ledger.
- Team launch/provisioning.
- Member runtime liveness.
Suggested internal state machine
type AttachmentPrepareState =
| { status: 'idle' }
| { status: 'decoding'; attachmentId: string }
| { status: 'optimizing'; attachmentId: string; progress?: number }
| { status: 'ready'; attachment: AgentAttachmentPayload }
| { status: 'blocked'; attachmentId: string; reason: AttachmentDeliveryFailureCode }
| { status: 'failed'; attachmentId: string; error: string };
UI rule:
blockedmeans user can fix/remove/change target.failedmeans local processing failed.- Neither state should enqueue runtime delivery.
Budget planning algorithm
Use deterministic, simple budget allocation. Do not optimize each image independently to the max, because a batch can still exceed total budget.
export function allocateImageBudgets(input: {
images: ImageCandidate[];
totalMaxBytes: number;
perImageMaxBytes: number;
}): ImageBudgetAllocation[] {
const perImageFairShare = Math.floor(input.totalMaxBytes / Math.max(1, input.images.length));
const targetBytes = Math.min(input.perImageMaxBytes, perImageFairShare);
return input.images.map((image) => ({ imageId: image.id, targetBytes }));
}
If the optimized output is still above target:
- Reduce dimensions down to min edge threshold.
- Reduce JPEG quality down to minimum acceptable quality.
- If still too large, block and explain.
Do not loop indefinitely.
Cancellation edge cases
| User action | Safe behavior |
|---|---|
| Removes image during optimization | Cancel or ignore result by generation id. |
| Adds image then switches team | Cancel or detach optimization result from old draft. |
| Switches provider/model | Recompute capability warnings without re-encoding if artifact is reusable. |
| Sends while optimization pending | Disable send or show “attachment still processing”. |
| Closes app mid-optimization | No partially written artifact should be treated as ready. |
Generation id pattern
let prepareGeneration = 0;
async function prepareAttachments(files: File[]) {
const generation = ++prepareGeneration;
const result = await optimize(files);
if (generation !== prepareGeneration) return;
setPreparedAttachments(result);
}
This avoids stale async results restoring removed attachments.
Phase 1 PR checklist
- No provider delivery path changed.
- No launch/runtime tests need snapshot updates.
- Text-only message send still works manually.
- Image preview removal cannot leave hidden payload in draft.
- Backend validation is stricter than renderer validation.
- All user-visible errors are actionable.
Failure Injection Tests for Phase 1
Add tests that intentionally simulate bad renderer or corrupted local state.
describe('attachment backend validation hardening', () => {
it('rejects renderer supplied absolute paths outside managed storage', () => {
const result = validateAttachmentStorageReference({
artifactId: 'att_1',
path: '/Users/belief/.ssh/id_rsa',
});
expect(result.ok).toBe(false);
expect(result.code).toBe('attachment_artifact_path_unsafe');
});
it('rejects metadata that claims small size but file is large', async () => {
const result = await validateAttachmentArtifactOnDisk({
expectedSizeBytes: 100,
actualPath: fixturePath('large-image.png'),
maxBytes: 1024,
});
expect(result.ok).toBe(false);
expect(result.code).toBe('attachment_too_large');
});
});
Browser and Platform Edge Cases
| Edge case | Safe implementation note |
|---|---|
| Safari/WebKit image decode differences | Use feature detection, not browser assumptions. |
| macOS clipboard TIFF/HEIC | Treat unsupported until explicitly converted/tested. |
| EXIF orientation | Prefer decode path that respects orientation or normalize orientation during canvas draw. |
| Color profiles | Accept minor color shift for screenshots, do not promise color-managed output. |
| Canvas memory pressure | Bound megapixels before drawing. |
| Pica worker failure | Fall back to canvas resize with warning, or block with clear error. |
| Transparent UI screenshot | Avoid JPEG flattening unless transparency absent or user warning is shown. |
| Very long screenshot | Limit max edge and total pixels to prevent huge canvas allocation. |
Memory Safety Budget
Renderer memory is the main Phase 1 risk.
Recommended starting limits:
export const ATTACHMENT_IMAGE_LIMITS = {
maxInputBytes: 20 * 1024 * 1024,
maxInputPixels: 32_000_000,
maxOutputBytesPerImage: 4 * 1024 * 1024,
maxOutputBytesTotal: 8 * 1024 * 1024,
maxOutputEdge: 2400,
minJpegQuality: 0.72,
defaultJpegQuality: 0.86,
};
These are release-safe starting values, not final product limits. They should be tuned after real usage.
Phase 1 Stop Conditions
Stop implementation and reassess if any of these happen:
- Optimized images are stored as base64 in persisted message JSON.
- Main process needs a new native image dependency.
- Existing file attachments stop sending.
- Composer draft state becomes provider-specific.
- Text-only messages require schema migration.
File-Level Implementation Plan
Suggested new files:
src/features/agent-attachments/shared/types.ts
src/features/agent-attachments/shared/budgets.ts
src/features/agent-attachments/shared/capabilities.ts
src/features/agent-attachments/shared/validation.ts
src/features/agent-attachments/shared/storageIds.ts
src/features/agent-attachments/renderer/optimizeImageForAgent.ts
src/features/agent-attachments/renderer/usePreparedAttachments.ts
src/features/agent-attachments/main/validateAttachmentArtifact.ts
src/features/agent-attachments/main/attachmentArtifactStore.ts
Suggested tests:
test/features/agent-attachments/budgets.test.ts
test/features/agent-attachments/capabilities.test.ts
test/features/agent-attachments/validation.test.ts
test/features/agent-attachments/attachmentArtifactStore.test.ts
Storage id validation
const SAFE_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,120}$/;
export function assertSafeAttachmentStorageId(name: string, value: string): void {
if (!SAFE_ID_RE.test(value)) {
throw new Error(`Invalid ${name}`);
}
}
Use this for teamName, messageId, and attachmentId before building artifact paths.
Managed path resolver
export function resolveAttachmentArtifactPath(input: {
teamRoot: string;
teamName: string;
messageId: string;
attachmentId: string;
fileName: 'original.png' | 'original.jpg' | 'optimized.png' | 'optimized.jpg' | 'thumb.jpg' | 'meta.json';
}): string {
assertSafeAttachmentStorageId('teamName', input.teamName);
assertSafeAttachmentStorageId('messageId', input.messageId);
assertSafeAttachmentStorageId('attachmentId', input.attachmentId);
const base = path.resolve(input.teamRoot, input.teamName, 'attachments', 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;
}
Renderer optimizer guardrails
export async function optimizeImageForAgent(input: OptimizeImageForAgentInput): Promise<OptimizeImageForAgentResult> {
const bitmap = await createImageBitmap(input.file);
assertPixelBudget(bitmap.width, bitmap.height, input.budget.maxInputPixels);
const target = planResizeDimensions({
width: bitmap.width,
height: bitmap.height,
maxEdge: input.budget.maxOutputEdge,
});
const canvas = document.createElement('canvas');
canvas.width = target.width;
canvas.height = target.height;
await resizeWithPicaOrFallback(bitmap, canvas);
const blob = await encodeCanvasForProvider(canvas, input.budget);
return buildOptimizationResult(input.file, blob, target);
}
Do not implement infinite quality-search loops. Use a small bounded set of quality attempts like [0.86, 0.8, 0.74, 0.72].
Phase 1 exact acceptance criteria
- Backend path resolver rejects traversal in tests.
- Budget tests cover single, multiple, and oversized images.
- Renderer hook ignores stale optimization result after removal.
- Composer cannot send while any attachment is
optimizing. - Existing text-only composer tests do not need behavioral changes.
- No provider delivery module imports renderer optimizer.
Phase 1 Deep Review Addendum
Exact UI states
Composer should expose these states clearly:
| State | Send allowed | Copy |
|---|---|---|
| No attachments | yes | normal text-only behavior |
| Attachments optimizing | no | Preparing image... |
| Attachments ready and supported | yes | optional optimized size summary |
| Attachment too large after optimization | no | Image is too large after optimization. Remove it or use a smaller image. |
| Target model unsupported | no | Selected model does not support image attachments. |
| Decode failed | no | Could not read this image. |
| Artifact persistence failed | no | Could not prepare attachment for sending. |
Accessibility and UX guardrails
- Warning text should be readable without relying on color.
- Remove button must remain available for blocked attachments.
- If multiple attachments have warnings, show per-attachment reason and aggregate reason near send.
- Do not hide text draft when attachment fails.
- If user removes the blocked attachment, send button should recover immediately.
Persistence safety
When user sends a message with attachments:
- Generate message id first.
- Persist original/optimized artifacts under message id.
- Persist message record referencing attachment ids.
- Begin provider delivery.
Do not begin provider delivery before message record and artifacts are durably written.
This avoids a retry state where ledger knows about a message but artifacts are missing because write happened later.
Phase 1 anti-regression tests
it('does not serialize image bytes into message json', () => {
const message = buildMessageRecordWithAttachment(fakeAttachment());
expect(JSON.stringify(message)).not.toContain('base64');
expect(JSON.stringify(message)).not.toContain('data:image');
});
it('restores send button after removing blocked attachment', () => {
const state = reducer(blockedAttachmentState(), removeAttachment('att_1'));
expect(selectCanSend(state)).toBe(true);
});
Phase 1 implementation order
Implement in this order to reduce bug risk:
- Pure budget/capability tests.
- Safe id/path helpers.
- Artifact store tests.
- Renderer optimization hook without composer integration.
- Composer preview/warning integration.
- Send blocking.
- Backend validation on send.
If a later step fails, earlier pure modules remain useful and low-risk.
Phase 1 Implementation Contract Addendum
Exact domain types
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 ImageOptimizationBudget {
maxInputBytes: number;
maxInputPixels: number;
maxOutputBytesPerImage: number;
maxOutputBytesTotal: number;
maxOutputEdge: number;
jpegQualityAttempts: readonly number[];
}
Keep these in shared pure code. Renderer and main may import types and pure validators, but renderer-specific optimizer code must not be imported by main.
Quality strategy
Use a deterministic quality strategy instead of adaptive unbounded loops.
const JPEG_QUALITY_ATTEMPTS = [0.86, 0.82, 0.78, 0.74, 0.72] as const;
for (const quality of JPEG_QUALITY_ATTEMPTS) {
const blob = await encodeCanvas(canvas, 'image/jpeg', quality);
if (blob.size <= targetBytes) return blob;
}
throw new AgentAttachmentError(
'attachment_too_large',
'Image is too large after optimization. Remove it or use a smaller image.'
);
PNG strategy:
- Keep PNG for small screenshots and transparency.
- Re-encode to JPEG only when transparency is absent and size requires it.
- If transparency exists and PNG remains too large, block with clear reason instead of silently flattening unless product explicitly accepts flattening.
UI copy table
| Code | Copy |
|---|---|
attachment_too_large |
Image is too large after optimization. Remove it or use a smaller image. |
attachment_type_unsupported |
This file type is not supported for agent image delivery. |
attachment_model_unsupported |
Selected model does not support image attachments. Switch model or remove the image. |
attachment_optimization_failed |
Could not prepare this image for sending. |
attachment_artifact_missing |
Prepared image file is missing. Remove and attach the image again. |
Copy should be short in UI. Detailed diagnostics can go into copy diagnostics/logs.
Batch behavior
Multiple images should preserve user order.
export function sortAttachmentsForDelivery(attachments: AgentAttachmentPayload[]): AgentAttachmentPayload[] {
return [...attachments].sort((a, b) => a.order - b.order);
}
Do not sort by size or file name because the prompt may refer to “first image” and “second image”.
Draft persistence edge cases
- Draft can reference attachment ids before final message id exists.
- On send, draft attachment ids should be reparented or copied into message artifact directory.
- If reparenting fails, send should stop before provider delivery.
- Removing attachment from draft should remove draft artifact eventually, but not synchronously block UI.
Implementation Readiness Addendum
Definition of Ready for Phase 1
Before coding Phase 1:
- Confirm exact composer components that own attachment state.
- Confirm where message ids are generated for user sends.
- Confirm where existing attachments are persisted today.
- Confirm whether current image previews store bytes, paths, or blobs.
- Confirm no provider delivery changes are included in the Phase 1 PR.
Exact reducer-style behavior
type ComposerAttachmentAction =
| { type: 'attachment_added'; file: File; draftAttachmentId: string }
| { type: 'attachment_prepare_started'; draftAttachmentId: string; generation: number }
| { type: 'attachment_prepare_succeeded'; draftAttachmentId: string; generation: number; payload: AgentAttachmentPayload }
| { type: 'attachment_prepare_failed'; draftAttachmentId: string; generation: number; error: AgentAttachmentErrorJson }
| { type: 'attachment_removed'; draftAttachmentId: string }
| { type: 'target_changed'; target: ProviderTarget };
Reducer rule:
- Ignore
attachment_prepare_succeededif generation is stale. - Ignore prepare results for removed attachment ids.
- Recompute capability warnings on
target_changedwithout reprocessing image bytes. - Do not clear text draft when attachment fails.
Backend artifact write order
async function persistMessageAttachments(input: PersistMessageAttachmentsInput): Promise<PersistedAttachmentBundle> {
const messageDir = resolveMessageAttachmentDir(input.teamName, input.messageId);
await fs.mkdir(messageDir, { recursive: true });
const persisted: PersistedAttachment[] = [];
for (const attachment of input.attachments) {
const paths = resolveAttachmentPaths(input.teamName, input.messageId, attachment.id);
await writeFileAtomic(paths.original, attachment.originalBytes);
if (attachment.optimizedBytes) await writeFileAtomic(paths.optimized, attachment.optimizedBytes);
await writeFileAtomic(paths.meta, JSON.stringify(buildMeta(attachment), null, 2));
persisted.push(toPersistedAttachment(paths, attachment));
}
return { attachments: persisted };
}
Use atomic writes for metadata and artifacts where practical. A partially written optimized image must not be treated as ready.
Atomic write requirement
async function writeFileAtomic(path: string, bytes: Buffer | string): Promise<void> {
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(tmp, bytes);
await fs.rename(tmp, path);
}
If write fails, cleanup tmp best-effort and return typed error.
Phase 1 additional edge cases
| Edge case | Expected behavior |
|---|---|
| Disk full during artifact write | Send blocked, text draft preserved, typed error shown. |
| App crashes after artifacts written before message record | Orphan artifacts may remain, later GC can clean. No message corruption. |
| App crashes after message record before provider delivery | Message exists with attachments and can be retried later. |
| Thumbnail write fails | Do not block delivery if original/optimized artifact is valid. Show preview fallback. |
| Original write succeeds, optimized write fails | Block image delivery unless original fits provider budget. |
Final Phase 1 Acceptance Specs
Spec 1 - small supported image
Given a user attaches a 200 KB PNG screenshot
And the selected target supports images
When the composer prepares the attachment
Then the preview is shown
And the send button remains enabled
And the persisted message references artifact ids
And the message JSON does not contain base64
Spec 2 - oversized image optimized successfully
Given a user attaches a 12 MB PNG screenshot
When optimization completes below provider budget
Then the user sees that the image was optimized
And the send button is enabled
And the optimized artifact is used for provider delivery later
And the original artifact remains available for retry/regeneration
Spec 3 - oversized image still too large
Given a user attaches a huge image
When bounded optimization cannot bring it below budget
Then the send button is disabled
And the text draft remains intact
And the user can remove the image
And no provider delivery is attempted
Spec 4 - stale optimization result
Given a user attaches an image
And removes it while optimization is running
When the optimization promise resolves
Then the removed attachment is not restored
And the send state reflects the current draft only
Phase 1 exact PR contract
The Phase 1 PR is acceptable only if:
- It adds shared attachment domain types.
- It adds budget/capability/validation tests.
- It adds safe managed artifact path/id helpers.
- It adds renderer optimization or a clearly documented placeholder if split.
- It does not change provider delivery behavior.
- It does not import provider runtime code into renderer optimizer.
- It does not import attachment feature into launch/provisioning.
Phase 1 likely review findings to prevent
| Finding | Prevention |
|---|---|
| Message JSON contains base64 | persist artifact ids only |
| Send starts before artifact write | enforce write-before-delivery order |
| Draft removal race restores attachment | generation id guard |
| Backend trusts renderer size | stat artifact on disk |
| Unsupported model warning only in UI | backend validation also blocks |
Phase 1 Pre-Mortem and Extra Safeguards
Likely Phase 1 mistakes
| Mistake | Concrete prevention |
|---|---|
| Optimizer result races with removed attachment | Generation id guard in reducer/hook. |
| Backend trusts renderer MIME | Backend validates by allowlist and artifact metadata. |
| Draft artifacts leak forever | Mark draft artifacts and add later GC policy. |
| UI blocks text-only send after image error removed | Selector tests for canSend. |
| Multiple images reorder | Preserve user-provided order field. |
| Image-only send unclear | Product decision before coding: allow image-only with default prompt or require text. |
Image-only message decision
Top options:
-
Require text with image - 🎯 8.5 🛡️ 9 🧠 2, примерно
20-50строк.Safest release behavior. It avoids confusing empty prompt behavior across providers.
-
Allow image-only with generated prompt - 🎯 7 🛡️ 7.5 🧠 4, примерно
60-120строк.Useful UX, but generated prompts can surprise users and differ by action mode.
-
Allow image-only raw - 🎯 5.5 🛡️ 5 🧠 2, примерно
20-40строк.Some providers may accept it, but behavior is inconsistent.
Recommendation: start with option 1 for release.
Pica wrapper contract
export interface ImageResizeEngine {
resize(input: {
source: ImageBitmap;
targetWidth: number;
targetHeight: number;
}): Promise<HTMLCanvasElement>;
}
Reason:
- Keeps
picabehind a tiny interface. - Allows tests with fake resize engine.
- Allows fallback or replacement without changing composer state.
Quality acceptance rules
- UI screenshots should remain legible at common zoom levels after resize.
- Do not reduce JPEG quality below configured floor.
- If text in screenshot becomes unreadable in manual QA, increase budget before release.
- If multiple images exceed total budget, prefer blocking over making all unreadable.
Phase 1 contract tests to add before UI wiring
it('preserves attachment order for delivery', () => {
const sorted = sortAttachmentsForDelivery([
fakeImageAttachment({ id: 'b', order: 2 }),
fakeImageAttachment({ id: 'a', order: 1 }),
]);
expect(sorted.map((item) => item.id)).toEqual(['a', 'b']);
});
it('does not allow send while attachment is optimizing', () => {
expect(selectCanSend(fakeDraft({ attachmentState: 'optimizing' }))).toBe(false);
});