Add persistent agent messaging and shared chat bubbles

Connect Sybil to the Pi root conversation with authenticated REST and WebSocket
APIs, persistent read state, and a dedicated agent tab. Keep optimistic messages
stable through delivery and retries, and animate arrivals without losing the
reader's scroll position.

Share bubble styling with regular chats and remove the obsolete admission sender.
Reject malformed upstream receipts and WebSocket upgrade requests, with regression
coverage and updated API documentation.

Validated with 51 web tests, 44 server tests, both production builds, desktop/mobile
bubble comparisons, and 11 browser animation and scrolling checks.
This commit is contained in:
2026-10-14 12:20:11 -07:00
parent dd6cd1ea7b
commit ef3d79c428
29 changed files with 2752 additions and 25 deletions
+166
View File
@@ -0,0 +1,166 @@
import type { Message } from "./api";
export const AGENT_OUTBOX_STORAGE_KEY = "sybil.agent.optimistic-outbox.v1";
export type AgentDeliveryStatus = "sending" | "queued" | "complete" | "failed" | "unconfirmed";
export type AgentOutboxRow = {
id: string; requestId: string; content: string; createdAt: string;
status: AgentDeliveryStatus; error?: string; seenOnServer: boolean; confirmed: boolean;
};
export type AgentOutboxStorage = {
read(): unknown; write(rows: AgentOutboxRow[]): void;
legacyRows?(): unknown; legacyIds?(): unknown;
};
type Receipt = { status: string; messageId?: string };
type RoomMessages = { messages: readonly Message[]; activity?: { queuedMessages?: readonly Message[] } | null };
const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, 240);
const info = (message: Message) => message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
? message.metadata as Record<string, unknown> : {};
const idFor = (requestId: string) => `inbound:sybil:${requestId}`;
const validRequestId = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 256;
/** Local rows use the server's identity from their first render through final echo. */
export function createAgentOptimisticOutbox(options: {
submit(content: string, requestId: string): Promise<Receipt>;
storage: AgentOutboxStorage;
changed(): void;
newId?: () => string;
now?: () => number;
}) {
const newId = options.newId || (() => crypto.randomUUID());
const now = options.now || Date.now;
const rows = new Map<string, AgentOutboxRow>();
const inFlight = new Map<string, Promise<void>>();
const rejectedStorage = new Set<string>();
const confirmedEchoes = new Map<string, Message>();
let latestTime = 0;
const timestamp = () => {
latestTime = Math.max(now(), latestTime + 1);
return new Date(latestTime).toISOString();
};
const restored = options.storage.read();
if (Array.isArray(restored)) {
for (const value of restored.slice(0, 100)) {
if (!value || typeof value.content !== "string" || !value.content.trim() || !validRequestId(value.requestId)) continue;
const time = typeof value.createdAt === "string" && Number.isFinite(Date.parse(value.createdAt)) ? value.createdAt : timestamp();
latestTime = Math.max(latestTime, Date.parse(time));
const status: AgentDeliveryStatus = ["failed", "queued", "complete", "unconfirmed"].includes(value.status) ? value.status : "unconfirmed";
const row: AgentOutboxRow = { id: idFor(value.requestId), requestId: value.requestId, content: value.content, createdAt: time,
status, seenOnServer: Boolean(value.seenOnServer), confirmed: false,
...(typeof value.error === "string" && value.error ? { error: value.error.slice(0, 240) } : {}) };
rows.set(row.id, row);
}
} else {
// Join the former UI-only failed cards to the old sender's receipt keys.
// Neither representation is retried automatically during migration.
const oldRows = options.storage.legacyRows?.();
const oldIds = options.storage.legacyIds?.();
const ids = new Map<string, string>();
if (Array.isArray(oldIds)) for (const value of oldIds) {
if (value && typeof value.content === "string" && validRequestId(value.requestId)) ids.set(value.content, value.requestId);
}
const contents = new Map<string, { content: string; status?: string; error?: string }>();
if (Array.isArray(oldRows)) for (const value of oldRows) {
if (value && typeof value.content === "string" && value.content.trim()) contents.set(value.content, value);
}
for (const content of ids.keys()) if (!contents.has(content)) contents.set(content, { content });
for (const value of [...contents.values()].slice(0, 100)) {
const requestId = ids.get(value.content) || newId();
rows.set(idFor(requestId), { id: idFor(requestId), requestId, content: value.content, createdAt: timestamp(),
status: value.status === "failed" ? "failed" : "unconfirmed", seenOnServer: false, confirmed: false,
error: typeof value.error === "string" && value.error ? value.error.slice(0, 240)
: ids.has(value.content) ? "Delivery was interrupted; retry to confirm." : "Restored draft; check the conversation before retrying." });
}
}
const persist = () => {
const pending = [...rows.values()].filter(row => !row.confirmed && !rejectedStorage.has(row.id));
if (pending.length > 100 || JSON.stringify(pending).length > 2 * 1024 * 1024) throw new Error("Too many pending agent messages. Retry existing messages before sending more.");
options.storage.write(pending.map(row => ({ ...row })));
};
const saveBestEffort = () => { try { persist(); } catch { /* Existing receipt IDs stay in memory; acknowledged messages remain sent. */ } };
const publish = () => options.changed();
const dispatch = (row: AgentOutboxRow): Promise<void> => {
const running = inFlight.get(row.id);
if (running) return running;
if (row.confirmed || row.seenOnServer) return Promise.resolve();
row.status = "sending"; delete row.error;
rejectedStorage.delete(row.id);
try { persist(); }
catch (error) {
// A refused new draft must not prevent older persisted drafts from
// draining their outbox. The refused row remains visibly retryable.
rejectedStorage.add(row.id);
row.status = "failed"; row.error = errorText(error); publish();
return Promise.reject(error);
}
// Both durable local storage and the visible row exist before POST begins.
publish();
const pending = Promise.resolve().then(() => options.submit(row.content, row.requestId)).then(receipt => {
if (receipt.messageId && receipt.messageId !== row.id) throw new Error("Agent returned a different message identity.");
if (!row.confirmed) {
row.seenOnServer = true;
row.status = receipt.status === "queued" ? "queued" : "complete";
delete row.error; saveBestEffort(); publish();
}
}).catch(error => {
// A canonical echo wins over a lost HTTP response. The message has already
// been admitted, so a transport error must not paint it failed or resend it.
if (row.confirmed || row.seenOnServer) return;
row.status = "failed"; row.error = errorText(error); saveBestEffort(); publish();
throw error;
}).finally(() => { inFlight.delete(row.id); });
inFlight.set(row.id, pending);
return pending;
};
// Complete migration even when the user leaves restored drafts untouched:
// their newly assigned identity/time must survive the next reload too.
saveBestEffort();
return {
send(content: string): Promise<void> {
if (!content.trim()) return Promise.reject(new Error("Enter a message."));
const requestId = newId();
const row: AgentOutboxRow = { id: idFor(requestId), requestId, content, createdAt: timestamp(), status: "sending", seenOnServer: false, confirmed: false };
rows.set(row.id, row);
return dispatch(row);
},
retry(messageId: string): Promise<void> {
const row = rows.get(messageId);
return row ? dispatch(row) : Promise.reject(new Error("This message is no longer pending."));
},
reconcile(room: RoomMessages): void {
const committed = new Map(room.messages.map(message => [message.id, message]));
const queued = new Set((room.activity?.queuedMessages || []).map(message => message.id));
let changed = false;
for (const row of rows.values()) {
if (committed.has(row.id)) {
confirmedEchoes.set(row.id, committed.get(row.id)!);
if (!row.confirmed || row.status !== "complete" || row.error) {
row.confirmed = true; row.seenOnServer = true; row.status = "complete"; delete row.error; changed = true;
}
} else if (!row.confirmed && queued.has(row.id) && (row.status !== "queued" || !row.seenOnServer || row.error)) {
row.seenOnServer = true; row.status = "queued"; delete row.error; changed = true;
}
}
if (changed) { saveBestEffort(); publish(); }
},
display(serverMessages: readonly Message[]): Message[] {
const displayed = new Map(serverMessages.map(message => {
const row = rows.get(message.id);
if (!row) return [message.id, message] as const;
const serverInfo = info(message);
const status = serverInfo.status === "complete" || row.confirmed ? "complete" : row.status;
return [message.id, { ...message, createdAt: row.createdAt,
metadata: { ...serverInfo, status, ...(row.error && status !== "complete" ? { error: row.error } : {}) } }] as const;
}));
for (const row of rows.values()) if (!displayed.has(row.id)) {
const echo = confirmedEchoes.get(row.id);
if (row.confirmed && !echo) continue;
displayed.set(row.id, echo ? { ...echo, createdAt: row.createdAt } : {
id: row.id, createdAt: row.createdAt, role: "user", content: row.content, name: "buzzert",
metadata: { kind: "message", status: row.status, channel: "sybil", requestId: row.requestId, ...(row.error ? { error: row.error } : {}) },
});
}
return [...displayed.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
},
snapshot(): AgentOutboxRow[] { return [...rows.values()].map(row => ({ ...row })); },
};
}
+120
View File
@@ -0,0 +1,120 @@
import type { AgentRoomEvent, AgentRoomState } from './agent-room';
import type { Message } from './api';
type Scheduler = {
now(): number;
schedule(callback: () => void, delay: number): unknown;
cancel(handle: unknown): void;
};
export type AgentRoomPresenterOptions = {
initial: AgentRoomState;
reduceEvent(state: AgentRoomState, event: AgentRoomEvent): AgentRoomState;
/** Publish room and transcript typing together in one UI state update. */
publish(state: AgentRoomState, isTyping: boolean): void;
batchMs?: number;
typingDelayMs?: number;
clock?: Scheduler;
};
const quietTools = new Set(['user_message', 'notify_parent', 'clock_wait', 'event_decision']);
const data = (message: Message): Record<string, unknown> => message.metadata && typeof message.metadata === 'object'
&& !Array.isArray(message.metadata) ? message.metadata as Record<string, unknown> : {};
const entryId = (message: Message): bigint | undefined => {
const value = data(message).entryId;
return (typeof value === 'string' && /^\d+$/.test(value)) || (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0)
? BigInt(value) : undefined;
};
const toolId = (message: Message) => typeof data(message).toolCallId === 'string' ? String(data(message).toolCallId) : message.id;
/**
* Pi sends messages and activity as separate frames. This adapter publishes them
* as one room snapshot and latches transcript typing off when a new public reply
* arrives. An unchanged busy frame cannot restore the old turn's indicator.
* Actual activity remains untouched for the sidebar, workers, and queue display.
*/
export function createAgentRoomPresenter(options: AgentRoomPresenterOptions) {
const clock: Scheduler = options.clock || {
now: () => Date.now(),
schedule: (callback, delay) => setTimeout(callback, delay),
cancel: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
};
const batchMs = options.batchMs ?? 100;
const typingDelayMs = options.typingDelayMs ?? 250;
let state = options.initial;
let stopped = false;
let batchTimer: unknown;
let typingTimer: unknown;
let suppressTyping = false;
let candidateSince: number | undefined;
const seenTools = new Set(state.messages.filter(message => message.role === 'tool').map(toolId));
for (const tool of state.activity?.tools || []) seenTools.add(tool.id);
const reconcile = () => {
const work = Boolean(state.activity?.isThinking || (state.activity?.backgroundWorkers || 0) > 0);
if (!work || suppressTyping) candidateSince = undefined;
else if (candidateSince === undefined) candidateSince = clock.now();
};
const clearTypingTimer = () => { if (typingTimer !== undefined) clock.cancel(typingTimer); typingTimer = undefined; };
const flush = () => {
if (stopped) return;
if (batchTimer !== undefined) clock.cancel(batchTimer);
batchTimer = undefined; clearTypingTimer();
reconcile();
const isTyping = candidateSince !== undefined && clock.now() - candidateSince >= typingDelayMs;
options.publish(state, isTyping);
if (candidateSince !== undefined && !isTyping) typingTimer = clock.schedule(flush, Math.max(0, typingDelayMs - (clock.now() - candidateSince)));
};
const schedule = () => {
reconcile();
// A fixed maximum batch window avoids starving publication during bursts.
if (batchTimer === undefined) batchTimer = clock.schedule(flush, batchMs);
};
const release = () => { suppressTyping = false; };
const latch = () => {
suppressTyping = true;
candidateSince = undefined;
clearTypingTimer();
};
return {
/** replay:true is for initial historical loading, not ongoing catch-up. */
push(event: AgentRoomEvent, hints: { replay?: boolean } = {}) {
if (stopped) return;
const before = state;
const next = options.reduceEvent(before, event); // Validate/reduce before touching presentation state.
state = next;
if (event.type === 'messages') {
const existing = new Set(before.messages.map(message => message.id));
const fresh = event.messages.filter(message => !existing.has(message.id) && (entryId(message) ?? -1n) > BigInt(before.cursor))
.sort((left, right) => (entryId(left) || 0n) < (entryId(right) || 0n) ? -1 : 1);
if (!hints.replay) for (const message of fresh) {
const info = data(message);
if (message.role === 'assistant' && info.kind === 'message' && message.content.trim()) latch();
else if (suppressTyping && message.role === 'user') release();
else if (suppressTyping && message.role === 'tool' && info.status === 'initiated'
&& !quietTools.has(String(info.toolName || message.name)) && !seenTools.has(toolId(message))) release();
if (message.role === 'tool') seenTools.add(toolId(message));
}
for (const message of event.messages) if (message.role === 'tool') seenTools.add(toolId(message));
} else if (event.type === 'activity') {
const activity = event.activity;
if (suppressTyping && (!activity.isThinking
|| activity.tools.some(tool => !seenTools.has(tool.id) && !quietTools.has(tool.name)))) release();
for (const tool of activity.tools) seenTools.add(tool.id);
}
schedule();
},
/** Read-position updates share the pending snapshot; they never lose queued frames. */
update(change: (state: AgentRoomState) => AgentRoomState) {
if (stopped) return;
state = change(state); schedule();
},
flush,
dispose() {
stopped = true;
if (batchTimer !== undefined) clock.cancel(batchTimer);
batchTimer = undefined; clearTypingTimer();
// Unpublished input is deliberately discarded; reconnect replays from the
// last published cursor, so neither delivery nor read tracking gets ahead.
},
};
}
+216
View File
@@ -0,0 +1,216 @@
import type { Message } from "./api";
export type AgentActivity = {
state: "idle" | "thinking" | "tool" | "waiting" | "error";
isThinking: boolean;
queued: number;
backgroundWorkers: number;
tools: Array<{ id: string; name: string; status: string; output?: string }>;
queuedMessages: Message[];
wait?: { due: number; reason?: string };
error?: string;
};
export type AgentMessagesPage = {
conversationId: string; messages: Message[]; cursor: string | null;
beforeCursor: string | null; hasMore: boolean;
};
export type AgentDescriptor = {
id: string; title: string; model: string; activity: AgentActivity;
websocket: string; protocol: string;
};
export type AgentReadState = { cursor: string; updatedAt: string | null };
export type AgentRoomState = {
conversationId: string | null; messages: Message[]; cursor: string;
readCursor: string; activity: AgentActivity | null;
};
export type AgentRoomEvent =
| ({ type: "messages" } & AgentMessagesPage)
| { type: "activity"; conversationId: string; activity: AgentActivity }
| { type: "ready"; conversationId?: string }
| { type: "pong" | "heartbeat" }
| { type: "error"; error?: string; message?: string };
const object = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> : undefined;
const defined = (value: Record<string, unknown>) => Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
export function normalizeAgentCursor(value: unknown): string {
if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("Invalid agent message cursor.");
return BigInt(value).toString();
}
export function advanceAgentCursor(current: string, incoming: string): string {
const a = normalizeAgentCursor(current), b = normalizeAgentCursor(incoming);
return BigInt(a) >= BigInt(b) ? a : b;
}
function metadata(message: Message) { return object(message.metadata); }
function messageCursor(message: Message): string | null {
const entryId = metadata(message)?.entryId;
if (typeof entryId === "number" && Number.isSafeInteger(entryId) && entryId >= 0) return String(entryId);
return typeof entryId === "string" && /^\d+$/.test(entryId) ? normalizeAgentCursor(entryId) : null;
}
function mergeMessage(previous: Message, incoming: Message): Message {
const prior = metadata(previous), next = metadata(incoming);
// A delayed activity snapshot must not demote an already placed user message.
if (prior?.status === "complete" && next?.status === "queued") return previous;
if (prior?.kind !== "tool_call" || next?.kind !== "tool_call") return { ...previous, ...incoming };
const terminal = prior.status === "completed" || prior.status === "failed";
const staleStart = terminal && next.status === "initiated";
const merged = staleStart ? { ...defined(next), ...defined(prior) } : { ...defined(prior), ...defined(next) };
if (!next.startedAt && prior.startedAt) merged.startedAt = prior.startedAt;
const oldArgs = object(prior.args), newArgs = object(next.args);
if (oldArgs && (!newArgs || Object.keys(newArgs).length === 0)) merged.args = oldArgs;
return { ...previous, ...incoming, ...(staleStart ? { content: previous.content, name: previous.name } : {}),
createdAt: previous.createdAt < incoming.createdAt ? previous.createdAt : incoming.createdAt, metadata: merged };
}
/** Replays and separate tool-start/result pages update stable rows instead of duplicating them. */
export function mergeAgentMessages(previous: readonly Message[], incoming: readonly Message[]): Message[] {
const messages = new Map(previous.map(message => [message.id, message]));
for (const message of incoming) {
const prior = messages.get(message.id);
messages.set(message.id, prior ? mergeMessage(prior, message) : message);
}
return [...messages.values()].sort((a, b) => {
const byTime = a.createdAt.localeCompare(b.createdAt);
if (byTime) return byTime;
const left = messageCursor(a), right = messageCursor(b);
if (left !== null && right !== null && left !== right) return BigInt(left) < BigInt(right) ? -1 : 1;
return a.id.localeCompare(b.id);
});
}
export const createAgentRoomState = (): AgentRoomState => ({
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
});
export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage): AgentRoomState {
if (state.conversationId !== null && state.conversationId !== page.conversationId) throw new Error("Agent conversation changed; reload the page.");
// Validate before changing state. Failed delivery/decoding cannot advance replay position.
const cursor = page.cursor === null ? state.cursor : advanceAgentCursor(state.cursor, page.cursor);
return { ...state, conversationId: page.conversationId, messages: mergeAgentMessages(state.messages, page.messages), cursor };
}
export function applyAgentReadState(state: AgentRoomState, read: AgentReadState): AgentRoomState {
return { ...state, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
}
export function applyAgentEvent(state: AgentRoomState, event: AgentRoomEvent): AgentRoomState {
if (event.type === "messages") return applyAgentPage(state, event);
if (event.type !== "activity") return state;
if (state.conversationId !== null && state.conversationId !== event.conversationId) throw new Error("Agent activity belongs to a different conversation.");
return { ...state, conversationId: event.conversationId, activity: event.activity };
}
export function agentUnreadCount(state: AgentRoomState): number {
return state.messages.filter(message => {
if (message.role !== "assistant" || metadata(message)?.kind !== "message") return false;
const cursor = messageCursor(message);
return cursor !== null && BigInt(cursor) > BigInt(state.readCursor);
}).length;
}
export function agentDisplayMessages(state: AgentRoomState): Message[] {
const committed = new Set(state.messages.map(message => message.id));
return mergeAgentMessages(state.messages, (state.activity?.queuedMessages || []).filter(message => !committed.has(message.id)));
}
export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
const event = object(JSON.parse(raw));
if (!event || typeof event.type !== "string") throw new Error("Invalid agent room frame.");
if (!["messages", "activity", "ready", "pong", "heartbeat", "error"].includes(event.type)) return null;
if (event.type === "messages") {
if (typeof event.conversationId !== "string" || !Array.isArray(event.messages) || typeof event.hasMore !== "boolean") throw new Error("Invalid agent messages frame.");
if (event.cursor !== null) normalizeAgentCursor(event.cursor);
for (const item of event.messages) {
const message = object(item);
if (!message || typeof message.id !== "string" || typeof message.content !== "string" || typeof message.createdAt !== "string"
|| !["system", "user", "assistant", "tool"].includes(String(message.role))) throw new Error("Invalid agent message.");
}
}
if (event.type === "activity" && (typeof event.conversationId !== "string" || !object(event.activity))) throw new Error("Invalid agent activity frame.");
return event as AgentRoomEvent;
}
export function agentAuthFrame(token: string | null, cursor: string): string {
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor) });
}
export type AgentSubmission = { id: number; status: "queued" | "placed" | "done" | "unanswered"; messageId: string };
export type AgentRoomClientOptions = { baseUrl?: string; token?: () => string | null; fetch?: typeof globalThis.fetch };
export class AgentRoomClient {
private readonly baseUrl: string;
private readonly fetcher: typeof globalThis.fetch;
private readonly token: () => string | null;
constructor(options: AgentRoomClientOptions = {}) {
this.baseUrl = (options.baseUrl || "/api").replace(/\/+$/, "");
this.fetcher = (options.fetch || globalThis.fetch).bind(globalThis);
this.token = options.token || (() => null);
}
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
const token = this.token();
if (token) headers.set("Authorization", `Bearer ${token}`);
if (init.body !== undefined) headers.set("Content-Type", "application/json");
const response = await this.fetcher(`${this.baseUrl}/v1/agent${path}`, { ...init, headers });
let value: unknown;
try { value = await response.json(); }
catch { throw new Error(`Agent API returned ${response.status} without a JSON response.`); }
if (!response.ok) {
const error = object(value);
throw new Error(typeof error?.message === "string" ? error.message : typeof error?.error === "string" ? error.error : `Agent API returned ${response.status}.`);
}
return value as T;
}
descriptor(signal?: AbortSignal): Promise<AgentDescriptor> { return this.request("", { signal }); }
messages(after: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal });
}
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
markRead(cursor: string, signal?: AbortSignal): Promise<AgentReadState> {
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor) }), signal });
}
async submit(content: string, requestId: string): Promise<AgentSubmission> {
const receipt = object(await this.request<unknown>("/messages", { method: "POST", body: JSON.stringify({ content, requestId }) }));
// An HTTP success alone does not acknowledge delivery. Keep the original
// outbox receipt key retryable if a proxy or upstream returns malformed data.
if (!receipt || typeof receipt.id !== "number" || !Number.isSafeInteger(receipt.id) || receipt.id <= 0
|| typeof receipt.status !== "string" || !["queued", "placed", "done", "unanswered"].includes(receipt.status)
|| receipt.messageId !== `inbound:sybil:${requestId}`) throw new Error("Agent API returned an invalid message receipt. Retry to confirm delivery.");
return receipt as AgentSubmission;
}
websocketUrl(pageUrl: string): string {
const url = new URL(`${this.baseUrl}/v1/agent/ws`, pageUrl);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return url.toString();
}
}
/** Read every newer page, including pages containing only invisible internal entries. */
export async function syncAgentHistory(client: Pick<AgentRoomClient, "messages">, from: string,
apply: (page: AgentMessagesPage) => void, signal?: AbortSignal): Promise<void> {
let cursor = normalizeAgentCursor(from);
while (!signal?.aborted) {
const page = await client.messages(cursor, signal);
if (signal?.aborted) return;
const next = normalizeAgentCursor(page.cursor ?? cursor);
if (BigInt(next) < BigInt(cursor) || (page.hasMore && next === cursor)) throw new Error("Agent history did not advance its cursor.");
apply(page);
cursor = next;
if (!page.hasMore) return;
}
}
// Retained only to migrate receipt IDs from the former admission sender.
export const AGENT_ADMISSION_STORAGE_KEY = "sybil.agent.pending-admissions.v1";
/** Only successfully persisted, explicitly observed cursors become read state. */
export function createAgentReadWriter(options: {
current: () => string; save: (cursor: string) => Promise<AgentReadState>; apply: (read: AgentReadState) => void;
}) {
let desired = "0";
let running: Promise<void> | undefined;
const flush = (): Promise<void> => {
if (running) return running;
running = (async () => {
while (BigInt(desired) > BigInt(options.current())) {
const captured = desired;
const saved = await options.save(captured);
if (BigInt(normalizeAgentCursor(saved.cursor)) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
options.apply(saved);
}
})().finally(() => { running = undefined; });
return running;
};
return { request(cursor: string) { desired = advanceAgentCursor(desired, cursor); return flush(); }, flush };
}
+8
View File
@@ -230,6 +230,14 @@ export function getConfiguredToken() {
return ENV_ADMIN_TOKEN;
}
export function getAuthToken() {
return authToken;
}
export function getApiBaseUrl() {
return API_BASE_URL;
}
export function setAuthToken(token: string | null) {
authToken = token?.trim() || null;
}
+5 -2
View File
@@ -1,6 +1,7 @@
export type SidebarSelection = { kind: "chat" | "search"; id: string };
export type HistorySelection = { kind: "chat" | "search"; id: string };
export type SidebarSelection = HistorySelection | { kind: "agent"; id: "root" };
type WorkspaceSelectionItem = { type: SidebarSelection["kind"]; id: string };
type WorkspaceSelectionItem = { type: HistorySelection["kind"]; id: string };
export function retainSelectedHistoryItem<T extends WorkspaceSelectionItem>(
items: T[], selection: SidebarSelection | null, retained: T | null
@@ -24,6 +25,8 @@ export function resolveSidebarSelectionAfterRefresh(
): SidebarSelection | null {
const hasItem = (candidate: SidebarSelection | null | undefined) => {
if (!candidate) return false;
// The persistent agent is always available, independent of saved history.
if (candidate.kind === "agent") return true;
return workspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id);
};