web: open Agent at recent history and load older pages on demand
This commit is contained in:
@@ -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
|
do not derive them from message IDs. `limit` bounds source entries, not displayed
|
||||||
messages.
|
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=<beforeCursor>&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`
|
### `POST /v1/agent/messages`
|
||||||
|
|
||||||
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
|
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
|
||||||
|
|||||||
@@ -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:
|
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.
|
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
|
### 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.
|
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.
|
||||||
|
|||||||
+3
-3
@@ -3554,7 +3554,7 @@ export default function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="agent-sidebar-item"
|
data-testid="agent-sidebar-item"
|
||||||
aria-current={isAgentMode ? "page" : undefined}
|
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={() => {
|
onClick={() => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -3580,9 +3580,9 @@ export default function App() {
|
|||||||
<span className="block truncate text-sm font-semibold">Sybil Agent</span>
|
<span className="block truncate text-sm font-semibold">Sybil Agent</span>
|
||||||
</span>
|
</span>
|
||||||
{isAgentBusy ? <LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-cyan-200" aria-label="Agent working" /> : null}
|
{isAgentBusy ? <LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-cyan-200" aria-label="Agent working" /> : null}
|
||||||
{agentRoom.unreadCount > 0 ? (
|
{agentRoom.unreadCount > 0 || agentRoom.unreadCountIsPartial ? (
|
||||||
<span data-testid="agent-unread-badge" className="flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-cyan-200 px-1.5 text-[11px] font-bold tabular-nums text-slate-950">
|
<span data-testid="agent-unread-badge" className="flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-cyan-200 px-1.5 text-[11px] font-bold tabular-nums text-slate-950">
|
||||||
{agentRoom.unreadCount}
|
{agentRoom.unreadCount ? `${agentRoom.unreadCount}${agentRoom.unreadCountIsPartial ? "+" : ""}` : "•"}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -45,6 +45,32 @@
|
|||||||
.agent-room__entry {
|
.agent-room__entry {
|
||||||
flex-shrink:0;
|
flex-shrink:0;
|
||||||
min-width: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 {
|
.agent-room__day {
|
||||||
|
|||||||
@@ -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 { Clock3, LoaderCircle, Menu, Paperclip, X, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact";
|
||||||
import type { Message } from "@/lib/api";
|
import type { Message } from "@/lib/api";
|
||||||
import type { AgentRoomHookResult } from "@/hooks/use-agent-room";
|
import type { AgentRoomHookResult } from "@/hooks/use-agent-room";
|
||||||
@@ -49,7 +50,7 @@ function toolName(value: unknown) {
|
|||||||
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
|
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 own = message.role === "user";
|
||||||
const info = metadata(message);
|
const info = metadata(message);
|
||||||
const status = typeof info.status === "string" ? info.status : "complete";
|
const status = typeof info.status === "string" ? info.status : "complete";
|
||||||
@@ -77,7 +78,7 @@ function AgentBubble({ message, animate, onRetry, download }: { message: Message
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Props) {
|
export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Props) {
|
||||||
const [draft, setDraft] = useState(savedDraft);
|
const [draft, setDraft] = useState(savedDraft);
|
||||||
@@ -98,6 +99,7 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
|||||||
const entryAnimationsRef = useRef(new Set<Animation>());
|
const entryAnimationsRef = useRef(new Set<Animation>());
|
||||||
const typingHeightRef = useRef(0);
|
const typingHeightRef = useRef(0);
|
||||||
const historyReadyRef = useRef(!room.isLoading);
|
const historyReadyRef = useRef(!room.isLoading);
|
||||||
|
const historyBeforeRef = useRef(room.historyBeforeCursor);
|
||||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -130,9 +132,20 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
|||||||
const element = scrollerRef.current;
|
const element = scrollerRef.current;
|
||||||
if (!element || pinnedRef.current) { anchorRef.current = null; return; }
|
if (!element || pinnedRef.current) { anchorRef.current = null; return; }
|
||||||
const top = element.getBoundingClientRect().top;
|
const top = element.getBoundingClientRect().top;
|
||||||
// Anchor to layout, independently of the bubble's entrance transform.
|
// Find the first visible row without laying out every offscreen message.
|
||||||
const node = Array.from(element.querySelectorAll<HTMLElement>("[data-agent-entry-id]")).find(item => item.getBoundingClientRect().bottom > top + 8);
|
const entries = element.querySelectorAll<HTMLElement>("[data-agent-entry-id]");
|
||||||
anchorRef.current = node ? { id: node.dataset.agentEntryId!, offset: node.getBoundingClientRect().top - top } : null;
|
// 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<HTMLElement>("[data-agent-message-id]");
|
||||||
|
anchorRef.current = node && message ? { id: node.dataset.agentEntryId!, offset: message.getBoundingClientRect().top - top } : null;
|
||||||
};
|
};
|
||||||
const restoreViewport = () => {
|
const restoreViewport = () => {
|
||||||
const element = scrollerRef.current;
|
const element = scrollerRef.current;
|
||||||
@@ -140,11 +153,14 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
|||||||
if (pinnedRef.current) element.scrollTop = element.scrollHeight;
|
if (pinnedRef.current) element.scrollTop = element.scrollHeight;
|
||||||
else if (anchorRef.current) {
|
else if (anchorRef.current) {
|
||||||
const anchor = anchorRef.current;
|
const anchor = anchorRef.current;
|
||||||
const node = Array.from(element.querySelectorAll<HTMLElement>("[data-agent-entry-id]")).find(item => item.dataset.agentEntryId === anchor.id);
|
const node = element.querySelector<HTMLElement>(`[data-agent-entry-id="${CSS.escape(anchor.id)}"]`);
|
||||||
if (node) element.scrollTop += node.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset;
|
const message = node?.querySelector<HTMLElement>("[data-agent-message-id]");
|
||||||
|
if (message) element.scrollTop += message.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset;
|
||||||
}
|
}
|
||||||
restoredScrollTopRef.current = element.scrollTop;
|
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();
|
scheduleRead();
|
||||||
};
|
};
|
||||||
const jumpToLatest = () => {
|
const jumpToLatest = () => {
|
||||||
@@ -195,7 +211,7 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
restoreViewport();
|
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(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
@@ -229,10 +245,10 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
|||||||
setDraft(value);
|
setDraft(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const retryMessage = (id: string) => {
|
const retryMessage = useCallback((id: string) => {
|
||||||
// Admission errors remain attached to their own bubble.
|
// Admission errors remain attached to their own bubble.
|
||||||
void room.retryMessage(id).catch(() => {});
|
void room.retryMessage(id).catch(() => {});
|
||||||
};
|
}, [room.retryMessage]);
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
const content = draftRef.current.trim();
|
const content = draftRef.current.trim();
|
||||||
if ((!content && !uploads.length) || pendingUploads || (uploads.length && !room.attachmentCapability)) return;
|
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 messages = useMemo(() => {
|
||||||
const transcript = agentTranscriptMessages(room.messages);
|
const transcript = agentTranscriptMessages(room.messages);
|
||||||
|
const historyChanged = historyBeforeRef.current !== room.historyBeforeCursor;
|
||||||
|
historyBeforeRef.current = room.historyBeforeCursor;
|
||||||
let previousDay = "";
|
let previousDay = "";
|
||||||
for (const message of transcript) {
|
let lastSeen = -1;
|
||||||
if (!seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading) {
|
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);
|
enteringMessageIdsRef.current.add(message.id);
|
||||||
}
|
}
|
||||||
seenMessageIdsRef.current.add(message.id);
|
seenMessageIdsRef.current.add(message.id);
|
||||||
@@ -263,7 +284,7 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [room.messages, room.isLoading, room.retryMessage, room.downloadAttachment]);
|
}, [room.messages, room.isLoading, room.historyBeforeCursor, room.retryMessage, room.downloadAttachment]);
|
||||||
|
|
||||||
const activity = room.activity;
|
const activity = room.activity;
|
||||||
const running = room.isTyping;
|
const running = room.isTyping;
|
||||||
@@ -309,6 +330,12 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
|||||||
>
|
>
|
||||||
<div ref={timelineRef} className="agent-room__timeline">
|
<div ref={timelineRef} className="agent-room__timeline">
|
||||||
{room.isLoading && !messages.length ? <div className="agent-room__empty" role="status"><LoaderCircle className="h-5 w-5 animate-spin" aria-hidden="true" /><span className="sr-only">Loading conversation</span></div> : null}
|
{room.isLoading && !messages.length ? <div className="agent-room__empty" role="status"><LoaderCircle className="h-5 w-5 animate-spin" aria-hidden="true" /><span className="sr-only">Loading conversation</span></div> : null}
|
||||||
|
{room.hasOlder ? <div className="agent-room__history">
|
||||||
|
<button type="button" disabled={room.isLoadingOlder} onClick={() => {
|
||||||
|
pinnedRef.current = false; rememberAnchor(); room.loadOlder();
|
||||||
|
}}>{room.isLoadingOlder ? "Loading older messages…" : "Load older messages"}</button>
|
||||||
|
{room.olderError ? <span role="alert">{room.olderError} Try loading again.</span> : null}
|
||||||
|
</div> : null}
|
||||||
{messages}
|
{messages}
|
||||||
{running ? <div className="agent-room__typing" role="status" aria-live="polite"><span className="agent-room__typing-dots" aria-hidden="true"><i /><i /><i /></span><span>{workingLabel}</span>{(activity?.backgroundWorkers || 0) > 1 ? <span className="agent-room__worker-count">{activity!.backgroundWorkers} workers</span> : null}</div> : null}
|
{running ? <div className="agent-room__typing" role="status" aria-live="polite"><span className="agent-room__typing-dots" aria-hidden="true"><i /><i /><i /></span><span>{workingLabel}</span>{(activity?.backgroundWorkers || 0) > 1 ? <span className="agent-room__worker-count">{activity!.backgroundWorkers} workers</span> : null}</div> : null}
|
||||||
{!running && waiting && activity?.wait ? <div className="agent-room__waiting" role="status"><Clock3 size={13} /><span>Checking back at {timeFormat.format(new Date(activity.wait.due))}</span></div> : null}
|
{!running && waiting && activity?.wait ? <div className="agent-room__waiting" role="status"><Clock3 size={13} /><span>Checking back at {timeFormat.format(new Date(activity.wait.due))}</span></div> : null}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"
|
|||||||
import { getApiBaseUrl, getAuthToken, type Message } from "@/lib/api";
|
import { getApiBaseUrl, getAuthToken, type Message } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||||
applyAgentEvent, applyAgentReadState,
|
applyAgentEvent, applyAgentReadState, applyAgentHistoryPage, agentUnreadCountIsPartial,
|
||||||
createAgentReadSession, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
|
createAgentReadSession, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
|
||||||
type AgentActivity, type AgentRoomState,
|
type AgentActivity, type AgentRoomState,
|
||||||
} from "@/lib/agent-room";
|
} from "@/lib/agent-room";
|
||||||
@@ -16,6 +16,7 @@ export type AgentRoomConnection = "connecting" | "connected" | "reconnecting" |
|
|||||||
export type AgentRoomHookResult = {
|
export type AgentRoomHookResult = {
|
||||||
messages: Message[]; activity: AgentActivity | null; unreadCount: number;
|
messages: Message[]; activity: AgentActivity | null; unreadCount: number;
|
||||||
connection: AgentRoomConnection; error: string | null; isLoading: boolean; isTyping: boolean;
|
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;
|
attachmentCapability?: AgentAttachmentCapability;
|
||||||
uploadAttachment(file: File, uploadId: string, signal: AbortSignal): Promise<AgentAttachment>;
|
uploadAttachment(file: File, uploadId: string, signal: AbortSignal): Promise<AgentAttachment>;
|
||||||
downloadAttachment(file: AgentAttachment, signal?: AbortSignal): Promise<Blob>;
|
downloadAttachment(file: AgentAttachment, signal?: AbortSignal): Promise<Blob>;
|
||||||
@@ -33,6 +34,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
|||||||
const [attachmentCapability, setAttachmentCapability] = useState<AgentAttachmentCapability>();
|
const [attachmentCapability, setAttachmentCapability] = useState<AgentAttachmentCapability>();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setLoading] = useState(enabled);
|
const [isLoading, setLoading] = useState(enabled);
|
||||||
|
const [isLoadingOlder, setLoadingOlder] = useState(false);
|
||||||
|
const [olderError, setOlderError] = useState<string | null>(null);
|
||||||
|
const older = useRef<(() => Promise<void>) | null>(null);
|
||||||
const [revision, setRevision] = useState(0);
|
const [revision, setRevision] = useState(0);
|
||||||
const client = useRef<AgentRoomClient | null>(null);
|
const client = useRef<AgentRoomClient | null>(null);
|
||||||
const [, renderUploads] = useState(0);
|
const [, renderUploads] = useState(0);
|
||||||
@@ -81,9 +85,10 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setLoadingOlder(false); setOlderError(null);
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
sourceMismatch.current = false;
|
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);
|
update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -96,6 +101,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
|||||||
let reconnectAttempt = 0;
|
let reconnectAttempt = 0;
|
||||||
let started = false;
|
let started = false;
|
||||||
let maintaining = false;
|
let maintaining = false;
|
||||||
|
let loadingOlder = false;
|
||||||
let lastPacket = Date.now();
|
let lastPacket = Date.now();
|
||||||
let socketAuthenticated = false;
|
let socketAuthenticated = false;
|
||||||
let authFailed = false;
|
let authFailed = false;
|
||||||
@@ -110,7 +116,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
|||||||
setView({ room: next, isTyping });
|
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 reportError = (failure: unknown) => { if (active && !controller.signal.aborted) setError(errorText(failure)); };
|
||||||
const readSession = createAgentReadSession({
|
const readSession = createAgentReadSession({
|
||||||
initial: current.current,
|
initial: current.current,
|
||||||
@@ -119,6 +125,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
|||||||
sourceChanged = true; sourceMismatch.current = true; mark.current = null;
|
sourceChanged = true; sourceMismatch.current = true; mark.current = null;
|
||||||
clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
|
clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
|
||||||
presenter.dispose(); controller.abort(); socket?.close();
|
presenter.dispose(); controller.abort(); socket?.close();
|
||||||
|
older.current = null; setLoadingOlder(false);
|
||||||
setError(failure.message); setConnection("offline"); setLoading(false);
|
setError(failure.message); setConnection("offline"); setLoading(false);
|
||||||
},
|
},
|
||||||
apply: read => {
|
apply: read => {
|
||||||
@@ -139,6 +146,22 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
|||||||
const descriptor = await api.descriptor(controller.signal);
|
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 }); }
|
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 = () => {
|
const connect = () => {
|
||||||
if (!active || authFailed || sourceChanged) return;
|
if (!active || authFailed || sourceChanged) return;
|
||||||
@@ -191,9 +214,11 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
|||||||
const start = async () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
await Promise.all([refreshRead(), refreshActivity()]);
|
await Promise.all([refreshRead(), refreshActivity()]);
|
||||||
// The first start begins at zero, not the latest page. Unread counts
|
if (!current.current.historyLoaded) {
|
||||||
// therefore include older public replies even after a long absence.
|
const page = await api.history(undefined, controller.signal);
|
||||||
await catchUp(!started);
|
if (!active || sourceChanged || controller.signal.aborted) return;
|
||||||
|
presenter.update(state => applyAgentHistoryPage(state, page));
|
||||||
|
} else await catchUp(!started);
|
||||||
if (!active || sourceChanged) return;
|
if (!active || sourceChanged) return;
|
||||||
presenter.flush();
|
presenter.flush();
|
||||||
started = true; setLoading(false); connect();
|
started = true; setLoading(false); connect();
|
||||||
@@ -231,18 +256,19 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
|||||||
window.removeEventListener("focus", focus); document.removeEventListener("visibilitychange", focus);
|
window.removeEventListener("focus", focus); document.removeEventListener("visibilitychange", focus);
|
||||||
socket?.close();
|
socket?.close();
|
||||||
if (client.current === api) client.current = null;
|
if (client.current === api) client.current = null;
|
||||||
mark.current = null;
|
mark.current = null; older.current = null;
|
||||||
};
|
};
|
||||||
}, [enabled, revision, update]);
|
}, [enabled, revision, update]);
|
||||||
|
|
||||||
const renderedCursor = room.cursor;
|
const renderedCursor = room.cursor;
|
||||||
const renderedSourceId = room.sourceId;
|
const renderedSourceId = room.sourceId;
|
||||||
|
const hasRenderedHistory = room.messages.some(message => message.role === "user" || message.role === "assistant");
|
||||||
const markRead = useCallback(() => {
|
const markRead = useCallback(() => {
|
||||||
if (!enabled || isLoading) return;
|
if (!enabled || isLoading || !hasRenderedHistory) return;
|
||||||
// This callback belongs to this rendered snapshot. Reading current.current
|
// This callback belongs to this rendered snapshot. Reading current.current
|
||||||
// here would incorrectly mark a socket message that has not rendered yet.
|
// here would incorrectly mark a socket message that has not rendered yet.
|
||||||
mark.current?.(renderedCursor, renderedSourceId);
|
mark.current?.(renderedCursor, renderedSourceId);
|
||||||
}, [enabled, isLoading, renderedCursor, renderedSourceId]);
|
}, [enabled, isLoading, hasRenderedHistory, renderedCursor, renderedSourceId]);
|
||||||
const sendMessage = useCallback(async (content: string, attachments?: AgentAttachment[], onPersisted?: () => void) => {
|
const sendMessage = useCallback(async (content: string, attachments?: AgentAttachment[], onPersisted?: () => void) => {
|
||||||
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
|
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
|
||||||
await outbox.current.send(content, attachments, () => { uploadQueue.current!.clear(); onPersisted?.(); });
|
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; }
|
if (sourceMismatch.current) { window.location.reload(); return; }
|
||||||
setRevision(value => value + 1);
|
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]);
|
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,
|
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 };
|
attachmentCapability, uploadQueue: uploadQueue.current!, uploadAttachment, downloadAttachment, isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type AgentReadState = { cursor: string; updatedAt: string | null; sourceI
|
|||||||
export type AgentRoomState = {
|
export type AgentRoomState = {
|
||||||
conversationId: string | null; messages: Message[]; cursor: string;
|
conversationId: string | null; messages: Message[]; cursor: string;
|
||||||
readCursor: string; activity: AgentActivity | null;
|
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. */
|
/** undefined before the first read snapshot; null pins a legacy source-less session. */
|
||||||
sourceId?: string | null;
|
sourceId?: string | null;
|
||||||
};
|
};
|
||||||
@@ -84,6 +85,7 @@ export function mergeAgentMessages(previous: readonly Message[], incoming: reado
|
|||||||
}
|
}
|
||||||
export const createAgentRoomState = (): AgentRoomState => ({
|
export const createAgentRoomState = (): AgentRoomState => ({
|
||||||
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
|
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
|
||||||
|
historyLoaded: false, beforeCursor: null, hasOlder: false,
|
||||||
});
|
});
|
||||||
export class AgentReadSourceChangedError extends Error {
|
export class AgentReadSourceChangedError extends Error {
|
||||||
constructor() { super("Agent history changed. Reload the page before marking messages read."); this.name = "AgentReadSourceChangedError"; }
|
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);
|
const cursor = page.cursor === null ? state.cursor : advanceAgentCursor(state.cursor, page.cursor);
|
||||||
return { ...state, conversationId: page.conversationId, messages: mergeAgentMessages(state.messages, page.messages), 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 {
|
export function applyAgentReadState(state: AgentRoomState, read: AgentReadState): AgentRoomState {
|
||||||
const sourceId = readSource(state, read);
|
const sourceId = readSource(state, read);
|
||||||
return { ...state, sourceId, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
|
return { ...state, sourceId, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
|
||||||
@@ -178,6 +199,9 @@ export class AgentRoomClient {
|
|||||||
messages(after: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
|
messages(after: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
|
||||||
return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal });
|
return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal });
|
||||||
}
|
}
|
||||||
|
history(before?: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
|
||||||
|
return this.request(`/messages?${new URLSearchParams({ ...(before === undefined ? {} : { before: normalizeAgentCursor(before) }), limit: "100" })}`, { signal });
|
||||||
|
}
|
||||||
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
|
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
|
||||||
markRead(cursor: string, signal?: AbortSignal, sourceId?: string | null): Promise<AgentReadState> {
|
markRead(cursor: string, signal?: AbortSignal, sourceId?: string | null): Promise<AgentReadState> {
|
||||||
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor), ...(agentPushSourceId(sourceId) ? { sourceId } : {}) }), signal });
|
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor), ...(agentPushSourceId(sourceId) ? { sourceId } : {}) }), signal });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
AgentRoomClient, AgentReadSourceChangedError, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
AgentRoomClient, AgentReadSourceChangedError, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||||
applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentReadSession, createAgentReadWriter,
|
applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentReadSession, createAgentReadWriter,
|
||||||
createAgentRoomState, mergeAgentMessages, normalizeAgentCursor, parseAgentRoomEvent, syncAgentHistory,
|
createAgentRoomState, mergeAgentMessages, normalizeAgentCursor, parseAgentRoomEvent, syncAgentHistory,
|
||||||
|
applyAgentHistoryPage, agentUnreadCountIsPartial,
|
||||||
} from "../src/lib/agent-room.ts";
|
} from "../src/lib/agent-room.ts";
|
||||||
import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.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);
|
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 requests = [];
|
||||||
const responses = [page("2", [message(1), message(2, "user")], true), page("4", [], true), page("7", [message(5), message(6, "tool"), message(7)], false)];
|
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();
|
let state = createAgentRoomState();
|
||||||
@@ -47,6 +48,78 @@ test("initial synchronization reads every page including invisible entries and c
|
|||||||
assert.equal(agentUnreadCount(state), 1);
|
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", () => {
|
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" } });
|
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]) });
|
let state = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: "1", activity: activity([pending]) });
|
||||||
|
|||||||
@@ -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(); }
|
||||||
Reference in New Issue
Block a user