diff --git a/docs/api/rest.md b/docs/api/rest.md index 6413645..5097897 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -605,6 +605,28 @@ durable entries and can advance without visible messages. Use returned cursors; do not derive them from message IDs. `limit` bounds source entries, not displayed messages. +The web Agent starts with one cursorless `limit=100` window and renders it in +chronological order, initially positioned at its newest end. `hasMore` on this +window or a `before` page means **older** source entries exist; on an `after` +page it means **newer** entries remain. Each **Load older messages** action fetches +one `before=&limit=100` page, even if that page has no visible rows. +Older pages do not advance the live replay/read cursor or overwrite newer copies +of the same message. Initial loading never walks forward from zero or drains +older history. Reconnect recovery still follows `after` from the accepted live +cursor. Aborted/replaced room sessions discard late responses. + +Web unread counts cover loaded public replies. A `+` suffix (or a dot when no +loaded reply is known unread) indicates that earlier messages may also be unread; +the API does not supply an exact total for unloaded history. Observing the latest +end in a focused visible conversation marks it read through that rendered cursor, +including earlier unloaded history. Fetching or reading older pages does not +acknowledge newer replies. An internal-only window with no rendered public +messages does not acknowledge history; older loading remains an explicit action. +Initial source-entry/network work is bounded; retained +messages/DOM grow with explicit older loads and live updates. Offscreen web rows +use CSS `content-visibility: auto`, which skips rendering work while retaining +chronological DOM, selection, find and accessibility; it is not DOM virtualization. + ### `POST /v1/agent/messages` Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`. diff --git a/docs/api/streaming-chat.md b/docs/api/streaming-chat.md index c3309e7..4ba850e 100644 --- a/docs/api/streaming-chat.md +++ b/docs/api/streaming-chat.md @@ -337,6 +337,18 @@ 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. +The web client first accepts source-bound read state and one recent REST history +window, then authenticates this socket with that window's `cursor`. Initial +`hasMore` refers to older history and does not trigger a forward replay from zero. +Older pages load on demand using a separate `beforeCursor`; delayed older pages +preserve live edits and never change the socket replay position. Reconnects and +foreground catch-up use the last accepted live cursor as before. Incoming output +follows the viewport only while it is pinned to the newest end; older prepends and +dynamic heights preserve the reader's keyed row. Only a focused, visible, settled +latest view acknowledges the rendered cursor, marking the conversation read +through that point. See [web history windows and unread counts](rest.md#get-v1agentmessages) +for unloaded-history count semantics and rendering limits. + ### 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. diff --git a/web/src/App.tsx b/web/src/App.tsx index 42ac1ee..faf2a31 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3554,7 +3554,7 @@ export default function App() { type="button" data-testid="agent-sidebar-item" aria-current={isAgentMode ? "page" : undefined} - aria-label={`Sybil Agent${agentRoom.unreadCount ? `, ${agentRoom.unreadCount} unread messages` : ""}${isAgentBusy ? ", working" : ""}`} + aria-label={`Sybil Agent${agentRoom.unreadCount ? `, ${agentRoom.unreadCount}${agentRoom.unreadCountIsPartial ? " or more" : ""} unread messages` : agentRoom.unreadCountIsPartial ? ", earlier messages may be unread" : ""}${isAgentBusy ? ", working" : ""}`} onClick={() => { setContextMenu(null); setError(null); @@ -3580,9 +3580,9 @@ export default function App() { Sybil Agent {isAgentBusy ? : null} - {agentRoom.unreadCount > 0 ? ( + {agentRoom.unreadCount > 0 || agentRoom.unreadCountIsPartial ? ( - {agentRoom.unreadCount} + {agentRoom.unreadCount ? `${agentRoom.unreadCount}${agentRoom.unreadCountIsPartial ? "+" : ""}` : "•"} ) : null} diff --git a/web/src/components/agent/agent-conversation.css b/web/src/components/agent/agent-conversation.css index c74eb9e..98d66ef 100644 --- a/web/src/components/agent/agent-conversation.css +++ b/web/src/components/agent/agent-conversation.css @@ -45,6 +45,32 @@ .agent-room__entry { flex-shrink:0; min-width:0; + /* Retain chronological DOM/selection/a11y; skip offscreen layout and paint. + This is not DOM virtualization. Remember measured heights when skipped. */ + content-visibility:auto; + contain-intrinsic-size:auto 160px; +} + +.agent-room__history { + display:grid; + justify-items:center; + gap:8px; + flex-shrink:0; + color:hsl(255 19% 70%); + font-size:12px; +} + +.agent-room__history button { + border:1px solid hsl(267 75% 73%/.2); + border-radius:8px; + padding:8px 14px; + background:hsl(261 52% 20%/.56); + cursor:pointer; +} + +.agent-room__history button:disabled { + cursor:wait; + opacity:.65; } .agent-room__day { diff --git a/web/src/components/agent/agent-conversation.tsx b/web/src/components/agent/agent-conversation.tsx index b7557ee..e1e09a1 100644 --- a/web/src/components/agent/agent-conversation.tsx +++ b/web/src/components/agent/agent-conversation.tsx @@ -1,4 +1,5 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; +import { memo } from "preact/compat"; import { 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"; @@ -49,7 +50,7 @@ function toolName(value: unknown) { return typeof value === "string" ? value.replace(/_/g, " ") : "Tool"; } -function AgentBubble({ message, animate, onRetry, download }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"] }) { +const AgentBubble = memo(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"; @@ -77,7 +78,7 @@ function AgentBubble({ message, animate, onRetry, download }: { message: Message ); -} +}); export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Props) { const [draft, setDraft] = useState(savedDraft); @@ -98,6 +99,7 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro const entryAnimationsRef = useRef(new Set()); const typingHeightRef = useRef(0); const historyReadyRef = useRef(!room.isLoading); + const historyBeforeRef = useRef(room.historyBeforeCursor); const scrollerRef = useRef(null); const timelineRef = useRef(null); const textareaRef = useRef(null); @@ -130,9 +132,20 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro const element = scrollerRef.current; if (!element || pinnedRef.current) { anchorRef.current = null; return; } const top = element.getBoundingClientRect().top; - // Anchor to layout, independently of the bubble's entrance transform. - const node = Array.from(element.querySelectorAll("[data-agent-entry-id]")).find(item => item.getBoundingClientRect().bottom > top + 8); - anchorRef.current = node ? { id: node.dataset.agentEntryId!, offset: node.getBoundingClientRect().top - top } : null; + // Find the first visible row without laying out every offscreen message. + const entries = element.querySelectorAll("[data-agent-entry-id]"); + // Probe row boxes without measuring every skipped subtree in a long history. + let low = 0, high = entries.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (entries[mid].getBoundingClientRect().bottom <= top + 8) low = mid + 1; + else high = mid; + } + const node = entries[low]; + // The first row can lose its day separator when older rows arrive. Anchor + // the message itself so that changing that separator cannot shift its text. + const message = node?.querySelector("[data-agent-message-id]"); + anchorRef.current = node && message ? { id: node.dataset.agentEntryId!, offset: message.getBoundingClientRect().top - top } : null; }; const restoreViewport = () => { const element = scrollerRef.current; @@ -140,11 +153,14 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro if (pinnedRef.current) element.scrollTop = element.scrollHeight; else if (anchorRef.current) { const anchor = anchorRef.current; - const node = Array.from(element.querySelectorAll("[data-agent-entry-id]")).find(item => item.dataset.agentEntryId === anchor.id); - if (node) element.scrollTop += node.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset; + const node = element.querySelector(`[data-agent-entry-id="${CSS.escape(anchor.id)}"]`); + const message = node?.querySelector("[data-agent-message-id]"); + if (message) element.scrollTop += message.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset; } restoredScrollTopRef.current = element.scrollTop; - rememberAnchor(); + // Keep the user's key/offset through successive intrinsic-size corrections. + // Recapturing here can anchor a newly revealed older row while its neighbors + // are still being measured, causing the original reading position to drift. scheduleRead(); }; const jumpToLatest = () => { @@ -195,7 +211,7 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro useLayoutEffect(() => { restoreViewport(); - }, [room.messages, room.isTyping, room.activity?.state, room.isLoading, isVisible]); + }, [room.messages, room.isTyping, room.activity?.state, room.isLoading, room.isLoadingOlder, room.olderError, room.hasOlder, isVisible]); useEffect(() => { mountedRef.current = true; @@ -229,10 +245,10 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro setDraft(value); }; - const retryMessage = (id: string) => { + const retryMessage = useCallback((id: string) => { // Admission errors remain attached to their own bubble. void room.retryMessage(id).catch(() => {}); - }; + }, [room.retryMessage]); const submit = () => { const content = draftRef.current.trim(); if ((!content && !uploads.length) || pendingUploads || (uploads.length && !room.attachmentCapability)) return; @@ -245,9 +261,14 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro const messages = useMemo(() => { const transcript = agentTranscriptMessages(room.messages); + const historyChanged = historyBeforeRef.current !== room.historyBeforeCursor; + historyBeforeRef.current = room.historyBeforeCursor; let previousDay = ""; - for (const message of transcript) { - if (!seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading) { + let lastSeen = -1; + transcript.forEach((message, index) => { if (seenMessageIdsRef.current.has(message.id)) lastSeen = index; }); + for (const [index, message] of transcript.entries()) { + // Historical prepends keep their measured space; only new tail rows enter. + if (index > lastSeen && !(historyChanged && lastSeen < 0) && !seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading) { enteringMessageIdsRef.current.add(message.id); } seenMessageIdsRef.current.add(message.id); @@ -263,7 +284,7 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro ); }); - }, [room.messages, room.isLoading, room.retryMessage, room.downloadAttachment]); + }, [room.messages, room.isLoading, room.historyBeforeCursor, room.retryMessage, room.downloadAttachment]); const activity = room.activity; const running = room.isTyping; @@ -309,6 +330,12 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro >
{room.isLoading && !messages.length ?
: null} + {room.hasOlder ?
+ + {room.olderError ? {room.olderError} Try loading again. : null} +
: null} {messages} {running ?
{workingLabel}{(activity?.backgroundWorkers || 0) > 1 ? {activity!.backgroundWorkers} workers : null}
: null} {!running && waiting && activity?.wait ?
Checking back at {timeFormat.format(new Date(activity.wait.due))}
: null} diff --git a/web/src/hooks/use-agent-room.ts b/web/src/hooks/use-agent-room.ts index 5e58411..00280a4 100644 --- a/web/src/hooks/use-agent-room.ts +++ b/web/src/hooks/use-agent-room.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks" import { getApiBaseUrl, getAuthToken, type Message } from "@/lib/api"; import { AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, agentAuthFrame, agentDisplayMessages, agentUnreadCount, - applyAgentEvent, applyAgentReadState, + applyAgentEvent, applyAgentReadState, applyAgentHistoryPage, agentUnreadCountIsPartial, createAgentReadSession, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory, type AgentActivity, type AgentRoomState, } from "@/lib/agent-room"; @@ -16,6 +16,7 @@ export type AgentRoomConnection = "connecting" | "connected" | "reconnecting" | export type AgentRoomHookResult = { messages: Message[]; activity: AgentActivity | null; unreadCount: number; connection: AgentRoomConnection; error: string | null; isLoading: boolean; isTyping: boolean; + hasOlder: boolean; historyBeforeCursor: string | null; isLoadingOlder: boolean; olderError: string | null; loadOlder(): void; unreadCountIsPartial: boolean; attachmentCapability?: AgentAttachmentCapability; uploadAttachment(file: File, uploadId: string, signal: AbortSignal): Promise; downloadAttachment(file: AgentAttachment, signal?: AbortSignal): Promise; @@ -33,6 +34,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult { const [attachmentCapability, setAttachmentCapability] = useState(); const [error, setError] = useState(null); const [isLoading, setLoading] = useState(enabled); + const [isLoadingOlder, setLoadingOlder] = useState(false); + const [olderError, setOlderError] = useState(null); + const older = useRef<(() => Promise) | null>(null); const [revision, setRevision] = useState(0); const client = useRef(null); const [, renderUploads] = useState(0); @@ -81,9 +85,10 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult { } useEffect(() => { + setLoadingOlder(false); setOlderError(null); if (!enabled) { sourceMismatch.current = false; - client.current = null; mark.current = null; setAttachmentCapability(undefined); + client.current = null; mark.current = null; older.current = null; setAttachmentCapability(undefined); update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null); return; } @@ -96,6 +101,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult { let reconnectAttempt = 0; let started = false; let maintaining = false; + let loadingOlder = false; let lastPacket = Date.now(); let socketAuthenticated = false; let authFailed = false; @@ -110,7 +116,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult { setView({ room: next, isTyping }); }, }); - setLoading(current.current.conversationId === null); setConnection("connecting"); setError(null); + setLoading(!current.current.historyLoaded); setConnection("connecting"); setError(null); const reportError = (failure: unknown) => { if (active && !controller.signal.aborted) setError(errorText(failure)); }; const readSession = createAgentReadSession({ initial: current.current, @@ -119,6 +125,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult { sourceChanged = true; sourceMismatch.current = true; mark.current = null; clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout); presenter.dispose(); controller.abort(); socket?.close(); + older.current = null; setLoadingOlder(false); setError(failure.message); setConnection("offline"); setLoading(false); }, apply: read => { @@ -139,6 +146,22 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult { const descriptor = await api.descriptor(controller.signal); if (active && !sourceChanged) { setAttachmentCapability(descriptor.capabilities?.attachments?.version === 1 ? descriptor.capabilities.attachments : undefined); presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity }); } }; + older.current = async () => { + const before = current.current.beforeCursor; + if (!active || sourceChanged || !started || loadingOlder || !current.current.hasOlder || before === null) return; + loadingOlder = true; setLoadingOlder(true); setOlderError(null); + try { + const page = await api.history(before, controller.signal); + if (!active || sourceChanged || controller.signal.aborted) return; + presenter.update(state => applyAgentHistoryPage(state, page, before)); + presenter.flush(); + } catch (failure) { + if (active && !sourceChanged && !controller.signal.aborted) setOlderError(errorText(failure)); + } finally { + loadingOlder = false; + if (active && !sourceChanged) setLoadingOlder(false); + } + }; const connect = () => { if (!active || authFailed || sourceChanged) return; @@ -191,9 +214,11 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult { const start = async () => { try { await Promise.all([refreshRead(), refreshActivity()]); - // The first start begins at zero, not the latest page. Unread counts - // therefore include older public replies even after a long absence. - await catchUp(!started); + if (!current.current.historyLoaded) { + const page = await api.history(undefined, controller.signal); + if (!active || sourceChanged || controller.signal.aborted) return; + presenter.update(state => applyAgentHistoryPage(state, page)); + } else await catchUp(!started); if (!active || sourceChanged) return; presenter.flush(); started = true; setLoading(false); connect(); @@ -231,18 +256,19 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult { window.removeEventListener("focus", focus); document.removeEventListener("visibilitychange", focus); socket?.close(); if (client.current === api) client.current = null; - mark.current = null; + mark.current = null; older.current = null; }; }, [enabled, revision, update]); const renderedCursor = room.cursor; const renderedSourceId = room.sourceId; + const hasRenderedHistory = room.messages.some(message => message.role === "user" || message.role === "assistant"); const markRead = useCallback(() => { - if (!enabled || isLoading) return; + if (!enabled || isLoading || !hasRenderedHistory) return; // This callback belongs to this rendered snapshot. Reading current.current // here would incorrectly mark a socket message that has not rendered yet. mark.current?.(renderedCursor, renderedSourceId); - }, [enabled, isLoading, renderedCursor, renderedSourceId]); + }, [enabled, isLoading, hasRenderedHistory, renderedCursor, renderedSourceId]); 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, attachments, () => { uploadQueue.current!.clear(); onPersisted?.(); }); @@ -263,7 +289,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult { if (sourceMismatch.current) { window.location.reload(); return; } setRevision(value => value + 1); }, []); + const loadOlder = useCallback(() => { void older.current?.(); }, []); 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, + hasOlder: room.hasOlder, historyBeforeCursor: room.beforeCursor, isLoadingOlder, olderError, loadOlder, unreadCountIsPartial: agentUnreadCountIsPartial(room), attachmentCapability, uploadQueue: uploadQueue.current!, uploadAttachment, downloadAttachment, isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry }; } diff --git a/web/src/lib/agent-room.ts b/web/src/lib/agent-room.ts index 88d0ede..97c4b33 100644 --- a/web/src/lib/agent-room.ts +++ b/web/src/lib/agent-room.ts @@ -24,6 +24,7 @@ export type AgentReadState = { cursor: string; updatedAt: string | null; sourceI export type AgentRoomState = { conversationId: string | null; messages: Message[]; cursor: string; readCursor: string; activity: AgentActivity | null; + historyLoaded: boolean; beforeCursor: string | null; hasOlder: boolean; /** undefined before the first read snapshot; null pins a legacy source-less session. */ sourceId?: string | null; }; @@ -84,6 +85,7 @@ export function mergeAgentMessages(previous: readonly Message[], incoming: reado } export const createAgentRoomState = (): AgentRoomState => ({ conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null, + historyLoaded: false, beforeCursor: null, hasOlder: false, }); export class AgentReadSourceChangedError extends Error { constructor() { super("Agent history changed. Reload the page before marking messages read."); this.name = "AgentReadSourceChangedError"; } @@ -102,6 +104,25 @@ export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage): const cursor = page.cursor === null ? state.cursor : advanceAgentCursor(state.cursor, page.cursor); return { ...state, conversationId: page.conversationId, messages: mergeAgentMessages(state.messages, page.messages), cursor }; } +/** Recent/older windows have their own boundary; live replay never moves it. */ +export function applyAgentHistoryPage(state: AgentRoomState, page: AgentMessagesPage, before?: string): AgentRoomState { + // A retired or repeated older request cannot overwrite a newer window. + if (before !== undefined && before !== state.beforeCursor) return state; + const boundary = page.beforeCursor === null ? null : normalizeAgentCursor(page.beforeCursor); + const cursor = page.cursor === null ? null : normalizeAgentCursor(page.cursor); + if (page.hasMore && (boundary === null || boundary === "0" + || (before !== undefined && BigInt(boundary) >= BigInt(before)))) throw new Error("Agent older history did not advance its cursor."); + if (before !== undefined && ((cursor !== null && BigInt(cursor) >= BigInt(before)) + || (boundary !== null && BigInt(boundary) > BigInt(before)))) throw new Error("Agent older history returned an invalid boundary."); + const next = applyAgentPage(state, page); + return { ...next, historyLoaded: true, beforeCursor: boundary, hasOlder: page.hasMore, + // Older snapshots must not replace newer socket edits or advance delivery/read state. + ...(before === undefined ? {} : { cursor: state.cursor, messages: mergeAgentMessages(page.messages, state.messages) }), + }; +} +export function agentUnreadCountIsPartial(state: AgentRoomState): boolean { + return state.hasOlder && state.beforeCursor !== null && BigInt(state.readCursor) + 1n < BigInt(state.beforeCursor); +} export function applyAgentReadState(state: AgentRoomState, read: AgentReadState): AgentRoomState { const sourceId = readSource(state, read); return { ...state, sourceId, readCursor: advanceAgentCursor(state.readCursor, read.cursor) }; @@ -178,6 +199,9 @@ export class AgentRoomClient { messages(after: string, signal?: AbortSignal): Promise { return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal }); } + history(before?: string, signal?: AbortSignal): Promise { + return this.request(`/messages?${new URLSearchParams({ ...(before === undefined ? {} : { before: normalizeAgentCursor(before) }), limit: "100" })}`, { signal }); + } readState(signal?: AbortSignal): Promise { return this.request("/read-state", { signal }); } markRead(cursor: string, signal?: AbortSignal, sourceId?: string | null): Promise { return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor), ...(agentPushSourceId(sourceId) ? { sourceId } : {}) }), signal }); diff --git a/web/tests/agent-room.test.mjs b/web/tests/agent-room.test.mjs index ce166d0..3f072e9 100644 --- a/web/tests/agent-room.test.mjs +++ b/web/tests/agent-room.test.mjs @@ -4,6 +4,7 @@ import { AgentRoomClient, AgentReadSourceChangedError, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount, applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentReadSession, createAgentReadWriter, createAgentRoomState, mergeAgentMessages, normalizeAgentCursor, parseAgentRoomEvent, syncAgentHistory, + applyAgentHistoryPage, agentUnreadCountIsPartial, } from "../src/lib/agent-room.ts"; import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.ts"; @@ -32,7 +33,7 @@ test("tool starts and results merge across replay without downgrading completed assert.equal(messages[0].createdAt, start.createdAt); }); -test("initial synchronization reads every page including invisible entries and counts only unread assistant messages", async () => { +test("forward recovery reads every missing page including invisible entries and counts only unread assistant messages", async () => { const requests = []; const responses = [page("2", [message(1), message(2, "user")], true), page("4", [], true), page("7", [message(5), message(6, "tool"), message(7)], false)]; let state = createAgentRoomState(); @@ -47,6 +48,78 @@ test("initial synchronization reads every page including invisible entries and c assert.equal(agentUnreadCount(state), 1); }); +test("recent history is one cursorless window and older history uses the returned source boundary", async () => { + const requests = []; + const client = new AgentRoomClient({ fetch: async (url, options) => { + requests.push({ url, signal: options.signal }); + return new Response(JSON.stringify({ ...page("1200", [message(1200)], true), beforeCursor: "1101" })); + } }); + const controller = new AbortController(); + let state = applyAgentHistoryPage(createAgentRoomState(), await client.history(undefined, controller.signal)); + assert.equal(requests.length, 1, "hasMore must not drain old history"); + assert.equal(requests[0].url, "/api/v1/agent/messages?limit=100"); + assert.equal(requests[0].signal, controller.signal); + assert.equal(state.historyLoaded, true); + assert.equal(state.cursor, "1200"); + assert.equal(state.beforeCursor, "1101", "boundary is not derived from visible message 1200"); + assert.equal(state.hasOlder, true); + await client.history(state.beforeCursor, controller.signal); + assert.equal(requests[1].url, "/api/v1/agent/messages?before=1101&limit=100"); +}); + +test("delayed older pages preserve newer edits, live cursor, source and read state", () => { + let state = applyAgentHistoryPage(sourceRoom(), { ...page("100", [message(100)], true), beforeCursor: "90" }); + state = applyAgentPage(state, page("110", [message(100, "assistant", { content: "New streamed text" }), message(110)])); + const next = applyAgentHistoryPage(state, { ...page("89", [message(80), message(100, "assistant", { content: "Old snapshot" })], true), beforeCursor: "70" }, "90"); + assert.equal(next.messages.find(m => m.id === "entry:100").content, "New streamed text"); + assert.equal(next.cursor, "110"); + assert.equal(next.readCursor, "90"); + assert.equal(next.sourceId, sourceA); + assert.equal(next.beforeCursor, "70"); + assert.equal(next.hasOlder, true); + assert.equal(applyAgentHistoryPage(next, page("89", [message(80)], false), "90"), next, "retired boundary cannot replace the next window"); +}); + +test("empty internal pages advance older boundaries without chasing another page or moving read state", () => { + const initial = applyAgentHistoryPage(createAgentRoomState(), { ...page("1200", [], true), beforeCursor: "1101" }); + assert.equal(initial.historyLoaded, true); + assert.equal(initial.messages.length, 0); + const next = applyAgentHistoryPage(initial, { ...page("1100", [], true), beforeCursor: "1001" }, "1101"); + assert.equal(next.beforeCursor, "1001"); + assert.equal(next.cursor, "1200"); + assert.equal(next.readCursor, "0"); + assert.equal(next.hasOlder, true); + const end = applyAgentHistoryPage(next, { ...page("0"), beforeCursor: "1001" }, "1001"); + assert.equal(end.hasOlder, false); + assert.equal(end.cursor, "1200"); +}); + +test("malformed or stalled older pages cannot change history or delivery boundaries", () => { + const state = applyAgentHistoryPage(createAgentRoomState(), { ...page("100", [message(100)], true), beforeCursor: "90" }); + for (const bad of [ + { ...page("89", [], true), beforeCursor: "90" }, + { ...page("89", [], true), beforeCursor: null }, + { ...page("91", [], false), beforeCursor: "80" }, + { ...page("89", [], false), beforeCursor: "91" }, + { ...page("bad", [], false), beforeCursor: "80" }, + ]) assert.throws(() => applyAgentHistoryPage(state, bad, "90"), /cursor|boundary/); + assert.throws(() => applyAgentHistoryPage(state, { ...page("89"), conversationId: "different" }, "90"), /conversation changed/); + assert.equal(state.beforeCursor, "90"); + assert.equal(state.cursor, "100"); +}); + +test("unread counts disclose unknown older replies until reading the latest rendered window", () => { + let state = applyAgentHistoryPage(createAgentRoomState(), { ...page("1200", [message(1199), message(1200, "user")], true), beforeCursor: "1101" }); + assert.equal(agentUnreadCount(state), 1); + assert.equal(agentUnreadCountIsPartial(state), true); + state = applyAgentReadState(state, { cursor: "1100", updatedAt: null }); + assert.equal(agentUnreadCountIsPartial(state), false); + assert.equal(agentUnreadCount(state), 1); + state = applyAgentReadState(state, { cursor: "1200", updatedAt: null }); + assert.equal(agentUnreadCount(state), 0); + assert.equal(state.hasOlder, true, "reading latest does not fetch or discard older pages"); +}); + test("queued user messages keep stable IDs and disappear if withdrawn or promoted", () => { const pending = message(1, "user", { id: "inbound:sybil:request1", metadata: { kind: "message", status: "queued" } }); let state = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: "1", activity: activity([pending]) }); diff --git a/web/tests/agent-transcript.browser.mjs b/web/tests/agent-transcript.browser.mjs new file mode 100644 index 0000000..ea8afeb --- /dev/null +++ b/web/tests/agent-transcript.browser.mjs @@ -0,0 +1,250 @@ +import assert from 'node:assert/strict'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +// Local production preview; all APIs/sockets are synthetic and external requests are blocked. +// PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs WEB_TEST_URL=http://127.0.0.1:5196 +// TRANSCRIPT_EVIDENCE=/tmp/evidence TRANSCRIPT_BASELINE=1 node tests/agent-transcript.browser.mjs +const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const target = new URL(process.env.WEB_TEST_URL || 'http://127.0.0.1:5196/'); +assert.ok(target.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(target.hostname)); +const baseline = process.env.TRANSCRIPT_BASELINE === '1'; +const evidence = process.env.TRANSCRIPT_EVIDENCE; +if (evidence) await mkdir(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_EXECUTABLE || undefined }); +const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, serviceWorkers: 'block' }); +const page = await context.newPage(); +const errors = [], requests = [], reads = [], socketCursors = []; +page.on('pageerror', error => errors.push(error.message)); +const idle = { state: 'idle', isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] }; +const sourceId = '00000000-0000-4000-8000-000000000001'; +const row = (id, content) => ({ id: `entry:${id}`, role: id % 3 ? 'assistant' : 'user', + createdAt: new Date(Date.UTC(2026, 9, 16, 0, 0, id)).toISOString(), + content: content || `Fixture message ${id}\n\n${'Synthetic transcript text for delayed loading. '.repeat(id % 11 === 0 ? 30 : 3)}`, + metadata: { kind: 'message', entryId: id, status: 'complete' } }); +const history = Array.from({ length: 1200 }, (_, i) => row(i + 1)); +let live, readCursor = '0', failOlder = false, imageBytes, emptyRecent = false; +const start = performance.now(); +const elapsed = () => Math.round(performance.now() - start); +await context.addInitScript(() => { + localStorage.clear(); sessionStorage.clear(); + window.fixtureTiming = {}; + const sample = () => { + const scroller = document.querySelector('.agent-room__scroll'); + if (scroller) { + const box = scroller.getBoundingClientRect(); + const visible = [...scroller.querySelectorAll('[data-agent-message-id]')].filter(e => { + const r = e.getBoundingClientRect(); return r.bottom > box.top && r.top < box.bottom; + }); + if (visible.length && !window.fixtureTiming.first) window.fixtureTiming.first = { ms: performance.now(), ids: visible.map(e => e.dataset.agentMessageId) }; + if (visible.some(e => e.dataset.agentMessageId === 'entry:1200') && !window.fixtureTiming.latestMs) window.fixtureTiming.latestMs = performance.now(); + } + if (!window.fixtureTiming.latestMs) requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); +}); +await page.route('**/*', async route => { + const req = route.request(), url = new URL(req.url()); + if (url.origin !== target.origin) return route.abort(); + if (!url.pathname.startsWith('/api/')) return route.continue(); + const path = url.pathname, method = req.method(); + assert.equal(req.headers().authorization, undefined, 'Fixture must never use a real credential'); + let value; + if (path.endsWith('/content')) { + await new Promise(resolve => setTimeout(resolve, 600)); + return route.fulfill({ contentType: 'image/png', body: imageBytes }); + } else if (path.endsWith('/agent/messages')) { + assert.equal(method, 'GET', 'Fixture must not send messages'); + const after = url.searchParams.get('after'), before = url.searchParams.get('before'), limit = Number(url.searchParams.get('limit') || 200); + const record = { query: url.search, requestedMs: elapsed() }; requests.push(record); + await new Promise(resolve => setTimeout(resolve, before ? 1600 : 800)); + if (before && failOlder) { failOlder = false; return route.fulfill({ status: 503, json: { message: 'Fixture older page unavailable' } }); } + const eligible = history.filter(m => after !== null ? m.metadata.entryId > Number(after) : before !== null ? m.metadata.entryId < Number(before) : true); + const messages = after !== null ? eligible.slice(0, limit) : eligible.slice(-limit); + value = { conversationId: 'fixture-room', messages, cursor: String(messages.at(-1)?.metadata.entryId ?? after ?? 0), + beforeCursor: messages.length ? String(messages[0].metadata.entryId) : null, hasMore: eligible.length > messages.length }; + if (emptyRecent && before === null && after === null) value.messages = []; + Object.assign(record, { responseMs: elapsed(), rows: messages.length, bytes: Buffer.byteLength(JSON.stringify(value)) }); + } else if (path.endsWith('/read-state')) { + if (method === 'PUT') { readCursor = req.postDataJSON().cursor; reads.push({ cursor: readCursor, ms: elapsed() }); } + value = { cursor: readCursor, sourceId, updatedAt: null }; + } else if (path.endsWith('/agent')) value = { id: 'fixture-room', title: 'Sybil Agent', activity: idle, protocol: 'pi-room.v1', websocket: '/v1/agent/ws' }; + else if (path.endsWith('/auth/session')) value = { authenticated: true, mode: 'open' }; + else if (path.endsWith('/models')) value = { models: [], providers: {} }; + else if (path.endsWith('/tools') || path.endsWith('/chat-tools')) value = { tools: [] }; + else value = []; + await route.fulfill({ json: value }); +}); +await page.routeWebSocket('**/api/v1/agent/ws', socket => { + live = socket; + socket.onMessage(raw => { const frame = JSON.parse(String(raw)); if (frame.type === 'auth') { + assert.equal(frame.token, null); socketCursors.push(frame.after); + socket.send(JSON.stringify({ type: 'ready', conversationId: 'fixture-room' })); + } }); +}); +const send = messages => { + for (const message of messages) { const index = history.findIndex(m => m.id === message.id); if (index < 0) history.push(message); else history[index] = message; } + live.send(JSON.stringify({ type: 'messages', conversationId: 'fixture-room', messages, + cursor: String(messages.at(-1).metadata.entryId), beforeCursor: String(messages[0].metadata.entryId), hasMore: false })); +}; +const position = () => page.locator('.agent-room__scroll').evaluate(el => ({ top: el.scrollTop, distance: el.scrollHeight - el.scrollTop - el.clientHeight, + rows: el.querySelectorAll('[data-agent-entry-id]').length })); +const anchor = () => page.locator('.agent-room__scroll').evaluate(el => { + const top = el.getBoundingClientRect().top; + const entry = [...el.querySelectorAll('[data-agent-entry-id]')].find(e => e.getBoundingClientRect().bottom > top + 8); + return { id: entry.dataset.agentEntryId, offset: entry.querySelector('[data-agent-message-id]').getBoundingClientRect().top - top }; +}); +const anchorDrift = saved => page.locator('.agent-room__scroll').evaluate((el, saved) => { + const entry = [...el.querySelectorAll('[data-agent-entry-id]')].find(e => e.dataset.agentEntryId === saved.id); + return entry.querySelector('[data-agent-message-id]').getBoundingClientRect().top - el.getBoundingClientRect().top - saved.offset; +}, saved); +const metrics = { mode: baseline ? 'before' : 'after', fixtureRows: 1200, pageDelayMs: 800, olderDelayMs: 1600 }; +try { + await page.goto(new URL('/?agent=root', target).href, { waitUntil: 'domcontentloaded' }); + await page.locator('[data-testid="agent-sidebar-item"]').click(); + await page.waitForFunction(() => window.fixtureTiming?.first); + if (evidence) await page.screenshot({ path: `${evidence}/${metrics.mode}-first.png` }); + await page.waitForFunction(() => window.fixtureTiming?.latestMs, { timeout: 20000 }); + await page.getByRole('status').filter({ hasText: /^Connected$/ }).waitFor(); + await page.waitForTimeout(400); + Object.assign(metrics, await page.evaluate(() => window.fixtureTiming), { initial: await position(), initialRequests: requests.map(r => ({ ...r })), socketCursors: [...socketCursors] }); + if (evidence) await page.screenshot({ path: `${evidence}/${metrics.mode}-latest.png` }); + if (!baseline) { + assert.equal(requests.length, 1, 'No eager older/forward chain on initial load'); + assert.equal(new URLSearchParams(requests[0].query).has('after'), false); + assert.equal(socketCursors[0], '1200'); + assert.ok(metrics.initial.rows <= 100); + assert.ok(metrics.initial.distance < 2); + await page.waitForTimeout(1800); + assert.equal(requests.length, 1, 'Idle initial view must not drain history'); + await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = 0; }); + await page.waitForTimeout(150); + const saved = await anchor(), readBefore = readCursor; + await page.getByRole('button', { name: 'Load older messages', exact: true }).click(); + send([row(1201)]); + await page.waitForTimeout(500); + assert.equal(readCursor, readBefore, 'Incoming while reading must stay unread'); + metrics.incomingDriftPx = await anchorDrift(saved); + await page.getByRole('button', { name: 'Load older messages', exact: true }).waitFor(); + await page.waitForTimeout(400); + metrics.prependDriftPx = await anchorDrift(saved); + metrics.afterPrepend = await position(); + assert.ok(Math.abs(metrics.incomingDriftPx) < 2); + assert.ok(Math.abs(metrics.prependDriftPx) < 2); + assert.equal(readCursor, readBefore); + assert.equal(requests.length, 2, 'One user action loads one older page'); + assert.equal(await page.locator('[data-agent-entry-id="entry:1001"] [data-animation="enter"]').count(), 0); + const beforeGrowth = await anchor(); + send([row(1201, 'Dynamic streamed content.\n\n'.repeat(80))]); + await page.waitForTimeout(350); + metrics.streamingReadingDriftPx = await anchorDrift(beforeGrowth); + assert.ok(Math.abs(metrics.streamingReadingDriftPx) < 2); + await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = el.scrollHeight; }); + await page.waitForTimeout(250); + send([row(1201, 'Dynamic streamed content continues.\n\n'.repeat(100))]); + await page.waitForTimeout(400); + metrics.streamingPinnedDistancePx = (await position()).distance; + assert.ok(metrics.streamingPinnedDistancePx < 2); + const order = await page.locator('.agent-room__timeline').evaluate(el => { + const ids = [...el.querySelectorAll('[data-agent-entry-id]')].map(e => Number(e.dataset.agentEntryId.split(':')[1])); + const range = document.createRange(); range.selectNodeContents(el); + return { ids, text: range.toString(), direction: getComputedStyle(el).flexDirection, + skipped: [...el.querySelectorAll('[data-agent-message-id]')].filter(e => !e.checkVisibility({ contentVisibilityAuto: true })).length }; + }); + assert.deepEqual(order.ids, [...order.ids].sort((a, b) => a - b)); + assert.ok(order.text.indexOf('Fixture message 1001') < order.text.indexOf('Fixture message 1200')); + assert.equal(order.direction, 'column'); + assert.equal(await page.getByRole('button', { name: /latest/i }).count(), 0); + metrics.skippedOffscreenRows = order.skipped; + assert.ok(order.skipped > 0, 'Offscreen rows should skip layout/paint'); + const ax = await page.locator('.agent-room__scroll').ariaSnapshot(); + assert.ok(ax.includes('Fixture message 1001') && ax.includes('Fixture message 1200')); + assert.ok(ax.indexOf('Fixture message 1001') < ax.indexOf('Fixture message 1200')); + + // Authenticated attachment bytes are synthetic, delayed, and never fetched off origin. + imageBytes = Buffer.from(await page.evaluate(() => { + const canvas = document.createElement('canvas'); canvas.width = 240; canvas.height = 180; + const ctx = canvas.getContext('2d'); ctx.fillStyle = '#8974da'; ctx.fillRect(0, 0, 240, 180); + return canvas.toDataURL('image/png').split(',')[1]; + }), 'base64'); + const file = { id: 'att_12345678-1234-4234-8234-123456789abc', filename: 'fixture.png', mimeType: 'image/png', kind: 'image', + sizeBytes: imageBytes.length, sha256: createHash('sha256').update(imageBytes).digest('hex') }; + send([{ ...row(1202), metadata: { ...row(1202).metadata, agentAttachments: [file] } }]); + await page.waitForTimeout(500); + await page.getByRole('button', { name: 'Preview', exact: true }).click(); + await page.locator('.agent-attachment__preview').waitFor(); + await page.waitForTimeout(250); + metrics.attachmentPinnedDistancePx = (await position()).distance; + assert.ok(metrics.attachmentPinnedDistancePx < 2); + send([row(1203, 'Following the delayed attachment.\n\n'.repeat(60))]); + await page.waitForTimeout(500); + await page.locator('.agent-room__scroll').evaluate(el => { + const entry = el.querySelector('[data-agent-entry-id="entry:1203"]'); + el.scrollTop += entry.getBoundingClientRect().top - el.getBoundingClientRect().top + 80; + }); + await page.waitForTimeout(200); + const attachmentAnchor = await anchor(); + // Collapse an earlier preview while reading the following message. + await page.getByRole('button', { name: 'Hide preview', exact: true }).evaluate(el => el.click()); + await page.waitForTimeout(300); + metrics.attachmentReadingDriftPx = await anchorDrift(attachmentAnchor); + assert.ok(Math.abs(metrics.attachmentReadingDriftPx) < 2); + + // A failed older page is locally retryable without clearing the recent window. + await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = 0; }); + await page.waitForTimeout(200); + const rowsBeforeFailure = (await position()).rows; + failOlder = true; + await page.getByRole('button', { name: 'Load older messages', exact: true }).click(); + await page.getByRole('alert').filter({ hasText: 'Fixture older page unavailable' }).waitFor(); + assert.equal((await position()).rows, rowsBeforeFailure); + const socketsBefore = socketCursors.length; + await page.getByRole('button', { name: 'Load older messages', exact: true }).click(); + await page.waitForTimeout(150); + live.close({ code: 1012, reason: 'Fixture reconnect' }); + await page.getByRole('button', { name: 'Reconnect to Sybil Agent', exact: true }).click(); + await page.waitForTimeout(2200); + assert.ok(socketCursors.length > socketsBefore); + assert.equal(socketCursors.at(-1), '1203'); + assert.equal((await position()).rows, rowsBeforeFailure, 'Retired older response must not publish into the new session'); + assert.equal(await page.getByRole('alert').count(), 0); + await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = 0; }); + await page.getByRole('button', { name: 'Load older messages', exact: true }).click(); + await page.waitForFunction(() => document.querySelector('[data-agent-entry-id="entry:901"]')); + metrics.retiredOlderIgnored = true; + metrics.olderFailureRetry = true; + assert.equal(requests.filter(r => r.query.includes('after=0')).length, 0); + await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = el.scrollHeight; }); + await page.waitForTimeout(200); + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForTimeout(400); + metrics.mobilePinnedDistancePx = (await position()).distance; + assert.ok(metrics.mobilePinnedDistancePx < 2); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); + assert.equal(await page.getByRole('button', { name: /latest/i }).count(), 0); + if (evidence) await page.screenshot({ path: `${evidence}/after-mobile.png` }); + // A source window can contain only internal entries. It stays bounded and + // must not mark unloaded public history read or animate an older prepend. + emptyRecent = true; readCursor = '0'; + const requestsBeforeEmpty = requests.length; + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(new URL('/?agent=root', target).href, { waitUntil: 'domcontentloaded' }); + await page.locator('[data-testid="agent-sidebar-item"]').click(); + await page.getByRole('button', { name: 'Load older messages', exact: true }).waitFor(); + await page.waitForTimeout(1000); + assert.equal((await position()).rows, 0); + assert.equal(requests.length, requestsBeforeEmpty + 1); + assert.equal(readCursor, '0'); + await page.getByRole('button', { name: 'Load older messages', exact: true }).click(); + await page.waitForFunction(() => document.querySelectorAll('[data-agent-entry-id]').length > 0); + assert.equal(await page.locator('[data-animation="enter"]').count(), 0); + metrics.internalOnlyWindowBounded = true; + metrics.internalOnlyWindowUnacknowledged = true; + } + assert.deepEqual(errors, []); + metrics.requests = requests; metrics.reads = reads; metrics.errors = errors; + if (evidence) await writeFile(`${evidence}/${metrics.mode}.json`, JSON.stringify(metrics, null, 2)); + console.log(JSON.stringify(metrics, null, 2)); +} catch (error) { + console.error(JSON.stringify({ metrics, requests, errors, body: (await page.locator('body').innerText()).slice(0, 1800) })); + throw error; +} finally { await context.close(); await browser.close(); }