import type { Message } from "./api"; 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; }; export type AgentOutboxStorage = { read(): unknown; write(rows: AgentOutboxRow[]): void; legacyRows?(): unknown; legacyIds?(): unknown; }; type Receipt = { status: string; messageId?: string }; type RoomMessages = { messages: readonly Message[]; activity?: { queuedMessages?: readonly Message[] } | null }; const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, 240); const info = (message: Message) => message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata) ? message.metadata as Record : {}; const idFor = (requestId: string) => `inbound:sybil:${requestId}`; const validRequestId = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 256; /** Local rows use the server's identity from their first render through final echo. */ export function createAgentOptimisticOutbox(options: { submit(content: string, requestId: string): Promise; storage: AgentOutboxStorage; changed(): void; newId?: () => string; now?: () => number; }) { const newId = options.newId || (() => crypto.randomUUID()); const now = options.now || Date.now; const rows = new Map(); const inFlight = new Map>(); const rejectedStorage = new Set(); const confirmedEchoes = new Map(); let latestTime = 0; const timestamp = () => { latestTime = Math.max(now(), latestTime + 1); return new Date(latestTime).toISOString(); }; 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; 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, ...(typeof value.error === "string" && value.error ? { error: value.error.slice(0, 240) } : {}) }; rows.set(row.id, row); } } else { // Join the former UI-only failed cards to the old sender's receipt keys. // Neither representation is retried automatically during migration. const oldRows = options.storage.legacyRows?.(); const oldIds = options.storage.legacyIds?.(); const ids = new Map(); if (Array.isArray(oldIds)) for (const value of oldIds) { if (value && typeof value.content === "string" && validRequestId(value.requestId)) ids.set(value.content, value.requestId); } const contents = new Map(); if (Array.isArray(oldRows)) for (const value of oldRows) { if (value && typeof value.content === "string" && value.content.trim()) contents.set(value.content, value); } for (const content of ids.keys()) if (!contents.has(content)) contents.set(content, { content }); for (const value of [...contents.values()].slice(0, 100)) { const requestId = ids.get(value.content) || newId(); rows.set(idFor(requestId), { id: idFor(requestId), requestId, content: value.content, createdAt: timestamp(), status: value.status === "failed" ? "failed" : "unconfirmed", seenOnServer: false, confirmed: false, error: typeof value.error === "string" && value.error ? value.error.slice(0, 240) : ids.has(value.content) ? "Delivery was interrupted; retry to confirm." : "Restored draft; check the conversation before retrying." }); } } const persist = () => { const pending = [...rows.values()].filter(row => !row.confirmed && !rejectedStorage.has(row.id)); if (pending.length > 100 || JSON.stringify(pending).length > 2 * 1024 * 1024) throw new Error("Too many pending agent messages. Retry existing messages before sending more."); options.storage.write(pending.map(row => ({ ...row }))); }; const saveBestEffort = () => { try { persist(); } catch { /* Existing receipt IDs stay in memory; acknowledged messages remain sent. */ } }; const publish = () => options.changed(); const dispatch = (row: AgentOutboxRow): Promise => { const running = inFlight.get(row.id); if (running) return running; if (row.confirmed || row.seenOnServer) return Promise.resolve(); row.status = "sending"; delete row.error; rejectedStorage.delete(row.id); try { persist(); } catch (error) { // A refused new draft must not prevent older persisted drafts from // draining their outbox. The refused row remains visibly retryable. rejectedStorage.add(row.id); row.status = "failed"; row.error = errorText(error); publish(); return Promise.reject(error); } // 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 => { if (receipt.messageId && receipt.messageId !== row.id) throw new Error("Agent returned a different message identity."); if (!row.confirmed) { row.seenOnServer = true; row.status = receipt.status === "queued" ? "queued" : "complete"; delete row.error; saveBestEffort(); publish(); } }).catch(error => { // A canonical echo wins over a lost HTTP response. The message has already // been admitted, so a transport error must not paint it failed or resend it. if (row.confirmed || row.seenOnServer) return; row.status = "failed"; row.error = errorText(error); saveBestEffort(); publish(); throw error; }).finally(() => { inFlight.delete(row.id); }); inFlight.set(row.id, pending); return pending; }; // Complete migration even when the user leaves restored drafts untouched: // their newly assigned identity/time must survive the next reload too. saveBestEffort(); return { send(content: string): Promise { if (!content.trim()) 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 }; rows.set(row.id, row); return dispatch(row); }, retry(messageId: string): Promise { 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)); let changed = false; for (const row of rows.values()) { if (committed.has(row.id)) { confirmedEchoes.set(row.id, committed.get(row.id)!); if (!row.confirmed || row.status !== "complete" || row.error) { row.confirmed = true; row.seenOnServer = true; row.status = "complete"; delete row.error; changed = true; } } else if (!row.confirmed && queued.has(row.id) && (row.status !== "queued" || !row.seenOnServer || row.error)) { row.seenOnServer = true; row.status = "queued"; delete row.error; changed = true; } } if (changed) { saveBestEffort(); publish(); } }, display(serverMessages: readonly Message[]): Message[] { const displayed = new Map(serverMessages.map(message => { const row = rows.get(message.id); if (!row) return [message.id, message] as const; const serverInfo = info(message); const status = serverInfo.status === "complete" || row.confirmed ? "complete" : row.status; return [message.id, { ...message, createdAt: row.createdAt, metadata: { ...serverInfo, status, ...(row.error && status !== "complete" ? { error: row.error } : {}) } }] as const; })); for (const row of rows.values()) if (!displayed.has(row.id)) { 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 } : {}) }, }); } return [...displayed.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); }, snapshot(): AgentOutboxRow[] { return [...rows.values()].map(row => ({ ...row })); }, }; }