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:
@@ -0,0 +1,139 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.ts";
|
||||
|
||||
const canonical = (id, content = "Hello", time = "2026-10-14T12:01:00.000Z") => ({
|
||||
id, content, createdAt: time, role: "user", name: "buzzert",
|
||||
metadata: { kind: "message", entryId: 123, status: "complete", channel: "sybil" },
|
||||
});
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
||||
function fixture(submit, legacy = {}) {
|
||||
let stored = null;
|
||||
let serial = 0;
|
||||
const changes = [];
|
||||
let box;
|
||||
const storage = { read: () => structuredClone(stored), write: rows => { stored = structuredClone(rows); }, ...legacy };
|
||||
const open = () => {
|
||||
box = createAgentOptimisticOutbox({ submit, storage, newId: () => `request-${++serial}`, now: () => Date.parse("2026-10-14T12:00:00.000Z"), changed: () => changes.push(box?.snapshot()) });
|
||||
return box;
|
||||
};
|
||||
return { open, changes, stored: () => stored, storage };
|
||||
}
|
||||
|
||||
test("send publishes and persists one canonical row before POST, retaining it through receipt and echo", async () => {
|
||||
const network = deferred(); let called = false;
|
||||
const state = fixture(async (_content, requestId) => { called = true; assert.equal(requestId, "request-1"); return network.promise; });
|
||||
const box = state.open();
|
||||
const done = box.send("Hello");
|
||||
const first = box.display([])[0];
|
||||
assert.equal(called, false, "network starts after synchronous local publication");
|
||||
assert.equal(first.id, "inbound:sybil:request-1");
|
||||
assert.equal(first.metadata.status, "sending");
|
||||
assert.equal(state.stored()[0].id, first.id);
|
||||
assert.equal(state.changes.length, 1);
|
||||
network.resolve({ status: "queued", messageId: first.id }); await done;
|
||||
assert.equal(box.display([])[0].metadata.status, "queued");
|
||||
assert.equal(box.display([])[0].createdAt, first.createdAt);
|
||||
const echo = canonical(first.id);
|
||||
box.reconcile({ messages: [echo] });
|
||||
const displayed = box.display([echo]);
|
||||
assert.equal(displayed.length, 1);
|
||||
assert.equal(displayed[0].id, first.id);
|
||||
assert.equal(displayed[0].createdAt, first.createdAt, "server receipt time cannot reorder the optimistic bubble");
|
||||
assert.equal(displayed[0].metadata.status, "complete");
|
||||
assert.deepEqual(state.stored(), []);
|
||||
assert.equal(box.display([]).length, 1, "even a stale display snapshot cannot create an acknowledgment gap");
|
||||
});
|
||||
|
||||
test("canonical echo before an HTTP failure wins and never paints a delivered bubble failed", async () => {
|
||||
const network = deferred(); const state = fixture(() => network.promise); const box = state.open();
|
||||
const done = box.send("Hello"); const row = box.display([])[0];
|
||||
const echo = canonical(row.id);
|
||||
box.reconcile({ messages: [echo] });
|
||||
network.reject(new Error("response lost"));
|
||||
await done;
|
||||
assert.equal(box.display([echo])[0].metadata.status, "complete");
|
||||
assert.equal(box.display([echo])[0].metadata.error, undefined);
|
||||
assert.deepEqual(state.stored(), []);
|
||||
});
|
||||
|
||||
test("queue echo before HTTP acknowledgment preserves one row when the queue snapshot disappears", async () => {
|
||||
const network = deferred(); const state = fixture(() => network.promise); const box = state.open();
|
||||
const done = box.send("Hello"); const row = box.display([])[0];
|
||||
const queued = { ...canonical(row.id), metadata: { kind: "message", status: "queued" } };
|
||||
box.reconcile({ messages: [], activity: { queuedMessages: [queued] } });
|
||||
assert.equal(box.display([queued]).length, 1);
|
||||
box.reconcile({ messages: [], activity: { queuedMessages: [] } });
|
||||
assert.equal(box.display([])[0].id, row.id, "queue-to-history transfer must not hide the bubble");
|
||||
network.reject(new Error("HTTP acknowledgment lost")); await done;
|
||||
assert.equal(box.display([])[0].metadata.status, "queued");
|
||||
});
|
||||
|
||||
test("failed retry and reload preserve the message ID, timestamp, and original receipt key", async () => {
|
||||
const requests = []; let fail = true;
|
||||
const state = fixture(async (content, requestId) => {
|
||||
requests.push({ content, requestId });
|
||||
if (fail) throw new Error("network down");
|
||||
return { status: "placed", messageId: `inbound:sybil:${requestId}` };
|
||||
});
|
||||
let box = state.open();
|
||||
await assert.rejects(box.send("Hello"), /network down/);
|
||||
const failed = box.display([])[0];
|
||||
assert.equal(failed.metadata.status, "failed");
|
||||
assert.equal(failed.metadata.error, "network down");
|
||||
box = state.open();
|
||||
assert.equal(requests.length, 1, "restoring drafts never sends automatically");
|
||||
assert.equal(box.display([])[0].metadata.status, "failed");
|
||||
assert.equal(box.display([])[0].createdAt, failed.createdAt);
|
||||
fail = false;
|
||||
const retry = box.retry(failed.id);
|
||||
assert.equal(box.display([])[0].metadata.status, "sending");
|
||||
await retry;
|
||||
assert.deepEqual(requests.map(row => row.requestId), ["request-1", "request-1"]);
|
||||
assert.equal(box.display([]).length, 1);
|
||||
assert.equal(box.display([])[0].metadata.status, "complete");
|
||||
assert.equal(box.display([])[0].createdAt, failed.createdAt);
|
||||
});
|
||||
|
||||
test("in-flight reload becomes unconfirmed and two identical sends remain independent rows", async () => {
|
||||
const first = deferred(), second = deferred(); let calls = 0;
|
||||
const state = fixture(() => ++calls === 1 ? first.promise : second.promise); const box = state.open();
|
||||
const send1 = box.send("Same content"), send2 = box.send("Same content");
|
||||
const rows = box.display([]);
|
||||
assert.equal(rows.length, 2);
|
||||
assert.notEqual(rows[0].id, rows[1].id);
|
||||
assert.ok(rows[0].createdAt < rows[1].createdAt, "same-millisecond sends retain insertion order");
|
||||
const reloaded = state.open();
|
||||
assert.deepEqual(reloaded.display([]).map(row => row.metadata.status), ["unconfirmed", "unconfirmed"]);
|
||||
assert.deepEqual(reloaded.display([]).map(row => row.id), rows.map(row => row.id));
|
||||
first.resolve({ status: "queued" }); second.resolve({ status: "queued" });
|
||||
await Promise.all([send1, send2]);
|
||||
});
|
||||
|
||||
test("legacy failed cards join their saved receipt keys without automatic delivery", () => {
|
||||
const state = fixture(() => assert.fail("migration must not submit"), {
|
||||
legacyRows: () => [{ content: "old message", status: "failed", error: "old error" }],
|
||||
legacyIds: () => [{ content: "old message", requestId: "old-request" }],
|
||||
});
|
||||
const box = state.open();
|
||||
assert.equal(box.display([])[0].id, "inbound:sybil:old-request");
|
||||
assert.equal(box.display([])[0].metadata.status, "failed");
|
||||
assert.equal(box.display([])[0].metadata.error, "old error");
|
||||
const migrated = state.stored()[0];
|
||||
assert.equal(migrated.id, "inbound:sybil:old-request");
|
||||
assert.equal(state.open().display([])[0].createdAt, migrated.createdAt, "migration is saved before any retry");
|
||||
});
|
||||
|
||||
test("storage failures still leave a failed inline row and prevent untracked POST", async () => {
|
||||
let calls = 0, visible = 0;
|
||||
const box = createAgentOptimisticOutbox({ submit: async () => { calls++; return { status: "queued" }; },
|
||||
storage: { read: () => null, write: () => { throw new Error("storage unavailable"); } },
|
||||
newId: () => "request", changed: () => visible++,
|
||||
});
|
||||
const sending = box.send("Do not lose this text");
|
||||
assert.equal(box.display([])[0].metadata.status, "failed");
|
||||
assert.equal(box.display([])[0].content, "Do not lose this text");
|
||||
assert.equal(visible, 1);
|
||||
await assert.rejects(sending, /storage unavailable/);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
Reference in New Issue
Block a user