feat(agent): add authenticated durable uploads to backend and web

This commit is contained in:
2026-10-14 17:25:52 -07:00
parent 4cbab487d0
commit 8b125c13a5
16 changed files with 838 additions and 54 deletions
+55 -3
View File
@@ -606,7 +606,7 @@ messages.
### `POST /v1/agent/messages`
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
Content must contain non-whitespace text and is limited to 40,000 characters;
Content is limited to 40,000 characters; it must contain non-whitespace text unless attachment IDs are included;
`requestId` is required (1–256 characters). Preserve the ID when retrying uncertain
network results. The proxy fixes `channel: "sybil"`, `whenBusy: "followUp"`, and
`wait: false`.
@@ -614,9 +614,9 @@ network results. The proxy fixes `channel: "sybil"`, `whenBusy: "followUp"`, and
Returns the agent's `202 Accepted` receipt, including `id` (submission ID), `status`, and
`messageId` such as `inbound:sybil:client-generated-uuid`. This is admission, not
completion: watch the room for answers, including unsolicited later replies.
Multiple requests can be accepted while the agent works. Attachments are not yet supported.
Multiple requests can be accepted while the agent works. Optional `attachments: [{"id":"att_…"}]` accepts uploaded files as specified below; no client paths or inline file bytes are accepted.
The proxy verifies successful receipts contain a positive submission ID, a known
status (`queued`, `placed`, `done`, or `unanswered`), and the expected `messageId`.
status (`queued`, `placed`, `done`, or `unanswered`), and the expected `messageId`. Attached sends also require matching ordered `attachmentIds` in the receipt.
Invalid receipts return 502; delivery remains uncertain, so retry using the original
`requestId` to avoid creating a duplicate message.
@@ -632,3 +632,55 @@ after showing the corresponding history; poll it to reconcile unread indicators
across clients. Agent REST responses use `Cache-Control: no-store`.
See the persistent Agent WebSocket section in `streaming-chat.md` for live updates.
### Agent attachments (v1)
`POST /v1/agent/attachments`: multipart/form-data with exactly one `file` part (original UTF-8 filename in Content-Disposition) and one `uploadId` field (client-generated UUID, stable across retries). Either part order is accepted. Request limit 8 MiB + 64 KiB. 201 new / 200 identical retry:
```json
{"attachment":{"id":"att_12345678-1234-4234-8234-123456789abc","kind":"text","filename":"notes.txt","mimeType":"text/plain","sizeBytes":12,"sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","workspacePath":"/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt"}}
```
`workspacePath` is OPTIONAL for clients, canonical absolute runtime path when supplied. Never a host path, URL, or request parameter. Descriptors must retain filename/mimeType/sizeBytes/sha256. `kind` is `image`, `text`, or `file`. IDs are opaque. UUID retries must match normalized filename, effective MIME type and bytes, otherwise 409 `idempotency_conflict`. Filename is NFC normalized, 1–255 UTF-8 bytes, no slash/backslash/control characters, not `.`/`..`. A leading dot becomes `_` in the on-disk basename only; display filename remains unchanged.
`POST /v1/agent/messages`:
```json
{"content":"Read these notes","requestId":"client-stable-id","attachments":[{"id":"att_12345678-1234-4234-8234-123456789abc"}]}
```
`content` is required, <=40,000 characters; empty/whitespace is allowed with attachments. `requestId` is 1–256 characters and must survive retries. Omit attachments for text-only requests. At most 8 distinct references, 32 MiB total original bytes. References contain ONLY `id`; filenames, paths, URLs, base64 and forged workspacePath are rejected. Root delivery uses channel sybil, followUp and wait=false. 202 receipt:
```json
{"id":123,"status":"queued","messageId":"inbound:sybil:client-stable-id","attachmentIds":["att_12345678-1234-4234-8234-123456789abc"]}
```
Receipt status also permits placed/done/unanswered. `attachmentIds` is present for attached input, in request order, and MUST be checked before treating delivery as acknowledged. Caption + ordered IDs are durably bound to requestId; conflicting replay 409. Native admission atomically stores the input payload, binding, quota charge and worker eligibility; a competing text-only input cannot falsely acknowledge attachment IDs. Network errors may be retried with the same request/upload IDs. Never auto-create a fresh ID on ambiguous failure.
History, replay and queued activity use the existing public message schema with `metadata.agentAttachments` containing these descriptors. `content` is the original caption, or `Attached: notes.txt.` for a file-only message. No model-only manifest, text preview or base64 is inserted into public message content. Ordinary chat's `metadata.attachments` schema is unchanged. Raw tool rows stay in the protocol; client transcript filters stay unchanged.
`GET /v1/agent/attachments/:id/content`: authenticated original bytes, Content-Length, Content-Disposition attachment (safe ASCII filename and UTF-8 filename*), Cache-Control private/no-store and nosniff. No URLs containing tokens, public static mount, redirects or inline serving. Browser previews use authenticated fetch + revocable blob URL. Native downloads hold one of four slots through response finish/close/error, with a 30-second outbound timeout and disconnect cleanup. Raster types retain their validated MIME type; text downloads use text/plain; other files application/octet-stream. Clients can display/copy workspacePath as text.
### Attachment capability and errors
Optional `GET /v1/agent` field:
```json
{"capabilities":{"attachments":{"version":1,"maxCount":8,"maxTotalBytes":33554432,"maxImageBytes":6291456,"maxFileBytes":8388608,"imageMimeTypes":["image/png","image/jpeg","image/gif","image/webp"],"maxImagePixels":50000000,"maxImageDimension":16384,"quotaBytes":1073741824,"usedBytes":0,"remainingBytes":1073741824,"maxConcurrentUploads":2,"maxConcurrentDecodes":1}}}
```
Quota values are a snapshot, not a reservation. Absence disables attachment selection and sending. Backend rejects attachments against an old/unsafe upstream rather than allowing dropped references. Sybil also requires ADMIN_TOKEN for attachments; text-only open development behavior remains compatible.
Errors: `{ "error": true, "message": "...", "statusCode": 507, "code": "attachment_quota_exceeded", "quotaBytes": 1073741824, "remainingBytes": 1200, "requiredBytes": 20000 }`. Quota numbers are included for 507; no host filesystem information. 400 invalid_upload/invalid_attachment; 401 authentication; 404 attachment_not_found; 409 idempotency_conflict; 413 attachment_limit_exceeded; 415 unsupported_media_type; 422 invalid_image/image_model_unsupported; 429 attachments_busy with Retry-After: 2; 503 attachments_unavailable; 507 attachment_quota_exceeded; 502 unavailable/invalid upstream at proxy. Clients show message and retain retry identity. Remove from composer does not delete completed durable uploads.
### Runtime access and release requirements
The runtime resolves IDs to persistent `WORKSPACE_DIR/uploads/<id>/<safe-original-basename>` files (default `/data/workspace/uploads/...`), backed by the existing `/data` mount. Images are native model image blocks, never remote links: supported PNG/JPEG/GIF/WebP originals are at most 6 MiB and are prepared as a bounded static first frame (orientation corrected, at most 2000×2000 and 4.5 MiB base64, further limited by the actual model). Reject unsupported image models; no arbitrary document/file provider blocks. Other files are at most 8 MiB and are available through actual workspace read/bash tools. Valid recognized UTF-8 text receives a bounded preview of 16,000 Unicode characters per file / 64,000 per input. PDF/archive/binary files are paths plus metadata. Original hash, size and confinement are validated during resolution. Root and workers share the same workspace; explicit `subagent` spawn/send `attachmentIds` persist in durable Reporter tasks and use the same resolver. Preparation saturation retries through a durable checkpoint after two seconds using the same Reporter request ID. Permanent admission failures, including quota exhaustion, reach the parent through the durable worker-report path. Uploads never auto-execute.
Global `SYBIL_AGENT_ATTACHMENT_QUOTA_BYTES` defaults to 1 GiB. Concurrent reservations cover originals, static renditions, staging, metadata and conservative attachment input copies. Quota is conservative accounting, not free disk space. No complete upload expiry or delete endpoint is enabled. Removed composer files stay stored; crashed/uncertain reservations stay charged across restart until deliberate operator reconciliation. Ordinary authorized workspace tool writes and unrelated conversation history are outside attachment-ingestion accounting. Runtime uploads default to two concurrently (`SYBIL_AGENT_ATTACHMENT_UPLOADS`, max8), image decodes one (`SYBIL_AGENT_ATTACHMENT_DECODES`, max4); bounded input preparation and proxy transfers return429 when saturated. Disconnected proxy transfers cancel upstream work and release slots.
Release requires native `SYBIL_AGENT_AUTH_MODE=token` with nonempty `SYBIL_AGENT_API_KEY`, plus backend `ADMIN_TOKEN` and matching server-side `SYBIL_AGENT_API_KEY` / `SYBIL_AGENT_BASE_URL`. Native open mode cannot advertise/store/download attachments. A runtime containing attachment records/files refuses open startup; raw/model APIs and sockets are protected in token mode. An open Sybil proxy also refuses access to an attachment-capable native runtime, rather than exposing its history through a private server credential. Explicit text-only open development remains supported with an open/legacy runtime lacking attachment capability. Native keys never enter browser/iOS payloads. No insecure development opt-in is enabled.
The runtime source is built into its container: rebuild it with pinned busboy/sharp dependencies for the target Node/Linux platform before release. The deployed open auth mode must be changed separately; no deployed config or mount is modified by this implementation. Backend and web must be upgraded with runtime capability/auth configured before clients advertise uploads.
Browser drafts retain upload IDs and completed descriptors across pane changes and reload. Incomplete uploads require reselection of the original filename/type/size and, once network delivery has begun, matching SHA-256 bytes; retries reuse uploadId. Sending persists the attachment-bearing outbox before clearing the draft. Message confirmation requires the same ordered attachment IDs in receipts and canonical echoes. Mismatched echoes must retain local attachments and delivery errors; they are not acknowledgement of a file submission.
+37
View File
@@ -335,3 +335,40 @@ cursor. Missing agent configuration closes with 4503.
The proxy closes upstream sockets when browsers leave or Sybil shuts down.
History remains durable in Sybil Agent. Shared read position is separate from delivery:
update `GET`/`PUT /v1/agent/read-state` only when messages have actually been viewed.
### Agent attachment history, queued activity and replay
Uploads and attached sends use REST, as specified in [rest.md](./rest.md#agent-attachments-v1). WebSocket authentication, event types, stable message IDs, cursor semantics and raw tool rows are unchanged. Capability discovery is optional `GET /v1/agent` → `capabilities.attachments.version: 1`; absence disables new attachment sends. Upgrade/authenticate the native runtime and backend before advertising it.
Both `messages.messages[]` and `activity.activity.queuedMessages[]` carry attachment descriptors under **`metadata.agentAttachments`**, distinct from ordinary chat's inline `metadata.attachments`:
```json
{
"id": "inbound:sybil:client-stable-id",
"role": "user",
"content": "Read these notes",
"createdAt": "2026-10-14T18:00:00.000Z",
"metadata": {
"kind": "message",
"channel": "sybil",
"status": "queued",
"agentAttachments": [{
"id": "att_12345678-1234-4234-8234-123456789abc",
"kind": "text",
"filename": "notes.txt",
"mimeType": "text/plain",
"sizeBytes": 12,
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"workspacePath": "/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt"
}]
}
}
```
`workspacePath` is optional, authoritative runtime text for display/copy, never a download URL or send parameter. Only `attachments:[{id}]` goes into POST messages. Committed/replayed messages retain the same ordered descriptors and original caption; file-only content is `Attached: notes.txt.`. Internal model manifests/previews and native base64 are excluded from public user message content. Root/worker native inputs and raw authorized model endpoints may contain the real image blocks; native token auth must protect all of them.
Merge queued and committed messages by stable message ID. A matching ID alone does not confirm an attachment-bearing optimistic message: compare the ordered `metadata.agentAttachments[].id` against its local IDs as well. Retain descriptors, error and retry identity on a mismatch or uncertain receipt; show the error inline for touch clients. Matching canonical echoes can acknowledge delivery even if HTTP was lost. Preserve raw tool rows/cursors in client room state and existing transcript filtering. Old clients can ignore the additive metadata and still see a meaningful caption.
Retrieve bytes using authenticated `GET /v1/agent/attachments/:id/content`; fetch with an Authorization header and use/revoke browser blob URLs for previews. Never add credentials to URLs or use a public file route. Persist completed descriptors and pending upload IDs with the composer/outbox; navigation cannot silently turn an attached draft into a text-only send. Incomplete reloaded uploads require verified file reselection or explicit removal, per the REST contract.
Attached input admission and its request binding are atomic. Replay verifies the durable accepted payload, including the ordered attachment IDs, before acknowledging it. Queued attachment metadata survives restart even if placement did not run before the crash; normal queue recovery places the original input without duplicate charge or delivery. Worker preparation saturation retries through durable Reporter checkpoints; permanent admission failures appear through the existing worker-report path.
+141
View File
@@ -0,0 +1,141 @@
import { Readable } from 'node:stream';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { requireAdmin } from './auth.js';
import { env } from './env.js';
export const AGENT_UPLOAD_LIMIT = 8 * 1024 * 1024 + 64 * 1024;
export const AgentAttachmentId = z.string().regex(/^att_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
export const AgentAttachmentReferences = z.array(z.object({ id: AgentAttachmentId }).strict()).max(8)
.refine(items => new Set(items.map(item => item.id)).size === items.length, 'Attachment IDs must be unique');
export const AgentAttachmentDescriptor = z.object({
id: AgentAttachmentId, kind: z.enum(['image', 'text', 'file']), filename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(127), sizeBytes: z.number().int().min(0).max(8 * 1024 * 1024),
sha256: z.string().regex(/^[0-9a-f]{64}$/), workspacePath: z.string().startsWith('/').max(4096).optional(),
});
export type AgentProxyJson = (path: string, method?: string, body?: unknown, signal?: AbortSignal) => Promise<{ status: number; data: unknown }>;
function apiError(statusCode: number, code: string, message: string) {
return Object.assign(new Error(message), { statusCode, code });
}
export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal) {
if (!env.ADMIN_TOKEN) throw apiError(503, 'attachments_unavailable', 'Attachments require authenticated Sybil access.');
const descriptor = await proxyJson('/api/conversations/root', 'GET', undefined, signal);
const value = descriptor.data as { capabilities?: { attachments?: { version?: number } } } | null;
if (descriptor.status !== 200 || value?.capabilities?.attachments?.version !== 1)
throw apiError(503, 'attachments_unavailable', 'The Agent does not currently support authenticated attachments.');
}
/** Authentication precedes parsing; bounded streaming keeps concurrent upload memory bounded. */
export async function registerAgentAttachmentRoutes(app: FastifyInstance, options: { baseUrl: string; apiKey?: string; proxyJson: AgentProxyJson }) {
await app.register(async routes => {
let uploads = 0, downloads = 0;
type Transfer = { controller: AbortController; release(): void };
const transfers = new WeakMap<FastifyRequest, Transfer>();
const transfer = (req: FastifyRequest) => { const value = transfers.get(req)!; value.controller.signal.throwIfAborted(); return value; };
routes.addHook('onRequest', async (req, reply) => {
reply.header('Cache-Control', 'private, no-store'); requireAdmin(req);
if (!options.apiKey) throw apiError(503, 'attachments_unavailable', 'Sybil Agent is not configured.');
const uploading = req.method === 'POST';
if (uploading ? uploads >= 2 : downloads >= 4) throw apiError(429, 'attachments_busy', 'Attachment transfers are busy. Retry shortly.');
if (uploading) uploads++; else downloads++;
const controller = new AbortController(); let released = false;
const timeout = setTimeout(() => { release(); reply.raw.destroy(); }, 60_000);
const release = () => {
if (released) return; released = true;
if (uploading) uploads--; else downloads--;
clearTimeout(timeout); controller.abort();
req.raw.off('aborted', release); reply.raw.off('close', release); reply.raw.off('finish', release); reply.raw.off('error', release);
};
transfers.set(req, { controller, release });
req.raw.once('aborted', release); reply.raw.once('close', release); reply.raw.once('finish', release); reply.raw.once('error', release);
if (req.raw.aborted || reply.raw.destroyed) { release(); controller.signal.throwIfAborted(); }
if (uploading && Number(req.headers['content-length'] || 0) > AGENT_UPLOAD_LIMIT)
throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
await requireAgentAttachments(options.proxyJson, controller.signal);
controller.signal.throwIfAborted();
});
routes.addHook('onResponse', async req => { transfers.get(req)?.release(); });
routes.setErrorHandler((error: Error & { statusCode?: number; code?: string }, _req, reply) => {
if (reply.raw.destroyed) return;
if (_req.method === 'POST') reply.header('Connection', 'close');
const statusCode = error.statusCode || 502;
const code = typeof error.code === 'string' ? error.code : undefined;
if (statusCode === 429) reply.header('Retry-After', '2');
reply.code(statusCode).send({ error: true, message: statusCode >= 500 && !code ? 'Sybil Agent is unavailable.' : error.message,
statusCode, code: code || 'agent_unavailable' });
});
routes.addContentTypeParser(/^multipart\/form-data(?:;|$)/i, (req, payload, done) => {
if (String(req.headers['content-type']).length > 512) done(apiError(400, 'invalid_upload', 'Invalid multipart content type.'));
else done(null, payload);
});
const json = async (response: Response) => {
let size = 0; const chunks: Uint8Array[] = [];
if (!response.body) throw apiError(502, 'invalid_upstream_response', 'The Agent returned an empty response.');
const reader = response.body.getReader();
try {
for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length;
if (size > 64 * 1024) { await reader.cancel(); throw new Error('Response too large'); } chunks.push(part.value); }
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
} catch { throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid response.'); }
finally { reader.releaseLock(); }
};
routes.post('/v1/agent/attachments', { bodyLimit: AGENT_UPLOAD_LIMIT }, async (req, reply) => {
if (!(req.body instanceof Readable)) throw apiError(400, 'invalid_upload', 'A multipart/form-data upload is required.');
let oversized = false;
const incoming = req.body;
const { controller } = transfer(req);
const body = Readable.from((async function* () {
let count = 0;
for await (const chunk of incoming) {
count += chunk.length;
if (count > AGENT_UPLOAD_LIMIT) { oversized = true; throw new Error('Upload limit exceeded'); }
yield chunk;
}
})());
try {
const response = await fetch(new URL('/api/conversations/root/attachments', options.baseUrl), {
method: 'POST', headers: { Authorization: `Bearer ${options.apiKey}`, 'Content-Type': String(req.headers['content-type']) },
body: body as unknown as RequestInit['body'], duplex: 'half', signal: controller.signal, redirect: 'error',
} as RequestInit & { duplex: string });
const value = await json(response); controller.signal.throwIfAborted();
if (response.ok && (![200, 201].includes(response.status) || !AgentAttachmentDescriptor.safeParse((value as { attachment?: unknown })?.attachment).success))
throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid attachment descriptor.');
if (response.status === 429) reply.header('Retry-After', '2');
return reply.code(response.status).send(value);
} catch (error) {
if (oversized) throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
if (error && typeof error === 'object' && 'statusCode' in error) throw error;
throw apiError(502, 'agent_unavailable', 'The upload could not be confirmed. Retry with the same uploadId.');
} finally { body.destroy(); }
});
routes.get<{ Params: { id: string } }>('/v1/agent/attachments/:id/content', async (req, reply) => {
if (!AgentAttachmentId.safeParse(req.params.id).success) throw apiError(404, 'attachment_not_found', 'Attachment not found.');
const { controller } = transfer(req);
let response: Response;
try { response = await fetch(new URL(`/api/conversations/root/attachments/${req.params.id}/content`, options.baseUrl), {
headers: { Authorization: `Bearer ${options.apiKey}` }, signal: controller.signal, redirect: 'error',
}); } catch { throw apiError(502, 'agent_unavailable', 'Sybil Agent is unavailable.'); }
controller.signal.throwIfAborted();
if (!response.ok) return reply.code(response.status).send(await json(response));
const length = Number(response.headers.get('content-length'));
const disposition = response.headers.get('content-disposition');
const type = response.headers.get('content-type') || '';
if (!response.body || !Number.isSafeInteger(length) || length < 0 || length > 8 * 1024 * 1024
|| !disposition?.startsWith('attachment;') || !/^(image\/(png|jpeg|gif|webp)|text\/plain(?:; charset=utf-8)?|application\/octet-stream)$/.test(type)) {
await response.body?.cancel(); throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid download.');
}
// Originals are bounded to 8 MiB. Verify the received length before sending a success response.
const reader = response.body.getReader(); let size = 0; const chunks: Uint8Array[] = [];
try {
for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length;
if (size > length) throw new Error('Download length mismatch'); chunks.push(part.value); }
if (size !== length) throw new Error('Download length mismatch');
} catch { await reader.cancel().catch(() => {}); throw apiError(502, 'invalid_upstream_response', 'The attachment download was incomplete.'); }
finally { reader.releaseLock(); }
controller.signal.throwIfAborted();
reply.header('Content-Type', type).header('Content-Length', length).header('Content-Disposition', disposition).header('X-Content-Type-Options', 'nosniff');
return reply.send(Buffer.concat(chunks, size));
});
});
}
+34 -10
View File
@@ -6,17 +6,19 @@ import { z } from 'zod';
import { requireAdmin } from './auth.js';
import { prisma } from './db.js';
import { env } from './env.js';
import { AgentAttachmentReferences, registerAgentAttachmentRoutes, requireAgentAttachments } from './agent-attachments.js';
const Cursor = z.string().regex(/^\d{1,16}$/).refine(value => Number.isSafeInteger(Number(value)), 'Invalid cursor')
.transform(value => String(Number(value)));
const PageQuery = z.object({ after: Cursor.optional(), before: Cursor.optional(), limit: z.coerce.number().int().min(1).max(200).optional() })
.refine(value => !(value.after && value.before), 'Use after or before, not both');
const MessageBody = z.object({ content: z.string().min(1).max(40_000).refine(value => value.trim().length > 0, 'content is required'), requestId: z.string().min(1).max(256) });
const MessageBody = z.object({ content: z.string().max(40_000), requestId: z.string().min(1).max(256), attachments: AgentAttachmentReferences.optional() })
.refine(value => value.content.trim().length > 0 || Boolean(value.attachments?.length), 'content or attachments are required');
const ReadBody = z.object({ cursor: Cursor });
const AuthFrame = z.object({ type: z.literal('auth'), token: z.string().nullable().optional(), after: Cursor.optional() });
const SubscribeFrame = z.object({ type: z.literal('subscribe'), after: Cursor.optional() });
const SubmissionReceipt = z.object({ id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
status: z.enum(['queued', 'placed', 'done', 'unanswered']), messageId: z.string() });
status: z.enum(['queued', 'placed', 'done', 'unanswered']), messageId: z.string(), attachmentIds: z.array(z.string()).optional() });
type AgentOptions = { baseUrl?: string; apiKey?: string };
const parse = <T>(schema: z.ZodType<T>, value: unknown): T => {
@@ -34,13 +36,26 @@ async function readState() {
export async function registerAgentRoutes(app: FastifyInstance, options: AgentOptions = {}) {
const baseUrl = options.baseUrl || env.SYBIL_AGENT_BASE_URL;
const apiKey = options.apiKey ?? env.SYBIL_AGENT_API_KEY;
const proxyJson = async (path: string, method = 'GET', body?: unknown) => {
// An open development proxy must not expose a token-protected attachment runtime's
// history/model context using its server-side credential.
const guardOpenProxy = async (signal?: AbortSignal) => {
if (env.ADMIN_TOKEN) return;
try {
const response = await fetch(new URL('/api/conversations/root', baseUrl), {
headers: { Authorization: `Bearer ${apiKey}` }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), redirect: 'error',
});
const descriptor = await response.json() as { capabilities?: { attachments?: unknown } };
if (!response.ok || descriptor?.capabilities?.attachments) throw new Error('Private runtime');
} catch { throw Object.assign(new Error('Authenticated Sybil access is required for this Agent runtime.'), { statusCode: 503, code: 'attachments_unavailable' }); }
};
const proxyJson = async (path: string, method = 'GET', body?: unknown, signal?: AbortSignal) => {
if (!apiKey) throw app.httpErrors.serviceUnavailable('Sybil Agent is not configured');
await guardOpenProxy(signal);
let response: Response;
try {
response = await fetch(new URL(path, baseUrl), { method,
headers: { Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000),
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
});
} catch { throw app.httpErrors.badGateway('Sybil Agent is unavailable'); }
let data: unknown;
@@ -55,7 +70,9 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
const result = await proxyJson('/api/conversations/root');
reply.code(result.status);
if (result.status !== 200) return result.data;
return { ...result.data as object, websocket: '/v1/agent/ws' };
const descriptor: Record<string, unknown> = { ...result.data as Record<string, unknown>, websocket: '/v1/agent/ws' };
if (!env.ADMIN_TOKEN) delete descriptor.capabilities;
return descriptor;
});
app.get('/v1/agent/messages', async (req, reply) => {
reply.header('Cache-Control', 'no-store');
@@ -71,10 +88,12 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
const body = parse(MessageBody, req.body);
if (body.attachments?.length) await requireAgentAttachments(proxyJson);
const result = await proxyJson('/api/conversations/root/messages', 'POST', { ...body, channel: 'sybil', whenBusy: 'followUp', wait: false });
if (result.status >= 200 && result.status < 300) {
const receipt = SubmissionReceipt.safeParse(result.data);
if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}`) {
if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}`
|| (body.attachments?.length && JSON.stringify(receipt.data.attachmentIds) !== JSON.stringify(body.attachments.map(item => item.id)))) {
throw app.httpErrors.badGateway('Sybil Agent returned an invalid message receipt');
}
}
@@ -99,10 +118,11 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
return readState();
});
attachAgentSocket(app, baseUrl, apiKey);
await registerAgentAttachmentRoutes(app, { baseUrl, apiKey, proxyJson });
attachAgentSocket(app, baseUrl, apiKey, guardOpenProxy);
}
function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: string) {
function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string | undefined, guardOpenProxy: () => Promise<void>) {
const sockets = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 });
const cleanup = new Set<() => void>();
const upgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => {
@@ -114,7 +134,7 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: strin
if (url.search) { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; }
sockets.handleUpgrade(request, socket, head, browser => {
let upstream: WebSocket | undefined;
let authenticated = false, disposed = false, alive = true;
let authenticated = false, authenticating = false, disposed = false, alive = true;
const send = (value: unknown) => {
if (browser.readyState !== WebSocket.OPEN) return;
if (browser.bufferedAmount > 2 * 1024 * 1024) { browser.close(1013, 'Slow consumer; reconnect with cursor'); return; }
@@ -130,14 +150,18 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: strin
};
cleanup.add(dispose);
browser.on('close', dispose); browser.on('error', dispose);
browser.on('message', (raw, isBinary) => {
browser.on('message', async (raw, isBinary) => {
try {
if (isBinary) throw new Error('JSON text frames are required');
const frame = JSON.parse(raw.toString());
if (!authenticated) {
if (authenticating) { browser.close(4400, 'Authentication is still pending'); return; }
authenticating = true;
const auth = parse(AuthFrame, frame);
if (env.ADMIN_TOKEN && auth.token !== env.ADMIN_TOKEN) { browser.close(4401, 'Invalid token'); return; }
if (!apiKey) { browser.close(4503, 'Sybil Agent is not configured'); return; }
try { await guardOpenProxy(); } catch { browser.close(4503, 'Authenticated Sybil access is required'); return; }
if (disposed || browser.readyState !== WebSocket.OPEN) return;
authenticated = true; clearTimeout(authTimeout);
const target = new URL('/api/ws', baseUrl); target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
upstream = new WebSocket(target, { headers: { Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 });
+1
View File
@@ -79,6 +79,7 @@ app.setErrorHandler((err, req, reply) => {
error: true,
message: e.message ?? String(e),
statusCode,
...(typeof e.code === 'string' ? { code: e.code } : {}),
});
});
+85
View File
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { createServer, request as httpRequest, type ServerResponse } from 'node:http';
import { Readable } from 'node:stream';
import test from 'node:test';
import Fastify from 'fastify';
import sensible from '@fastify/sensible';
process.env.ADMIN_TOKEN = 'attachments-browser-token-test';
process.env.DATABASE_URL = 'file:/tmp/sybil-attachment-proxy-unused.sqlite';
const [{ registerAgentRoutes }, { env }] = await Promise.all([import('../src/agent.js'), import('../src/env.js')]);
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }, key = 'runtime-private-test-key';
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'résumé.txt',mimeType:'text/plain',sizeBytes:4,sha256:'a'.repeat(64),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/résumé.txt' };
const bodies: any[] = [];
let capable=true, badReceipt=false, errorStatus=0, uploadCalls=0;
let holdDescriptor=false, holdDownload=false;const held:ServerResponse[]=[], closed:ServerResponse[]=[];
const source=createServer(async(req,res)=>{
assert.equal(req.headers.authorization,`Bearer ${key}`);
const chunks: Buffer[]=[];
try { for await(const chunk of req) chunks.push(chunk); } catch { return; }
const bytes=Buffer.concat(chunks);res.setHeader('Content-Type','application/json');
if(req.url==='/api/conversations/root'){if(holdDescriptor){held.push(res);res.once('close',()=>closed.push(res));return;}res.end(JSON.stringify({id:'1',...(capable?{capabilities:{attachments:{version:1}}}:{})}));return;}
if(errorStatus){res.statusCode=errorStatus;res.end(JSON.stringify({error:true,message:'Storage quota reached',statusCode:errorStatus,code:'attachment_quota_exceeded',remainingBytes:12,quotaBytes:1024,requiredBytes:99}));return;}
if(req.url?.endsWith('/attachments')){uploadCalls++;assert.match(req.headers['content-type']!,/^multipart\/form-data; boundary=/);assert.ok(bytes.includes(Buffer.from('résumé.txt')));bodies.push(bytes.toString());res.statusCode=201;res.end(JSON.stringify({attachment:file}));return;}
if(req.url?.endsWith('/content')){if(holdDownload){held.push(res);res.once('close',()=>closed.push(res));return;}res.writeHead(200,{'Content-Type':'text/plain; charset=utf-8','Content-Length':'4','Content-Disposition':"attachment; filename=notes.txt; filename*=UTF-8''notes.txt"});res.end('data');return;}
if(req.method==='POST'){const body=JSON.parse(bytes.toString());bodies.push(body);res.statusCode=202;res.end(JSON.stringify({id:1,status:'queued',messageId:`inbound:sybil:${body.requestId}`,...(!badReceipt?{attachmentIds:body.attachments.map((f:any)=>f.id)}:{})}));return;}
res.end(JSON.stringify({conversationId:'1',messages:[{id:'inbound:sybil:send',role:'user',content:'Attached: résumé.txt.',metadata:{agentAttachments:[file]}},{id:'tool:2',role:'tool',metadata:{kind:'tool_call'}}],cursor:'2',hasMore:false}));
});
source.listen(0,'127.0.0.1');await once(source,'listening');
const app=Fastify();await app.register(sensible);await registerAgentRoutes(app,{baseUrl:`http://127.0.0.1:${(source.address() as {port:number}).port}`,apiKey:key});
await app.listen({port:0,host:'127.0.0.1'});const base=`http://127.0.0.1:${(app.server.address() as {port:number}).port}`;
test.after(async()=>{await app.close();source.closeAllConnections();await new Promise<void>(r=>source.close(()=>r()));});
function form(){const body=new FormData();body.append('file',new Blob(['data'],{type:'text/plain'}),'résumé.txt');body.append('uploadId','12345678-1234-4234-8234-123456789abc');return body;}
test('auth precedes parsing/forwarding; capability gates old or unsafe upstream and open backend',async()=>{
const before=uploadCalls;
assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',body:form()})).status,401);assert.equal(uploadCalls,before);
capable=false;assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,503);
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{content:'',requestId:'x',attachments:[{id:file.id}]}})).statusCode,503);
capable=true;const saved=env.ADMIN_TOKEN;env.ADMIN_TOKEN=undefined;
try {assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',body:form()})).status,503);assert.equal((await app.inject({url:'/v1/agent'})).statusCode,503);assert.equal((await app.inject({url:'/v1/agent/messages'})).statusCode,503);}finally{env.ADMIN_TOKEN=saved;}
});
test('multipart forwards Unicode filename/uploadId with private auth; quota details and authenticated originals survive',async()=>{
const response=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()});assert.equal(response.status,201);assert.deepEqual(await response.json(),{attachment:file});assert.match(bodies.at(-1),/12345678-1234-4234-8234-123456789abc/);
errorStatus=507;try{const full=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()});assert.equal(full.status,507);assert.equal((await full.json() as any).remainingBytes,12);}finally{errorStatus=0;}
assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`)).status,401);
const download=await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth});assert.equal(download.status,200);assert.equal(await download.text(),'data');assert.match(download.headers.get('cache-control')!,/no-store/);assert.equal(download.headers.get('x-content-type-options'),'nosniff');
assert.equal((await app.inject({url:'/v1/agent/attachments/not-an-id/content',headers:auth})).statusCode,404);
});
test('ID-only sends require matching receipts; history retains attachment metadata and raw tools',async()=>{
const payload={content:'',requestId:'send',attachments:[{id:file.id}]};
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,202);assert.deepEqual(bodies.at(-1),{...payload,channel:'sybil',whenBusy:'followUp',wait:false});
badReceipt=true;try{assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,502);}finally{badReceipt=false;}
for(const attachments of [[{...file}],[{id:file.id,workspacePath:'/etc/passwd'}],[{id:file.id},{id:file.id}],Array(9).fill({id:file.id})]) assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{...payload,attachments}})).statusCode,400);
const page=(await app.inject({url:'/v1/agent/messages',headers:auth})).json();assert.deepEqual(page.messages[0].metadata.agentAttachments,[file]);assert.equal(page.messages[1].role,'tool');
});
test('proxy rejects oversized declared bodies and chunked bodies',async()=>{
const before=uploadCalls;
const large=await app.inject({method:'POST',url:'/v1/agent/attachments',headers:{...auth,'content-type':'multipart/form-data; boundary=x','content-length':String(9*1024*1024)}});assert.equal(large.statusCode,413);assert.equal(uploadCalls,before);
const chunks=Readable.from((async function*(){for(let i=0;i<145;i++)yield Buffer.alloc(64*1024);})());
const result=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:{...auth,'content-type':'multipart/form-data; boundary=x'},body:chunks as unknown as RequestInit['body'],duplex:'half'} as RequestInit & {duplex:string});
assert.equal(result.status,413);assert.equal((await result.json() as any).code,'attachment_limit_exceeded');
});
async function eventually(check:()=>boolean){const end=Date.now()+3000;while(!check()&&Date.now()<end)await new Promise(r=>setTimeout(r,10));assert.ok(check(),'Expected cancellation to finish');}
test('disconnect releases upload/download slots exactly once, cancels upstream and never continues after capability cancellation',async()=>{
const before=uploadCalls;
holdDescriptor=true;
for(let n=0;n<3;n++){
const expected=held.length+1,terminal=closed.length+1;
const req=httpRequest(base+'/v1/agent/attachments',{method:'POST',headers:{...auth,'content-type':'multipart/form-data; boundary=x'}},()=>{});req.on('error',()=>{});req.end('--x--');
await eventually(()=>held.length===expected);req.destroy();await eventually(()=>closed.length===terminal);
}
holdDescriptor=false;assert.equal(uploadCalls,before,'canceled capability checks never forward the upload');
assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,201);
holdDownload=true;
for(let n=0;n<5;n++){
const expected=held.length+1,terminal=closed.length+1;
const req=httpRequest(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth},()=>{});req.on('error',()=>{});req.end();
await eventually(()=>held.length===expected);req.destroy();await eventually(()=>closed.length===terminal);
}
holdDownload=false;assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth})).status,200);
});
@@ -0,0 +1,39 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { Copy, Download, FileText, Image, LoaderCircle } from "lucide-preact";
import { attachmentSize, type AgentAttachment } from "@/lib/agent-attachments";
type Props = { file: AgentAttachment; download(file: AgentAttachment, signal?: AbortSignal): Promise<Blob> };
/** Authenticated bytes only; no native credential, remote href, or public file serving. */
export function AgentAttachmentCard({ file, download }: Props) {
const [preview, setPreview] = useState<string>();
const [error, setError] = useState<string>();
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const controller = useRef<AbortController>();
useEffect(() => () => { controller.current?.abort(); }, []);
useEffect(() => () => { if (preview) URL.revokeObjectURL(preview); }, [preview]);
const retrieve = async (show: boolean) => {
if (busy) return;
const request = new AbortController(); controller.current = request; setBusy(true); setError(undefined);
try {
const blob = await download(file, request.signal); if (request.signal.aborted) return;
const url = URL.createObjectURL(blob);
if (show && file.kind === "image" && ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(blob.type)) setPreview(url);
else {
const anchor = document.createElement("a"); anchor.href = url; anchor.download = file.filename; anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
} catch (failure) { if (!request.signal.aborted) setError(failure instanceof Error ? failure.message : "Download failed."); }
finally { if (!request.signal.aborted) setBusy(false); }
};
return <div className="agent-attachment">
<div className="agent-attachment__title"><FileText size={15} /><span>{file.filename}</span><small>{attachmentSize(file.sizeBytes)}</small></div>
{file.workspacePath ? <div className="agent-attachment__path"><code title={file.workspacePath}>{file.workspacePath}</code><button type="button" aria-label={`Copy path for ${file.filename}`} title={copied ? "Copied" : "Copy workspace path"} onClick={() => { void navigator.clipboard.writeText(file.workspacePath!).then(() => setCopied(true)).catch(() => setError("Could not copy the path.")); }}><Copy size={13} /></button></div> : null}
{preview ? <img className="agent-attachment__preview" src={preview} alt={file.filename} /> : null}
<div className="agent-attachment__actions">
{file.kind === "image" ? <button type="button" disabled={busy} onClick={() => preview ? setPreview(undefined) : void retrieve(true)}><Image size={13} />{preview ? "Hide preview" : "Preview"}</button> : null}
<button type="button" disabled={busy} onClick={() => void retrieve(false)}>{busy ? <LoaderCircle size={13} className="animate-spin" /> : <Download size={13} />}Download</button>
</div>
{error ? <span className="agent-attachment__error" role="alert">{error} Retry using the buttons above.</span> : null}
</div>;
}
@@ -455,3 +455,24 @@
transition:none!important;
}
}
.agent-room__attach { display: grid; place-items: center; align-self: end; width: 38px; height: 46px; color: #c4b5fd; border-radius: 12px; }
.agent-room__attach:hover { background: #a78bfa22; }
.agent-room__uploads { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 0; }
.agent-room__upload { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; max-width: 100%; padding: 9px 12px; border: 1px solid #a78bfa44; border-radius: 12px; background: #211b36; font-size: 12px; }
.agent-room__upload strong { display: block; overflow-wrap: anywhere; font-weight: 500; }
.agent-room__upload small { color: #c4b5fd; }
.agent-room__upload button { padding: 5px; color: #ddd6fe; }
.agent-room__upload-path { flex-basis: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; color: #b9a7d1; }
.agent-attachment { margin-top: 10px; padding: 10px; border: 1px solid #a78bfa44; border-radius: 10px; background: #0e0a1933; font-size: 12px; min-width: 0; }
.agent-attachment__title { display: flex; align-items: center; gap: 6px; }
.agent-attachment__title span { overflow-wrap: anywhere; }
.agent-attachment__title small { margin-left: auto; white-space: nowrap; opacity: .7; }
.agent-attachment__path { display: flex; gap: 6px; margin-top: 6px; }
.agent-attachment__path code { min-width: 0; overflow-wrap: anywhere; font-size: 10px; opacity: .75; }
.agent-attachment__path button { flex-shrink: 0; }
.agent-attachment__actions { display: flex; gap: 14px; margin-top: 8px; }
.agent-attachment__actions button { display: flex; align-items: center; gap: 5px; color: #ddd6fe; }
.agent-attachment__actions button:disabled { opacity: .5; }
.agent-attachment__preview { display: block; max-height: 260px; max-width: 100%; margin-top: 8px; border-radius: 6px; }
.agent-attachment__error { display: block; width: 100%; color: #fda4af; font-size: 12px; margin-top: 5px; }
+46 -11
View File
@@ -1,10 +1,12 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import { ArrowDown, Clock3, LoaderCircle, Menu, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact";
import { ArrowDown, Clock3, LoaderCircle, Menu, Paperclip, X, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact";
import type { Message } from "@/lib/api";
import type { AgentRoomHookResult } from "@/hooks/use-agent-room";
import { agentTranscriptMessages } from "@/lib/agent-room-presentation";
import { MarkdownContent } from "@/components/markdown/markdown-content";
import { Button } from "@/components/ui/button";
import { agentAttachments, attachmentSize } from "@/lib/agent-attachments";
import { AgentAttachmentCard } from "./agent-attachment";
import "./agent-conversation.css";
type Props = {
@@ -47,7 +49,7 @@ function toolName(value: unknown) {
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
}
function AgentBubble({ message, animate, onRetry }: { message: Message; animate: boolean; onRetry: (id: string) => void }) {
function AgentBubble({ message, animate, onRetry, download }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"] }) {
const own = message.role === "user";
const info = metadata(message);
const status = typeof info.status === "string" ? info.status : "complete";
@@ -59,7 +61,9 @@ function AgentBubble({ message, animate, onRetry }: { message: Message; animate:
<div className="agent-room__message-content">
<div className={`message-bubble${own ? " message-bubble--user" : ""}`}>
{own ? <p className="agent-room__plain-message">{message.content}</p> : <MarkdownContent markdown={message.content} openLinksInNewTab />}
{agentAttachments(message.metadata).map(file => <AgentAttachmentCard key={file.id} file={file} download={download} />)}
</div>
{own && failed && typeof info.error === "string" ? <div className="agent-attachment__error" role="alert">{info.error}</div> : null}
<div className="agent-room__message-meta">
<span>{own ? "You" : "Sybil"}</span>
{date ? <time dateTime={message.createdAt} title={date.toLocaleString()}>{timeFormat.format(date)}</time> : null}
@@ -78,6 +82,16 @@ function AgentBubble({ message, animate, onRetry }: { message: Message; animate:
export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Props) {
const [draft, setDraft] = useState(savedDraft);
const draftRef = useRef(draft);
const [uploadError, setUploadError] = useState<string>();
const pickerRef = useRef<HTMLInputElement>(null);
const reselectRef = useRef<HTMLInputElement>(null);
const reselectId = useRef<string>();
const uploads = room.uploadQueue.snapshot();
const pendingUploads = uploads.some(file => file.status !== "ready");
const addFiles = (files: Iterable<File>) => {
try { room.uploadQueue.add(files); setUploadError(undefined); } catch (error) { setUploadError(error instanceof Error ? error.message : String(error)); }
};
const removeFile = (id: string) => { try { room.uploadQueue.remove(id); setUploadError(undefined); } catch (error) { setUploadError(String(error)); } };
const seenMessageIdsRef = useRef(new Set(room.messages.map(message => message.id)));
const enteringMessageIdsRef = useRef(new Set<string>());
const animatedEntryIdsRef = useRef(new Set<string>());
@@ -223,11 +237,11 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
};
const submit = () => {
const content = draftRef.current.trim();
if (!content) return;
const delivery = room.sendMessage(content);
updateDraft("");
jumpToLatest();
void delivery.catch(() => {});
if ((!content && !uploads.length) || pendingUploads || (uploads.length && !room.attachmentCapability)) return;
let attachments;
try { attachments = room.uploadQueue.ready(); } catch (error) { setUploadError(String(error)); return; }
const delivery = room.sendMessage(content, attachments, () => { updateDraft(""); setUploadError(undefined); jumpToLatest(); });
void delivery.catch(error => { if (draftRef.current === content) setUploadError(error instanceof Error ? error.message : String(error)); });
textareaRef.current?.focus();
};
@@ -247,11 +261,11 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
return (
<div className="agent-room__entry" key={message.id} data-agent-entry-id={message.id}>
{separator ? <div className="agent-room__day"><span>{dayLabel(date)}</span></div> : null}
<AgentBubble message={message} animate={enteringMessageIdsRef.current.has(message.id)} onRetry={retryMessage} />
<AgentBubble message={message} animate={enteringMessageIdsRef.current.has(message.id)} onRetry={retryMessage} download={room.downloadAttachment} />
</div>
);
});
}, [room.messages, room.isLoading, room.retryMessage]);
}, [room.messages, room.isLoading, room.retryMessage, room.downloadAttachment]);
const activity = room.activity;
const running = room.isTyping;
@@ -306,21 +320,42 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
{!atBottom ? <button className="agent-room__jump" onClick={jumpToLatest}><ArrowDown size={15} /><span>{room.unreadCount ? `${room.unreadCount} new message${room.unreadCount === 1 ? "" : "s"}` : "Jump to latest"}</span></button> : null}
</div>
<div className="agent-room__composer-area">
<div className="agent-room__composer-area"
onDragOver={event => { if (room.attachmentCapability && event.dataTransfer?.types.includes("Files")) event.preventDefault(); }}
onDrop={event => { if (room.attachmentCapability && event.dataTransfer?.files.length) { event.preventDefault(); addFiles(event.dataTransfer.files); } }}>
<div className="agent-room__composer-inner">
{room.error ? <div className="agent-room__error" role="alert"><WifiOff size={15} /><span>{room.error}</span><button onClick={room.retry}>Retry</button></div> : null}
{uploadError ? <div className="agent-room__error" role="alert">{uploadError}</div> : null}
<input ref={reselectRef} type="file" hidden aria-label="Reselect attachment" onChange={event => {
const file = event.currentTarget.files?.[0], id = reselectId.current; event.currentTarget.value = "";
if (file && id) void room.uploadQueue.reselect(id, file).then(() => setUploadError(undefined)).catch(error => setUploadError(error instanceof Error ? error.message : String(error)));
}} />
{uploads.length ? <div className="agent-room__uploads" aria-label="Selected attachments">{uploads.map(row => <div className="agent-room__upload" key={row.uploadId}>
<div><strong>{row.filename}</strong><small>{attachmentSize(row.sizeBytes)} · <span role="status">{row.status === "ready" ? "Uploaded" : row.status === "uploading" ? "Uploading…" : row.status === "waiting" ? "Waiting…" : row.status === "reselect" ? "Reselect file" : "Upload failed"}</span></small></div>
{row.status === "reselect" ? <button type="button" onClick={() => { reselectId.current = row.uploadId; reselectRef.current?.click(); }}>Reselect file</button> : null}
{row.status === "failed" ? <button type="button" onClick={() => room.uploadQueue.retry(row.uploadId)}>Retry upload</button> : null}
<button type="button" aria-label={`Remove ${row.filename}`} onClick={() => removeFile(row.uploadId)}><X size={15} /></button>
{row.error ? <span className="agent-attachment__error" role="alert">{row.error}</span> : null}
{row.attachment?.workspacePath ? <code className="agent-room__upload-path" title={row.attachment.workspacePath}>{row.attachment.workspacePath}</code> : null}
</div>)}</div> : null}
<form className="agent-room__composer" onSubmit={event => { event.preventDefault(); submit(); }}>
{room.attachmentCapability ? <>
<input ref={pickerRef} type="file" multiple hidden aria-label="Choose attachments" onChange={event => { if (event.currentTarget.files) addFiles(event.currentTarget.files); event.currentTarget.value = ""; }} />
<button type="button" className="agent-room__attach" aria-label="Attach files" title="Attach files (8 files, 32 MiB total)" onClick={() => pickerRef.current?.click()}><Paperclip size={19} /></button>
</> : null}
<textarea
ref={textareaRef}
value={draft}
rows={1}
maxLength={40000}
onPaste={event => { if (room.attachmentCapability && event.clipboardData?.files.length) { event.preventDefault(); addFiles(event.clipboardData.files); } }}
onInput={event => updateDraft(event.currentTarget.value)}
onKeyDown={event => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); submit(); } }}
placeholder="Message Sybil Agent…"
aria-label="Message Sybil Agent"
enterkeyhint="send"
/>
<button type="submit" className="agent-room__send" disabled={!draft.trim()} aria-label="Send message"><SendHorizontal size={19} /></button>
<button type="submit" className="agent-room__send" disabled={(!draft.trim() && !uploads.length) || pendingUploads || Boolean(uploads.length && !room.attachmentCapability)} aria-label="Send message"><SendHorizontal size={19} /></button>
</form>
</div>
</div>
+36 -8
View File
@@ -9,11 +9,17 @@ import {
import { AGENT_OUTBOX_STORAGE_KEY, createAgentOptimisticOutbox } from "@/lib/agent-optimistic-outbox";
import { createAgentRoomPresenter } from "@/lib/agent-room-presentation";
import { AGENT_ATTACHMENT_DRAFT_KEY, createAgentUploadQueue, type AgentAttachment, type AgentAttachmentCapability } from "@/lib/agent-attachments";
export type AgentRoomConnection = "connecting" | "connected" | "reconnecting" | "offline";
export type AgentRoomHookResult = {
messages: Message[]; activity: AgentActivity | null; unreadCount: number;
connection: AgentRoomConnection; error: string | null; isLoading: boolean; isTyping: boolean;
sendMessage(content: string): Promise<void>; retryMessage(messageId: string): Promise<void>; markRead(): void; retry(): void;
attachmentCapability?: AgentAttachmentCapability;
uploadAttachment(file: File, uploadId: string, signal: AbortSignal): Promise<AgentAttachment>;
downloadAttachment(file: AgentAttachment, signal?: AbortSignal): Promise<Blob>;
uploadQueue: ReturnType<typeof createAgentUploadQueue>;
sendMessage(content: string, attachments?: AgentAttachment[], onPersisted?: () => void): Promise<void>; retryMessage(messageId: string): Promise<void>; markRead(): void; retry(): void;
};
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
@@ -23,10 +29,24 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
const room = view.room;
const current = useRef(room);
const [connection, setConnection] = useState<AgentRoomConnection>("offline");
const [attachmentCapability, setAttachmentCapability] = useState<AgentAttachmentCapability>();
const [error, setError] = useState<string | null>(null);
const [isLoading, setLoading] = useState(enabled);
const [revision, setRevision] = useState(0);
const client = useRef<AgentRoomClient | null>(null);
const [, renderUploads] = useState(0);
const capabilityRef = useRef(attachmentCapability); capabilityRef.current = enabled ? attachmentCapability : undefined;
const uploadQueue = useRef<ReturnType<typeof createAgentUploadQueue> | null>(null);
if (!uploadQueue.current) uploadQueue.current = createAgentUploadQueue({
capability: () => capabilityRef.current,
upload: (file, id, signal) => { if (!client.current) throw new Error("Agent connection is unavailable."); return client.current.upload(file, id, signal); },
changed: () => renderUploads(n => n + 1),
storage: {
read: () => { try { return JSON.parse(sessionStorage.getItem(AGENT_ATTACHMENT_DRAFT_KEY) || "null"); } catch { return null; } },
write: rows => { try { sessionStorage.setItem(AGENT_ATTACHMENT_DRAFT_KEY, JSON.stringify(rows)); } catch { throw new Error("Could not save attachment retry details. Allow session storage before uploading."); } },
},
});
useEffect(() => () => uploadQueue.current?.dispose(), []);
const mark = useRef<((cursor: string) => void) | null>(null);
const [outboxRevision, setOutboxRevision] = useState(0);
const update = useCallback((change: (state: AgentRoomState) => AgentRoomState) => {
@@ -36,9 +56,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
if (!outbox.current) {
const stored = (key: string) => { try { return JSON.parse(sessionStorage.getItem(key) || "null"); } catch { return null; } };
outbox.current = createAgentOptimisticOutbox({
submit: (content, requestId) => {
submit: (content, requestId, attachments) => {
if (!client.current) throw new Error("Agent connection is unavailable.");
return client.current.submit(content, requestId);
return client.current.submit(content, requestId, attachments);
},
changed: () => setOutboxRevision(value => value + 1),
storage: {
@@ -60,7 +80,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
useEffect(() => {
if (!enabled) {
client.current = null; mark.current = null;
client.current = null; mark.current = null; setAttachmentCapability(undefined);
update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null);
return;
}
@@ -112,7 +132,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
}, controller.signal);
const refreshActivity = async () => {
const descriptor = await api.descriptor(controller.signal);
if (active) presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity });
if (active) { setAttachmentCapability(descriptor.capabilities?.attachments?.version === 1 ? descriptor.capabilities.attachments : undefined); presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity }); }
};
const connect = () => {
@@ -214,9 +234,17 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
// here would incorrectly mark a socket message that has not rendered yet.
mark.current?.(renderedCursor);
}, [enabled, isLoading, renderedCursor]);
const sendMessage = useCallback(async (content: string) => {
const sendMessage = useCallback(async (content: string, attachments?: AgentAttachment[], onPersisted?: () => void) => {
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
await outbox.current.send(content);
await outbox.current.send(content, attachments, () => { uploadQueue.current!.clear(); onPersisted?.(); });
}, [enabled]);
const uploadAttachment = useCallback((file: File, uploadId: string, signal: AbortSignal) => {
if (!enabled || !client.current) return Promise.reject(new Error("Agent connection is unavailable."));
return client.current.upload(file, uploadId, signal);
}, [enabled]);
const downloadAttachment = useCallback((file: AgentAttachment, signal?: AbortSignal) => {
if (!enabled || !client.current) return Promise.reject(new Error("Agent connection is unavailable."));
return client.current.download(file, signal);
}, [enabled]);
const retryMessage = useCallback(async (messageId: string) => {
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
@@ -225,5 +253,5 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
const retry = useCallback(() => { setRevision(value => value + 1); }, []);
const messages = useMemo(() => outbox.current?.display(agentDisplayMessages(room)) || agentDisplayMessages(room), [room.messages, room.activity, outboxRevision]);
return { messages: enabled ? messages : [], activity: room.activity, unreadCount: agentUnreadCount(room), connection, error, isLoading,
isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
attachmentCapability, uploadQueue: uploadQueue.current!, uploadAttachment, downloadAttachment, isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
}
+117
View File
@@ -0,0 +1,117 @@
/** Agent descriptors are independent of ordinary chat's inline attachment schema. */
export type AgentAttachment = {
id: string; kind: "image" | "text" | "file"; filename: string; mimeType: string;
sizeBytes: number; sha256: string; workspacePath?: string;
};
export type AgentAttachmentCapability = {
version: 1; maxCount: number; maxTotalBytes: number; maxImageBytes: number; maxFileBytes: number;
imageMimeTypes: string[]; quotaBytes: number; usedBytes: number; remainingBytes: number;
maxConcurrentUploads: number; maxConcurrentDecodes: number;
};
export function parseAgentAttachment(value: unknown): AgentAttachment {
const item = value as AgentAttachment | null;
if (!item || !/^att_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(item.id)
|| !["image", "text", "file"].includes(item.kind) || typeof item.filename !== "string" || !item.filename
|| typeof item.mimeType !== "string" || !Number.isSafeInteger(item.sizeBytes) || item.sizeBytes < 0 || item.sizeBytes > 8 * 1024 * 1024
|| typeof item.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(item.sha256)
|| (item.workspacePath !== undefined && (typeof item.workspacePath !== "string" || !item.workspacePath.startsWith("/"))))
throw new Error("Agent returned an invalid attachment descriptor.");
return { id: item.id, kind: item.kind, filename: item.filename, mimeType: item.mimeType, sizeBytes: item.sizeBytes, sha256: item.sha256,
...(item.workspacePath ? { workspacePath: item.workspacePath } : {}) };
}
export function agentAttachments(metadata: unknown): AgentAttachment[] {
const values = (metadata as { agentAttachments?: unknown } | null)?.agentAttachments;
if (!Array.isArray(values) || values.length > 8) return [];
try { return values.map(parseAgentAttachment); } catch { return []; }
}
export function attachmentCaption(content: string, files: AgentAttachment[]): string {
return content.trim() ? content : `Attached: ${files.map(file => file.filename).join(", ")}.`;
}
export function attachmentSize(bytes: number): string { return bytes < 1024 ? `${bytes} B` : bytes < 1024 ** 2 ? `${Math.ceil(bytes / 1024)} KiB` : `${(bytes / 1024 ** 2).toFixed(1)} MiB`; }
export const AGENT_ATTACHMENT_DRAFT_KEY = "sybil.agent.attachment-draft.v1";
export type AgentUpload = {
uploadId: string; filename: string; sizeBytes: number; mimeType: string; sha256?: string; file?: File;
status: "waiting" | "uploading" | "ready" | "failed" | "reselect"; attachment?: AgentAttachment; error?: string;
};
export type AgentUploadStorage = { read(): unknown; write(rows: Omit<AgentUpload, "file">[]): void };
/** One active transfer. Durable identities/descriptors survive navigation and reload; bytes stay in memory. */
export function createAgentUploadQueue(options: {
capability(): AgentAttachmentCapability | undefined;
upload(file: File, uploadId: string, signal: AbortSignal): Promise<AgentAttachment>;
changed(): void; storage?: AgentUploadStorage; newId?: () => string;
}) {
const rows = new Map<string, AgentUpload>();
let running: { id: string; controller: AbortController } | undefined, disposed = false;
const persist = () => options.storage?.write([...rows.values()].map(({ file: _file, ...row }) => ({ ...row })));
const publish = () => { if (!disposed) options.changed(); };
const save = () => { if (disposed) return; try { persist(); } catch { /* Pre-transfer persist below is mandatory. Already-uploaded identities remain in memory. */ } publish(); };
const restored = options.storage?.read();
if (Array.isArray(restored)) for (const value of restored.slice(0, 8)) {
if (!value || typeof value.uploadId !== "string" || !/^[a-f0-9-]{36}$/i.test(value.uploadId)
|| typeof value.filename !== "string" || typeof value.mimeType !== "string" || !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes < 0 || value.sizeBytes > 8 * 1024 * 1024) continue;
try {
const attachment = value.attachment ? parseAgentAttachment(value.attachment) : undefined;
rows.set(value.uploadId, { uploadId: value.uploadId, filename: value.filename, sizeBytes: value.sizeBytes, mimeType: value.mimeType,
...(typeof value.sha256 === "string" && /^[a-f0-9]{64}$/.test(value.sha256) ? { sha256: value.sha256 } : {}),
...(attachment ? { attachment, status: "ready" } : { status: "reselect", error: "Reselect the same file to resume this upload. Its retry ID has been retained." }) });
} catch { /* Ignore invalid storage, never turn it into a server path/reference. */ }
}
const digest = async (file: File) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", await file.arrayBuffer()))].map(n => n.toString(16).padStart(2, "0")).join("");
const pump = () => {
if (disposed || running) return;
const row = [...rows.values()].find(row => row.status === "waiting" && row.file);
if (!row) return;
const controller = new AbortController(); running = { id: row.uploadId, controller }; row.status = "uploading"; publish();
void (async () => {
row.sha256 ||= await digest(row.file!);
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
// Persist the upload identity AND original byte hash before any network request.
persist();
const value = await options.upload(row.file!, row.uploadId, controller.signal);
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
const attachment = parseAgentAttachment(value);
if (attachment.sha256 !== row.sha256 || attachment.sizeBytes !== row.sizeBytes || attachment.filename !== row.filename.normalize("NFC"))
throw new Error("The uploaded file receipt does not match this file. Retry to confirm.");
row.attachment = attachment; row.status = "ready"; delete row.error;
})().catch(error => {
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
row.status = "failed"; row.error = error instanceof Error ? error.message : String(error);
}).finally(() => { running = undefined; save(); pump(); });
};
return {
add(files: Iterable<File>) {
const cap = options.capability(); if (!cap || cap.version !== 1) throw new Error("Attachments are not available on this Agent.");
const selected = Array.from(files);
if (rows.size + selected.length > Math.min(8, cap.maxCount)) throw new Error("Attach at most 8 files per message.");
if ([...rows.values()].reduce((n, r) => n + r.sizeBytes, 0) + selected.reduce((n, f) => n + f.size, 0) > Math.min(32 * 1024 * 1024, cap.maxTotalBytes))
throw new Error("Attachments exceed 32 MiB per message.");
for (const file of selected) {
const image = cap.imageMimeTypes.includes(file.type);
const limit = image ? Math.min(6 * 1024 * 1024, cap.maxImageBytes) : Math.min(8 * 1024 * 1024, cap.maxFileBytes);
if (file.size > limit) throw new Error(`${file.name} exceeds the ${image ? "6" : "8"} MiB file limit.`);
}
const added = selected.map(file => ({ uploadId: (options.newId || (() => crypto.randomUUID()))(), file, filename: file.name, sizeBytes: file.size, mimeType: file.type, status: "waiting" as const }));
for (const row of added) rows.set(row.uploadId, row);
try { persist(); } catch (error) { for (const row of added) rows.delete(row.uploadId); throw error; }
publish(); pump();
},
async reselect(id: string, file: File) {
const row = rows.get(id); if (!row || row.status !== "reselect") return;
if (file.name !== row.filename || file.size !== row.sizeBytes || file.type !== row.mimeType) throw new Error("Choose the same filename, type and size to resume this upload.");
const hash = await digest(file);
if (row.sha256 && row.sha256 !== hash) throw new Error("The selected file has different contents. Choose the original file or remove this attachment.");
if (rows.get(id) !== row) return;
row.sha256 = hash; row.file = file; row.status = "waiting"; delete row.error; persist(); publish(); pump();
},
remove(id: string) { const row = rows.get(id); rows.delete(id); try { persist(); } catch (error) { if (row) rows.set(id, row); throw error; } if (running?.id === id) running.controller.abort(); publish(); },
retry(id: string) { const row = rows.get(id); if (row?.status === "failed") { row.status = row.file ? "waiting" : "reselect"; delete row.error; save(); pump(); } },
snapshot(): AgentUpload[] { return [...rows.values()].map(row => ({ ...row })); },
ready(): AgentAttachment[] {
if ([...rows.values()].some(row => row.status !== "ready")) throw new Error("Wait for uploads, reselect or retry failed files, or remove them before sending.");
return [...rows.values()].map(row => row.attachment!);
},
clear() { rows.clear(); running?.controller.abort(); save(); },
dispose() { disposed = true; running?.controller.abort(); try { persist(); } catch { /* Existing stored retry IDs remain usable. */ } },
};
}
+21 -15
View File
@@ -1,10 +1,11 @@
import type { Message } from "./api";
import { agentAttachments, attachmentCaption, parseAgentAttachment, type AgentAttachment } from "./agent-attachments.ts";
export const AGENT_OUTBOX_STORAGE_KEY = "sybil.agent.optimistic-outbox.v1";
export type AgentDeliveryStatus = "sending" | "queued" | "complete" | "failed" | "unconfirmed";
export type AgentOutboxRow = {
id: string; requestId: string; content: string; createdAt: string;
status: AgentDeliveryStatus; error?: string; seenOnServer: boolean; confirmed: boolean;
attachments?: AgentAttachment[]; status: AgentDeliveryStatus; error?: string; seenOnServer: boolean; confirmed: boolean;
};
export type AgentOutboxStorage = {
read(): unknown; write(rows: AgentOutboxRow[]): void;
@@ -20,7 +21,7 @@ const validRequestId = (value: unknown): value is string => typeof value === "st
/** Local rows use the server's identity from their first render through final echo. */
export function createAgentOptimisticOutbox(options: {
submit(content: string, requestId: string): Promise<Receipt>;
submit(content: string, requestId: string, attachments?: AgentAttachment[]): Promise<Receipt>;
storage: AgentOutboxStorage;
changed(): void;
newId?: () => string;
@@ -40,12 +41,14 @@ export function createAgentOptimisticOutbox(options: {
const restored = options.storage.read();
if (Array.isArray(restored)) {
for (const value of restored.slice(0, 100)) {
if (!value || typeof value.content !== "string" || !value.content.trim() || !validRequestId(value.requestId)) continue;
if (!value || typeof value.content !== "string" || (!value.content.trim() && !value.attachments?.length) || !validRequestId(value.requestId)) continue;
let attachments: AgentAttachment[] = [];
try { if (value.attachments !== undefined) { if (!Array.isArray(value.attachments) || value.attachments.length > 8) continue; attachments = value.attachments.map(parseAgentAttachment); } } catch { continue; }
const time = typeof value.createdAt === "string" && Number.isFinite(Date.parse(value.createdAt)) ? value.createdAt : timestamp();
latestTime = Math.max(latestTime, Date.parse(time));
const status: AgentDeliveryStatus = ["failed", "queued", "complete", "unconfirmed"].includes(value.status) ? value.status : "unconfirmed";
const row: AgentOutboxRow = { id: idFor(value.requestId), requestId: value.requestId, content: value.content, createdAt: time,
status, seenOnServer: Boolean(value.seenOnServer), confirmed: false,
...(attachments.length ? { attachments } : {}), status, seenOnServer: Boolean(value.seenOnServer), confirmed: false,
...(typeof value.error === "string" && value.error ? { error: value.error.slice(0, 240) } : {}) };
rows.set(row.id, row);
}
@@ -78,7 +81,8 @@ export function createAgentOptimisticOutbox(options: {
};
const saveBestEffort = () => { try { persist(); } catch { /* Existing receipt IDs stay in memory; acknowledged messages remain sent. */ } };
const publish = () => options.changed();
const dispatch = (row: AgentOutboxRow): Promise<void> => {
const matches = (row: AgentOutboxRow, message: Message) => !row.attachments?.length || JSON.stringify(agentAttachments(message.metadata).map(file => file.id)) === JSON.stringify(row.attachments.map(file => file.id));
const dispatch = (row: AgentOutboxRow, onPersisted?: () => void): Promise<void> => {
const running = inFlight.get(row.id);
if (running) return running;
if (row.confirmed || row.seenOnServer) return Promise.resolve();
@@ -92,9 +96,10 @@ export function createAgentOptimisticOutbox(options: {
row.status = "failed"; row.error = errorText(error); publish();
return Promise.reject(error);
}
onPersisted?.();
// Both durable local storage and the visible row exist before POST begins.
publish();
const pending = Promise.resolve().then(() => options.submit(row.content, row.requestId)).then(receipt => {
const pending = Promise.resolve().then(() => options.submit(row.content, row.requestId, row.attachments)).then(receipt => {
if (receipt.messageId && receipt.messageId !== row.id) throw new Error("Agent returned a different message identity.");
if (!row.confirmed) {
row.seenOnServer = true;
@@ -115,20 +120,21 @@ export function createAgentOptimisticOutbox(options: {
// their newly assigned identity/time must survive the next reload too.
saveBestEffort();
return {
send(content: string): Promise<void> {
if (!content.trim()) return Promise.reject(new Error("Enter a message."));
send(content: string, attachments: AgentAttachment[] = [], onPersisted?: () => void): Promise<void> {
if (!content.trim() && !attachments.length) return Promise.reject(new Error("Enter a message."));
const requestId = newId();
const row: AgentOutboxRow = { id: idFor(requestId), requestId, content, createdAt: timestamp(), status: "sending", seenOnServer: false, confirmed: false };
const row: AgentOutboxRow = { id: idFor(requestId), requestId, content, ...(attachments.length ? { attachments: attachments.map(parseAgentAttachment) } : {}), createdAt: timestamp(), status: "sending", seenOnServer: false, confirmed: false };
rows.set(row.id, row);
return dispatch(row);
return dispatch(row, onPersisted);
},
retry(messageId: string): Promise<void> {
const row = rows.get(messageId);
return row ? dispatch(row) : Promise.reject(new Error("This message is no longer pending."));
},
reconcile(room: RoomMessages): void {
const committed = new Map(room.messages.map(message => [message.id, message]));
const queued = new Set((room.activity?.queuedMessages || []).map(message => message.id));
const compatible = (message: Message) => { const row = rows.get(message.id); return !row || matches(row, message); };
const committed = new Map(room.messages.filter(compatible).map(message => [message.id, message]));
const queued = new Set((room.activity?.queuedMessages || []).filter(compatible).map(message => message.id));
let changed = false;
for (const row of rows.values()) {
if (committed.has(row.id)) {
@@ -143,7 +149,7 @@ export function createAgentOptimisticOutbox(options: {
if (changed) { saveBestEffort(); publish(); }
},
display(serverMessages: readonly Message[]): Message[] {
const displayed = new Map(serverMessages.map(message => {
const displayed = new Map(serverMessages.filter(message => { const row = rows.get(message.id); return !row || matches(row, message); }).map(message => {
const row = rows.get(message.id);
if (!row) return [message.id, message] as const;
const serverInfo = info(message);
@@ -155,8 +161,8 @@ export function createAgentOptimisticOutbox(options: {
const echo = confirmedEchoes.get(row.id);
if (row.confirmed && !echo) continue;
displayed.set(row.id, echo ? { ...echo, createdAt: row.createdAt } : {
id: row.id, createdAt: row.createdAt, role: "user", content: row.content, name: "buzzert",
metadata: { kind: "message", status: row.status, channel: "sybil", requestId: row.requestId, ...(row.error ? { error: row.error } : {}) },
id: row.id, createdAt: row.createdAt, role: "user", content: row.attachments?.length ? attachmentCaption(row.content, row.attachments) : row.content, name: "buzzert",
metadata: { kind: "message", status: row.status, channel: "sybil", requestId: row.requestId, ...(row.attachments?.length ? { agentAttachments: row.attachments } : {}), ...(row.error ? { error: row.error } : {}) },
});
}
return [...displayed.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
+22 -6
View File
@@ -1,4 +1,5 @@
import type { Message } from "./api";
import { parseAgentAttachment, type AgentAttachment, type AgentAttachmentCapability } from "./agent-attachments.ts";
export type AgentActivity = {
state: "idle" | "thinking" | "tool" | "waiting" | "error";
@@ -16,7 +17,7 @@ export type AgentMessagesPage = {
};
export type AgentDescriptor = {
id: string; title: string; model: string; activity: AgentActivity;
websocket: string; protocol: string;
websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability };
};
export type AgentReadState = { cursor: string; updatedAt: string | null };
export type AgentRoomState = {
@@ -126,7 +127,7 @@ export function agentAuthFrame(token: string | null, cursor: string): string {
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor) });
}
export type AgentSubmission = { id: number; status: "queued" | "placed" | "done" | "unanswered"; messageId: string };
export type AgentSubmission = { id: number; status: "queued" | "placed" | "done" | "unanswered"; messageId: string; attachmentIds?: string[] };
export type AgentRoomClientOptions = { baseUrl?: string; token?: () => string | null; fetch?: typeof globalThis.fetch };
export class AgentRoomClient {
private readonly baseUrl: string;
@@ -141,7 +142,7 @@ export class AgentRoomClient {
const headers = new Headers(init.headers);
const token = this.token();
if (token) headers.set("Authorization", `Bearer ${token}`);
if (init.body !== undefined) headers.set("Content-Type", "application/json");
if (init.body !== undefined && !(init.body instanceof FormData)) headers.set("Content-Type", "application/json");
const response = await this.fetcher(`${this.baseUrl}/v1/agent${path}`, { ...init, headers });
let value: unknown;
try { value = await response.json(); }
@@ -160,15 +161,30 @@ export class AgentRoomClient {
markRead(cursor: string, signal?: AbortSignal): Promise<AgentReadState> {
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor) }), signal });
}
async submit(content: string, requestId: string): Promise<AgentSubmission> {
const receipt = object(await this.request<unknown>("/messages", { method: "POST", body: JSON.stringify({ content, requestId }) }));
async submit(content: string, requestId: string, attachments: AgentAttachment[] = []): Promise<AgentSubmission> {
const receipt = object(await this.request<unknown>("/messages", { method: "POST", body: JSON.stringify({ content, requestId, ...(attachments.length ? { attachments: attachments.map(({ id }) => ({ id })) } : {}) }) }));
// An HTTP success alone does not acknowledge delivery. Keep the original
// outbox receipt key retryable if a proxy or upstream returns malformed data.
if (!receipt || typeof receipt.id !== "number" || !Number.isSafeInteger(receipt.id) || receipt.id <= 0
|| typeof receipt.status !== "string" || !["queued", "placed", "done", "unanswered"].includes(receipt.status)
|| receipt.messageId !== `inbound:sybil:${requestId}`) throw new Error("Agent API returned an invalid message receipt. Retry to confirm delivery.");
|| receipt.messageId !== `inbound:sybil:${requestId}`
|| (attachments.length && JSON.stringify(receipt.attachmentIds) !== JSON.stringify(attachments.map(file => file.id)))) throw new Error("Agent API returned an invalid message receipt. Retry to confirm delivery.");
return receipt as AgentSubmission;
}
async upload(file: File, uploadId: string, signal?: AbortSignal): Promise<AgentAttachment> {
const body = new FormData(); body.append("uploadId", uploadId); body.append("file", file, file.name);
const value = await this.request<{ attachment: unknown }>("/attachments", { method: "POST", body, signal });
return parseAgentAttachment(value.attachment);
}
async download(attachment: AgentAttachment, signal?: AbortSignal): Promise<Blob> {
const file = parseAgentAttachment(attachment), headers = new Headers(), token = this.token();
if (token) headers.set("Authorization", `Bearer ${token}`);
const response = await this.fetcher(`${this.baseUrl}/v1/agent/attachments/${encodeURIComponent(file.id)}/content`, { headers, signal, cache: "no-store", redirect: "error" });
if (!response.ok) { const error = await response.json().catch(() => null); throw new Error(error?.message || "Attachment download failed."); }
const blob = await response.blob();
if (blob.size !== file.sizeBytes || blob.size > 8 * 1024 * 1024) throw new Error("Attachment download was incomplete.");
return blob;
}
websocketUrl(pageUrl: string): string {
const url = new URL(`${this.baseUrl}/v1/agent/ws`, pageUrl);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
+81
View File
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
// Run against a local Vite server with an existing Playwright/Chromium installation:
// PLAYWRIGHT_MODULE=/path/to/playwright-core/index.mjs CHROME_EXECUTABLE=/path/to/chrome
// WEB_TEST_URL=http://127.0.0.1:5192 node tests/agent-attachments.browser.mjs
// Every API and Agent socket is mocked; this does not contact the deployed runtime.
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright-core');
const target = new URL(process.env.WEB_TEST_URL || 'http://127.0.0.1:5192/');
assert.ok(target.protocol === 'http:' && ['localhost','127.0.0.1','[::1]'].includes(target.hostname), 'Use a loopback development server');
const browser=await chromium.launch({executablePath:process.env.CHROME_EXECUTABLE || undefined,headless:true,
...(process.env.BROWSER_NO_SANDBOX === '1' ? {args:['--no-sandbox']} : {})});
const context=await browser.newContext({viewport:{width:1200,height:900},permissions:['clipboard-write']});
await context.addInitScript(()=>localStorage.setItem('sybil_admin_token','browser-fixture-token'));
const page=await context.newPage(), errors=[], uploads=[], sends=[], downloads=[];
page.on('pageerror',e=>errors.push(e.stack || e.message));
const cap={version:1,maxCount:8,maxTotalBytes:33554432,maxImageBytes:6291456,maxFileBytes:8388608,imageMimeTypes:['image/png','image/jpeg','image/gif','image/webp'],quotaBytes:1073741824,usedBytes:0,remainingBytes:1073741824,maxConcurrentUploads:2,maxConcurrentDecodes:1};
const idle={state:'idle',isThinking:false,queued:0,backgroundWorkers:0,tools:[],queuedMessages:[]};
let failedUpload=true, failedSend=true, live, cursor=0;const history=[];
const image=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a2ioAAAAASUVORK5CYII=','base64');
const attachment=(name='notes.txt')=>({id:`att_12345678-1234-4234-8234-${name==='picture.png'?'123456789abd':'123456789abc'}`,filename:name,mimeType:name==='picture.png'?'image/png':'text/plain',kind:name==='picture.png'?'image':'text',sizeBytes:name==='picture.png'?image.length:4,sha256:createHash('sha256').update(name==='picture.png'?image:Buffer.from('data')).digest('hex'),workspacePath:`/data/workspace/uploads/test/${name}`});
await page.route('**/api/**',async route=>{
const req=route.request(),url=new URL(req.url()),p=url.pathname,m=req.method();let value;
if(p.startsWith('/api/v1/agent'))assert.equal(req.headers().authorization,'Bearer browser-fixture-token');
if(p.endsWith('/attachments')&&m==='POST'){
const body=req.postDataBuffer().toString();uploads.push(body);
if(failedUpload){failedUpload=false;await route.fulfill({status:507,json:{message:'Storage quota reached',code:'attachment_quota_exceeded'}});return;}
await route.fulfill({status:201,json:{attachment:attachment(body.includes('picture.png')?'picture.png':'notes.txt')}});return;
}
if(p.endsWith('/content')){downloads.push(req.url());await route.fulfill({contentType:p.includes('789abd')?'image/png':'text/plain',body:p.includes('789abd')?image:'data'});return;}
if(p.endsWith('/agent/messages')&&m==='POST'){
const body=req.postDataJSON();sends.push(body);
if(failedSend){failedSend=false;await route.fulfill({status:503,json:{message:'Temporarily unavailable'}});return;}
const files=(body.attachments||[]).map(f=>attachment(f.id.endsWith('abd')?'picture.png':'notes.txt'));
const row={id:`inbound:sybil:${body.requestId}`,role:'user',createdAt:new Date().toISOString(),content:body.content||`Attached: ${files.map(f=>f.filename).join(', ')}.`,metadata:{kind:'message',status:'complete',entryId:++cursor,agentAttachments:files}};history.push(row);
live?.send(JSON.stringify({type:'messages',conversationId:'1',messages:[row],cursor:String(cursor),beforeCursor:String(cursor),hasMore:false}));
await route.fulfill({status:202,json:{id:cursor,status:'queued',messageId:row.id,attachmentIds:files.map(f=>f.id)}});return;
}
if(p.endsWith('/agent/messages'))value={conversationId:'1',messages:history,cursor:String(cursor),beforeCursor:null,hasMore:false};
else if(p.endsWith('/read-state'))value={cursor:String(cursor),updatedAt:null};
else if(p.endsWith('/agent'))value={id:'1',title:'Sybil Agent',activity:idle,capabilities:{attachments:cap},protocol:'pi-room.v1',websocket:'/v1/agent/ws'};
else if(p.includes('session'))value={mode:'token',authenticated:true};
else if(p.endsWith('/tools')||p.endsWith('/chat-tools'))value={tools:[]};
else if(p.endsWith('/models'))value={models:[],providers:{}};
else if(p.includes('stream'))value=[];
else value=[];
await route.fulfill({json:value});
});
await page.routeWebSocket('**/api/v1/agent/ws',socket=>{live=socket;socket.onMessage(raw=>{if(JSON.parse(String(raw)).type==='auth')socket.send(JSON.stringify({type:'ready',conversationId:'1'}));});});
try{
await page.goto(target.href,{waitUntil:'networkidle'});
await page.locator('[data-testid="agent-sidebar-item"]').click();
await page.getByRole('button',{name:'Attach files',exact:true}).waitFor();
const picker=page.getByLabel('Choose attachments');
await picker.setInputFiles({name:'notes.txt',mimeType:'text/plain',buffer:Buffer.from('data')});
await page.getByText('Storage quota reached',{exact:true}).waitFor();
assert.equal(await page.getByRole('button',{name:'Send message',exact:true}).isDisabled(),true);
await page.getByRole('button',{name:'Retry upload',exact:true}).click();
await page.getByText('Uploaded',{exact:true}).waitFor();
await page.getByRole('button',{name:/^New chat/}).first().click();await page.locator('.agent-room__composer-area').waitFor({state:'detached'});await page.locator('[data-testid="agent-sidebar-item"]').click();await page.getByText('Uploaded',{exact:true}).waitFor();
await page.reload({waitUntil:'networkidle'});await page.locator('[data-testid="agent-sidebar-item"]').click();await page.getByText('Uploaded',{exact:true}).waitFor();
const uuid=body=>body.match(/name="uploadId"\r\n\r\n([^\r]+)/)?.[1];assert.equal(uuid(uploads[0]),uuid(uploads[1]));
await page.getByRole('button',{name:'Remove notes.txt'}).click();assert.equal(await page.locator('.agent-room__upload').count(),0);
await picker.setInputFiles([{name:'notes.txt',mimeType:'text/plain',buffer:Buffer.from('data')},{name:'picture.png',mimeType:'image/png',buffer:image}]);
await page.waitForFunction(()=>document.querySelectorAll('.agent-room__upload [role="status"]').length===2&&Array.from(document.querySelectorAll('.agent-room__upload [role="status"]')).every(e=>e.textContent==='Uploaded'));
await page.getByRole('button',{name:'Send message',exact:true}).click();await page.getByRole('button',{name:'Retry message',exact:true}).waitFor();
assert.deepEqual(sends[0].attachments,[{id:attachment().id},{id:attachment('picture.png').id}]);assert.equal(sends[0].content,'');
await page.reload({waitUntil:'networkidle'});await page.locator('[data-testid="agent-sidebar-item"]').click();await page.getByRole('button',{name:'Retry message',exact:true}).click();
await page.getByText('Attached: notes.txt, picture.png.',{exact:true}).waitFor();assert.equal(sends[1].requestId,sends[0].requestId);
await page.getByRole('button',{name:'Preview',exact:true}).click();await page.locator('.agent-attachment__preview').waitFor();assert.ok((await page.locator('.agent-attachment__preview').getAttribute('src')).startsWith('blob:'));
await page.getByRole('button',{name:'Hide preview',exact:true}).click();assert.equal(await page.locator('.agent-attachment__preview').count(),0);
const download=page.waitForEvent('download');await page.getByRole('button',{name:'Download',exact:true}).first().click();assert.equal((await download).suggestedFilename(),'notes.txt');assert.ok(downloads.every(url=>!url.includes('token')));
await page.getByRole('button',{name:'Copy path for notes.txt'}).click();
// Real paste/drop DOM events feed the same upload queue.
await page.getByRole('textbox',{name:'Message Sybil Agent'}).evaluate(el=>{const data=new DataTransfer();data.items.add(new File(['data'],'notes.txt',{type:'text/plain'}));el.dispatchEvent(new ClipboardEvent('paste',{clipboardData:data,bubbles:true,cancelable:true}));});
await page.getByText('Uploaded',{exact:true}).waitFor();await page.getByRole('button',{name:'Remove notes.txt'}).click();
await page.locator('.agent-room__composer-area').evaluate(el=>{const data=new DataTransfer();data.items.add(new File(['data'],'notes.txt',{type:'text/plain'}));el.dispatchEvent(new DragEvent('drop',{dataTransfer:data,bubbles:true,cancelable:true}));});
await page.getByText('Uploaded',{exact:true}).waitFor();
await page.setViewportSize({width:390,height:844});if(process.env.ATTACHMENT_SCREENSHOT)await page.screenshot({path:process.env.ATTACHMENT_SCREENSHOT});
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>window.innerWidth),false);
assert.deepEqual(errors,[]);console.log('PASS browser picker/retry/quota/remove, file-only send/reload, authenticated preview/download, copy, paste/drop, mobile layout');
}finally{await context.close();await browser.close();}
+101
View File
@@ -0,0 +1,101 @@
import test from 'node:test';
import { createHash, randomUUID } from 'node:crypto';
import assert from 'node:assert/strict';
import { createAgentUploadQueue, agentAttachments, parseAgentAttachment } from '../src/lib/agent-attachments.ts';
import { AgentRoomClient } from '../src/lib/agent-room.ts';
import { createAgentOptimisticOutbox } from '../src/lib/agent-optimistic-outbox.ts';
import { agentTranscriptMessages } from '../src/lib/agent-room-presentation.ts';
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'notes.txt',mimeType:'text/plain',sizeBytes:4,sha256:createHash('sha256').update('data').digest('hex'),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt' };
const cap = {version:1,maxCount:8,maxTotalBytes:32*1024*1024,maxImageBytes:6*1024*1024,maxFileBytes:8*1024*1024,imageMimeTypes:['image/png','image/jpeg','image/gif','image/webp']};
const tick=()=>new Promise(r=>setTimeout(r,0));
const waitFor=async check=>{const until=Date.now()+5000;while(!check()){assert.ok(Date.now()<until,'async condition timed out');await tick();}};
test('composer gates unsupported servers, count/type/size and batches before upload; preserves retry ID and remove races',async()=>{
let capability, attempts=[], next=0, changed=0, finish;
const queue=createAgentUploadQueue({capability:()=>capability,newId:()=>`upload-${++next}`,changed:()=>changed++,upload:async(_file,id,signal)=>{attempts.push({id,signal});if(attempts.length===1)throw new Error('Quota full');return new Promise(resolve=>finish=resolve);}});
const input=new File(['data'],'notes.txt',{type:'text/plain'});
assert.throws(()=>queue.add([input]),/not available/);capability=cap;
assert.throws(()=>queue.add(Array(9).fill(input)),/8 files/);
assert.throws(()=>queue.add([new File([new Uint8Array(6*1024*1024+1)],'big.png',{type:'image/png'})]),/6 MiB/);
assert.throws(()=>queue.add(Array(5).fill(new File([new Uint8Array(7*1024*1024)],'large.bin'))),/32 MiB/);
queue.add([input]);assert.throws(()=>queue.ready(),/Wait/);await waitFor(()=>queue.snapshot()[0].status==='failed');
assert.equal(queue.snapshot()[0].status,'failed');assert.match(queue.snapshot()[0].error,/Quota/);
queue.retry('upload-1');await waitFor(()=>attempts.length===2);assert.deepEqual(attempts.map(a=>a.id),['upload-1','upload-1']);
queue.remove('upload-1');assert.equal(attempts.at(-1).signal.aborted,true);finish(file);await tick();assert.deepEqual(queue.ready(),[]);
assert.ok(changed>0);queue.dispose();
});
test('composer serializes transfers, keeps ready descriptors, and clears without orphaning pending state',async()=>{
const done=[];let count=0;
const queue=createAgentUploadQueue({capability:()=>cap,changed:()=>{},newId:()=>`id-${++count}`,upload:async()=>new Promise(r=>done.push(r))});
queue.add([new File(['data'],'notes.txt'),new File(['data'],'notes.txt')]);await waitFor(()=>done.length===1);assert.deepEqual(queue.snapshot().map(r=>r.status),['uploading','waiting']);
done[0](file);await waitFor(()=>done.length===2);assert.deepEqual(queue.snapshot().map(r=>r.status),['ready','uploading']);done[1]({...file,id:file.id.replace('12345678','12345679')});await waitFor(()=>queue.snapshot().every(row=>row.status==='ready'));assert.equal(queue.ready().length,2);queue.clear();assert.deepEqual(queue.ready(),[]);queue.dispose();
});
test('transport uses auth headers/FormData, ID-only send and checked receipt, authenticated download with no token URLs',async()=>{
const calls=[];let acknowledge=true;
const client=new AgentRoomClient({baseUrl:'/api',token:()=> 'browser-token',fetch:async(url,init)=>{
calls.push({url,init});assert.ok(!url.includes('token'));assert.equal(init.headers.get('Authorization'),'Bearer browser-token');
if(url.endsWith('/attachments')){assert.ok(init.body instanceof FormData);assert.equal(init.headers.get('Content-Type'),null);assert.equal(init.body.get('uploadId'),'uuid');return Response.json({attachment:file},{status:201});}
if(url.endsWith('/content'))return new Response('data',{headers:{'content-type':'text/plain'}});
const body=JSON.parse(init.body);assert.deepEqual(body,{content:'',requestId:'send-id',attachments:[{id:file.id}]});
return Response.json({id:1,status:'queued',messageId:'inbound:sybil:send-id',...(acknowledge?{attachmentIds:[file.id]}:{})});
}});
assert.deepEqual(await client.upload(new File(['data'],'notes.txt'),'uuid'),file);
assert.equal((await client.submit('','send-id',[file])).status,'queued');acknowledge=false;await assert.rejects(client.submit('','send-id',[file]),/invalid message receipt/);
assert.equal(await (await client.download(file)).text(),'data');assert.equal(calls.at(-1).init.cache,'no-store');assert.equal(calls.at(-1).init.redirect,'error');
});
test('file-only outbox survives reload and retry without sending paths, confirms matching attachment echoes and retains tool filtering',async()=>{
let saved, call, fail=true;
const options={storage:{read:()=>saved,write:rows=>saved=structuredClone(rows)},changed:()=>{},newId:()=> 'request',submit:async(content,requestId,attachments)=>{call={content,requestId,attachments};if(fail)throw new Error('lost response');return {status:'queued',messageId:'inbound:sybil:request'};}};
let outbox=createAgentOptimisticOutbox(options);await assert.rejects(outbox.send('',[file]));assert.deepEqual(saved[0].attachments,[file]);
outbox=createAgentOptimisticOutbox(options);assert.equal(outbox.snapshot().length,1);fail=false;await outbox.retry('inbound:sybil:request');assert.equal(call.content,'');assert.deepEqual(call.attachments,[file]);
const row=outbox.display([])[0];assert.equal(row.content,'Attached: notes.txt.');assert.deepEqual(agentAttachments(row.metadata),[file]);
outbox.reconcile({messages:[{...row,metadata:{kind:'message',status:'complete'}}]});assert.equal(outbox.snapshot()[0].confirmed,false);
const mismatch=outbox.display([{...row,metadata:{kind:'message',status:'complete'}}])[0];assert.deepEqual(agentAttachments(mismatch.metadata),[file]);assert.equal(mismatch.metadata.status,'queued');
outbox.reconcile({messages:[{...row,metadata:{...row.metadata,status:'complete'}}]});assert.equal(outbox.snapshot()[0].confirmed,true);
const raw=[row,{...row,id:'tool',role:'tool',metadata:{kind:'tool_call'}}];assert.equal(agentTranscriptMessages(raw).length,1);assert.equal(raw.length,2);
assert.throws(()=>parseAgentAttachment({...file,workspacePath:'https://bad'}));
});
test('attachment drafts preserve completed receipts and unresolved IDs/hash across disposal/reload, verified reselection resumes without a new ID',async()=>{
let saved=[], attempts=[], complete;
const storage={read:()=>saved,write:rows=>saved=structuredClone(rows)};
const options={capability:()=>cap,storage,changed:()=>{},upload:async(_file,id,signal)=>{attempts.push({id,signal});return new Promise(r=>complete=r);}};
let queue=createAgentUploadQueue(options);queue.add([new File(['data'],'notes.txt',{type:'text/plain'})]);
await waitFor(()=>Boolean(complete));const original=attempts[0].id;assert.equal(saved[0].sha256,file.sha256);
queue.dispose();assert.equal(attempts[0].signal.aborted,true);
queue=createAgentUploadQueue(options);assert.equal(queue.snapshot()[0].status,'reselect');assert.equal(queue.snapshot()[0].uploadId,original);assert.throws(()=>queue.ready(),/Wait/);
await assert.rejects(queue.reselect(original,new File(['evil'],'notes.txt',{type:'text/plain'})),/different contents/);assert.equal(attempts.length,1);
await queue.reselect(original,new File(['data'],'notes.txt',{type:'text/plain'}));await waitFor(()=>attempts.length===2);assert.equal(attempts[1].id,original);
complete(file);await tick();assert.equal(queue.ready()[0].id,file.id);queue.dispose();
const restored=createAgentUploadQueue(options);assert.deepEqual(restored.ready(),[file]);assert.equal(attempts.length,2,'completed upload is never repeated');restored.clear();assert.deepEqual(saved,[]);
});
test('mismatched complete echoes cannot hide local attachment errors or the retry action',async()=>{
const box=createAgentOptimisticOutbox({storage:{read:()=>null,write:()=>{}},changed:()=>{},newId:()=> 'mismatch',submit:async()=>{throw new Error('quota failure');}});
await assert.rejects(box.send('caption',[file]));const initial=box.display([])[0];
for(const attachments of [undefined,[{...file,id:file.id.replace('12345678','12345679')}]]){
const echo={...initial,metadata:{kind:'message',status:'complete',...(attachments?{agentAttachments:attachments}:{})}};
box.reconcile({messages:[echo]});const shown=box.display([echo])[0];assert.equal(shown.metadata.status,'failed');assert.equal(shown.metadata.error,'quota failure');assert.deepEqual(shown.metadata.agentAttachments,[file]);assert.equal(box.snapshot()[0].confirmed,false);
}
});
test('storage refusal prevents uploads and outbox handoff; no attachment draft is cleared before persistence',async()=>{
let uploads=0;const queue=createAgentUploadQueue({capability:()=>cap,storage:{read:()=>null,write:()=>{throw new Error('storage full');}},changed:()=>{},upload:async()=>{uploads++;return file;}});
assert.throws(()=>queue.add([new File(['data'],'notes.txt')]),/storage full/);await tick();assert.equal(uploads,0);
let cleared=false;const box=createAgentOptimisticOutbox({storage:{read:()=>null,write:()=>{throw new Error('storage full');}},changed:()=>{},submit:async()=>({status:'queued'})});
await assert.rejects(box.send('draft',[file],()=>{cleared=true;}));assert.equal(cleared,false);
});
test('a late canceled queue completion cannot overwrite a restored draft',async()=>{
let saved=[];const pending=[];
const options={capability:()=>cap,changed:()=>{},storage:{read:()=>saved,write:rows=>saved=structuredClone(rows)},upload:async()=>new Promise(resolve=>pending.push(resolve))};
const old=createAgentUploadQueue(options);old.add([new File(['data'],'notes.txt',{type:'text/plain'})]);await waitFor(()=>pending.length===1);
const oldId=old.snapshot()[0].uploadId;old.dispose();
const restored=createAgentUploadQueue(options);restored.remove(oldId);restored.add([new File(['data'],'notes.txt',{type:'text/plain'})]);await waitFor(()=>pending.length===2);
pending[1](file);await tick();const current=structuredClone(saved);assert.equal(current[0].status,'ready');assert.notEqual(current[0].uploadId,oldId);
pending[0](file);await tick();assert.deepEqual(saved,current);assert.deepEqual(restored.ready(),[file]);restored.dispose();
});
+1 -1
View File
@@ -6,7 +6,7 @@
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,