feat(agent): add authenticated durable uploads to backend and web
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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. */ } },
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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:";
|
||||
|
||||
@@ -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();}
|
||||
@@ -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
@@ -6,7 +6,7 @@
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
||||
Reference in New Issue
Block a user