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.
211 lines
12 KiB
JavaScript
211 lines
12 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
AgentRoomClient, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
|
applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentReadWriter,
|
|
createAgentRoomState, mergeAgentMessages, normalizeAgentCursor, parseAgentRoomEvent, syncAgentHistory,
|
|
} from "../src/lib/agent-room.ts";
|
|
import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.ts";
|
|
|
|
const message = (id, role = "assistant", extra = {}) => ({
|
|
id: `entry:${id}`, createdAt: `2026-10-14T10:00:${String(id % 60).padStart(2, "0")}.000Z`,
|
|
role, content: `message ${id}`, name: role === "assistant" ? "Pi" : "buzzert",
|
|
metadata: { kind: "message", entryId: id, status: "complete" }, ...extra,
|
|
});
|
|
const page = (cursor, messages = [], hasMore = false) => ({
|
|
conversationId: "1", messages, cursor, beforeCursor: messages.length ? String(messages[0].metadata.entryId) : cursor, hasMore,
|
|
});
|
|
const activity = (queuedMessages = []) => ({ state: "idle", isThinking: false, queued: queuedMessages.length, backgroundWorkers: 0, tools: [], queuedMessages });
|
|
|
|
test("tool starts and results merge across replay without downgrading completed cards", () => {
|
|
const start = message(2, "tool", { id: "tool:1:call1", metadata: { kind: "tool_call", entryId: 2, status: "initiated", args: { query: "cookie" }, startedAt: "2026-10-14T10:00:00Z" } });
|
|
const done = message(3, "tool", { id: start.id, metadata: { kind: "tool_call", entryId: 3, status: "completed", resultPreview: "found" } });
|
|
const messages = mergeAgentMessages(mergeAgentMessages([], [start]), [done, start]);
|
|
assert.equal(messages.length, 1);
|
|
assert.equal(messages[0].metadata.status, "completed");
|
|
assert.equal(messages[0].metadata.resultPreview, "found");
|
|
assert.deepEqual(messages[0].metadata.args, { query: "cookie" });
|
|
assert.equal(messages[0].metadata.entryId, 3);
|
|
assert.equal(messages[0].createdAt, start.createdAt);
|
|
});
|
|
|
|
test("initial synchronization reads every page including invisible entries and counts only unread assistant messages", async () => {
|
|
const requests = [];
|
|
const responses = [page("2", [message(1), message(2, "user")], true), page("4", [], true), page("7", [message(5), message(6, "tool"), message(7)], false)];
|
|
let state = createAgentRoomState();
|
|
await syncAgentHistory({ messages: async after => { requests.push(after); return responses.shift(); } }, "0", update => { state = applyAgentPage(state, update); });
|
|
assert.deepEqual(requests, ["0", "2", "4"]);
|
|
assert.equal(state.cursor, "7");
|
|
assert.equal(agentUnreadCount(state), 3);
|
|
state = applyAgentReadState(state, { cursor: "5", updatedAt: null });
|
|
assert.equal(agentUnreadCount(state), 1);
|
|
state = applyAgentReadState(state, { cursor: "1", updatedAt: null });
|
|
assert.equal(state.readCursor, "5", "a stale tab response cannot regress read state");
|
|
assert.equal(agentUnreadCount(state), 1);
|
|
});
|
|
|
|
test("queued user messages keep stable IDs and disappear if withdrawn or promoted", () => {
|
|
const pending = message(1, "user", { id: "inbound:sybil:request1", metadata: { kind: "message", status: "queued" } });
|
|
let state = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: "1", activity: activity([pending]) });
|
|
assert.equal(agentDisplayMessages(state).length, 1);
|
|
assert.equal(state.cursor, "0", "pending rows never advance replay or read cursors");
|
|
assert.equal(agentUnreadCount(state), 0);
|
|
state = applyAgentPage(state, page("4", [message(4, "user", { id: pending.id })]));
|
|
assert.equal(agentDisplayMessages(state).length, 1);
|
|
assert.equal(agentDisplayMessages(state)[0].metadata.status, "complete");
|
|
state = applyAgentEvent(state, { type: "activity", conversationId: "1", activity: activity() });
|
|
assert.equal(agentDisplayMessages(state).length, 1, "committed rows survive queue removal");
|
|
const onlyPending = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: "1", activity: activity([pending]) });
|
|
assert.deepEqual(agentDisplayMessages(applyAgentEvent(onlyPending, { type: "activity", conversationId: "1", activity: activity() })), []);
|
|
});
|
|
|
|
test("failed or stalled page delivery preserves the last applied cursor for replay", async () => {
|
|
let state = createAgentRoomState();
|
|
let calls = 0;
|
|
await assert.rejects(syncAgentHistory({ messages: async () => {
|
|
if (++calls === 1) return page("10", [message(10)], true);
|
|
throw new Error("network unavailable");
|
|
} }, "0", next => { state = applyAgentPage(state, next); }), /network unavailable/);
|
|
assert.equal(state.cursor, "10");
|
|
await assert.rejects(syncAgentHistory({ messages: async () => page("10", [], true) }, state.cursor, next => { state = applyAgentPage(state, next); }), /did not advance/);
|
|
assert.equal(state.cursor, "10");
|
|
assert.throws(() => applyAgentPage(state, page("bad", [message(11)])), /cursor/);
|
|
assert.equal(state.messages.length, 1);
|
|
assert.throws(() => applyAgentPage(state, { ...page("11"), conversationId: "different" }), /conversation changed/);
|
|
assert.equal(advanceAgentCursor("9007199254740993", "9007199254740992"), "9007199254740993");
|
|
assert.throws(() => normalizeAgentCursor("-1"), /cursor/);
|
|
});
|
|
|
|
test("read acknowledgments use only the rendered cursor and failed persistence retains unread replies", async () => {
|
|
let state = applyAgentPage(createAgentRoomState(), page("5", [message(5)]));
|
|
const renderedCursor = state.cursor;
|
|
state = applyAgentPage(state, page("6", [message(6)]));
|
|
const saved = [];
|
|
let fail = true;
|
|
const writer = createAgentReadWriter({
|
|
current: () => state.readCursor,
|
|
save: async cursor => { saved.push(cursor); if (fail) throw new Error("database offline"); return { cursor, updatedAt: null }; },
|
|
apply: read => { state = applyAgentReadState(state, read); },
|
|
});
|
|
await assert.rejects(writer.request(renderedCursor), /database offline/);
|
|
assert.equal(state.readCursor, "0");
|
|
assert.equal(agentUnreadCount(state), 2);
|
|
fail = false;
|
|
await writer.flush();
|
|
assert.deepEqual(saved, ["5", "5"]);
|
|
assert.equal(state.readCursor, "5");
|
|
assert.equal(agentUnreadCount(state), 1, "message6 arrived after render and must remain unread");
|
|
});
|
|
|
|
test("read acknowledgments coalesce concurrent seen snapshots and reject unacknowledged cursors", async () => {
|
|
let cursor = "0";
|
|
let release;
|
|
const gate = new Promise(resolve => { release = resolve; });
|
|
const saves = [];
|
|
const writer = createAgentReadWriter({ current: () => cursor, save: async value => {
|
|
saves.push(value); if (value === "3") await gate; return { cursor: value, updatedAt: null };
|
|
}, apply: read => { cursor = read.cursor; } });
|
|
const first = writer.request("3"), second = writer.request("7");
|
|
release(); await Promise.all([first, second]);
|
|
assert.deepEqual(saves, ["3", "7"]);
|
|
assert.equal(cursor, "7");
|
|
const broken = createAgentReadWriter({ current: () => "0", save: async () => ({ cursor: "0", updatedAt: null }), apply: () => assert.fail("must not apply a failed acknowledgment") });
|
|
await assert.rejects(broken.request("2"), /did not acknowledge/);
|
|
});
|
|
|
|
test("failed persistence after acknowledgment retains the original receipt key across reload", async () => {
|
|
let stored = [], writes = 0;
|
|
const storage = { read: () => structuredClone(stored), write: rows => {
|
|
if (++writes === 3) throw new Error("storage unavailable after acknowledgment");
|
|
stored = structuredClone(rows);
|
|
} };
|
|
const requests = [];
|
|
const open = () => createAgentOptimisticOutbox({
|
|
storage, changed: () => {}, newId: () => "stable-request",
|
|
submit: async (_content, requestId) => {
|
|
requests.push(requestId);
|
|
return { status: "placed", messageId: `inbound:sybil:${requestId}` };
|
|
},
|
|
});
|
|
const original = open();
|
|
await original.send("retry me");
|
|
const accepted = original.display([])[0];
|
|
assert.equal(accepted.metadata.status, "complete", "a storage error cannot undo server acceptance");
|
|
assert.equal(stored[0].status, "sending", "the pre-admission receipt remains durable if its update fails");
|
|
const restored = open();
|
|
assert.deepEqual(requests, ["stable-request"], "reload never retries automatically");
|
|
const uncertain = restored.display([])[0];
|
|
assert.equal(uncertain.metadata.status, "unconfirmed");
|
|
assert.equal(uncertain.id, accepted.id);
|
|
assert.equal(uncertain.createdAt, accepted.createdAt);
|
|
await restored.retry(uncertain.id);
|
|
assert.deepEqual(requests, ["stable-request", "stable-request"], "manual retry cannot create a second admission");
|
|
restored.reconcile({ messages: [message(1, "user", { id: accepted.id, content: "retry me" })] });
|
|
assert.deepEqual(stored, [], "canonical echo finishes local cleanup");
|
|
});
|
|
|
|
test("client uses Sybil auth and routes while websocket credentials remain in the first frame", async () => {
|
|
const calls = [];
|
|
let token = "first-session-token";
|
|
const client = new AgentRoomClient({ baseUrl: "https://sybil.example/api/", token: () => token, fetch: async (url, init) => {
|
|
calls.push({ url, init }); return new Response(JSON.stringify(init.method === "POST"
|
|
? { id: 1, status: "queued", messageId: "inbound:sybil:request-1" } : { cursor: "0", updatedAt: null }), { status: 200 });
|
|
} });
|
|
await client.messages("0");
|
|
token = "second-session-token";
|
|
await client.markRead("42");
|
|
await client.submit("hi", "request-1");
|
|
assert.equal(calls[0].url, "https://sybil.example/api/v1/agent/messages?after=0&limit=200");
|
|
assert.equal(calls[0].init.headers.get("Authorization"), "Bearer first-session-token");
|
|
assert.equal(calls[1].init.headers.get("Authorization"), "Bearer second-session-token");
|
|
assert.equal(calls[1].init.method, "PUT");
|
|
assert.deepEqual(JSON.parse(calls[1].init.body), { cursor: "42" });
|
|
assert.deepEqual(JSON.parse(calls[2].init.body), { content: "hi", requestId: "request-1" });
|
|
const url = client.websocketUrl("https://sybil.example/");
|
|
assert.equal(url, "wss://sybil.example/api/v1/agent/ws");
|
|
assert.equal(new URL(url).search, "");
|
|
assert.deepEqual(JSON.parse(agentAuthFrame(null, "42")), { type: "auth", token: null, after: "42" });
|
|
assert.deepEqual(JSON.parse(agentAuthFrame(token, "42")), { type: "auth", token, after: "42" });
|
|
});
|
|
|
|
test("malformed successful receipts keep optimistic messages retryable under the original request ID", async () => {
|
|
const requests = [];
|
|
let response = {};
|
|
const client = new AgentRoomClient({ fetch: async (_url, init) => {
|
|
requests.push(JSON.parse(init.body));
|
|
return new Response(JSON.stringify(response), { status: 202 });
|
|
} });
|
|
const box = createAgentOptimisticOutbox({ submit: (content, requestId) => client.submit(content, requestId),
|
|
storage: { read: () => [], write: () => {} }, changed: () => {}, newId: () => "retry-id",
|
|
});
|
|
await assert.rejects(box.send("Keep this request"), /invalid message receipt/);
|
|
const row = box.display([])[0];
|
|
assert.equal(row.metadata.status, "failed");
|
|
assert.equal(box.snapshot()[0].seenOnServer, false);
|
|
const valid = { id: 1, status: "queued", messageId: row.id };
|
|
for (const malformed of [null, { error: "Invalid Pi response" }, { ...valid, id: 0 }, { ...valid, id: 1.5 },
|
|
{ ...valid, id: Number.MAX_SAFE_INTEGER + 1 }, { ...valid, status: "unknown" }, { ...valid, status: ["queued"] },
|
|
{ ...valid, messageId: "inbound:sybil:other" }]) {
|
|
response = malformed;
|
|
await assert.rejects(box.retry(row.id), /invalid message receipt/);
|
|
assert.equal(box.display([])[0].metadata.status, "failed");
|
|
}
|
|
response = valid;
|
|
await box.retry(row.id);
|
|
assert.equal(box.display([])[0].metadata.status, "queued");
|
|
assert.ok(requests.every(request => request.requestId === "retry-id"), "uncertain responses cannot mint a duplicate admission");
|
|
for (const status of ["placed", "done", "unanswered"]) {
|
|
response = { ...valid, status };
|
|
assert.equal((await client.submit("Keep this request", "retry-id")).status, status);
|
|
}
|
|
});
|
|
|
|
test("protocol ignores unknown frames and rejects malformed message cursors", () => {
|
|
assert.equal(parseAgentRoomEvent('{"type":"future-frame"}'), null);
|
|
assert.deepEqual(parseAgentRoomEvent('{"type":"pong"}'), { type: "pong" });
|
|
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "messages", ...page("oops") })), /cursor/);
|
|
const state = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: "1", activity: { ...activity(), state: "thinking", isThinking: true } });
|
|
assert.equal(state.activity.isThinking, true);
|
|
assert.deepEqual(state.messages, [], "a thinking indicator never becomes a chat message");
|
|
});
|