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);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAgentEvent, applyAgentReadState, createAgentRoomState } from '../src/lib/agent-room.ts';
|
||||
import { createAgentRoomPresenter } from '../src/lib/agent-room-presentation.ts';
|
||||
|
||||
const message = (entry, role = 'assistant', extra = {}) => ({ id: `entry:${entry}`, createdAt: '2026-10-14T00:00:00.000Z', role,
|
||||
content: `Message ${entry}`, metadata: { kind: 'message', entryId: entry, status: 'complete' }, ...extra });
|
||||
const page = (cursor, messages) => ({ type: 'messages', conversationId: '1', messages, cursor: String(cursor), beforeCursor: '0', hasMore: false });
|
||||
const activity = (isThinking, extra = {}) => ({ type: 'activity', conversationId: '1', activity: {
|
||||
state: isThinking ? 'thinking' : 'idle', isThinking, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [], ...extra,
|
||||
} });
|
||||
function fixture(initial = createAgentRoomState()) {
|
||||
let now = 0, sequence = 0;
|
||||
const tasks = new Map();
|
||||
const frames = [];
|
||||
const clock = { now: () => now, schedule(callback, delay) { const id = ++sequence; tasks.set(id, { at: now + delay, callback }); return id; }, cancel(id) { tasks.delete(id); } };
|
||||
const presenter = createAgentRoomPresenter({ initial, reduceEvent: applyAgentEvent, publish: (room, isTyping) => frames.push({ room, isTyping }), clock });
|
||||
const advance = ms => {
|
||||
const end = now + ms;
|
||||
while (true) {
|
||||
const next = [...tasks].sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!next || next[1].at > end) break;
|
||||
now = next[1].at; tasks.delete(next[0]); next[1].callback();
|
||||
}
|
||||
now = end;
|
||||
};
|
||||
return { presenter, advance, frames, last: () => frames.at(-1) };
|
||||
}
|
||||
|
||||
test('a reply and removal of its old thinking indicator publish together even when idle is delayed', () => {
|
||||
const f = fixture();
|
||||
f.presenter.push(activity(true)); f.advance(250); assert.equal(f.last().isTyping, true);
|
||||
f.presenter.push(page(2, [message(2)])); f.advance(100);
|
||||
assert.equal(f.last().room.messages.length, 1); assert.equal(f.last().isTyping, false);
|
||||
assert.equal(f.last().room.activity.isThinking, true, 'raw sidebar activity remains unchanged');
|
||||
f.presenter.push(activity(true)); f.advance(1000); assert.equal(f.last().isTyping, false, 'identical busy snapshots cannot revive stale typing');
|
||||
f.presenter.push(activity(false)); f.advance(100);
|
||||
assert.ok(f.frames.every(frame => !frame.room.messages.length || !frame.isTyping));
|
||||
});
|
||||
|
||||
test('split messages/activity frames batch without reply-plus-thinking flashes or transient typing starts', () => {
|
||||
const f = fixture();
|
||||
f.presenter.push(activity(true)); f.advance(50);
|
||||
f.presenter.push(page(2, [message(1, 'user'), message(2)])); f.advance(20);
|
||||
f.presenter.push(activity(false)); f.advance(30);
|
||||
assert.equal(f.frames.length, 1); assert.equal(f.last().room.messages.length, 2); assert.equal(f.last().isTyping, false);
|
||||
f.advance(1000); assert.equal(f.frames.length, 1);
|
||||
});
|
||||
|
||||
test('new user work and new tools can start typing again, but delivery tools cannot', () => {
|
||||
const f = fixture();
|
||||
f.presenter.push(activity(true)); f.advance(250);
|
||||
f.presenter.push(page(2, [message(2)])); f.advance(100);
|
||||
f.presenter.push(activity(true, { tools: [{ id: 'send-reply', name: 'user_message', status: 'running' }] }));
|
||||
f.advance(300); assert.equal(f.last().isTyping, false);
|
||||
f.presenter.push(page(3, [message(3, 'user')])); f.advance(250); assert.equal(f.last().isTyping, true);
|
||||
f.presenter.push(page(4, [message(4)])); f.advance(100); assert.equal(f.last().isTyping, false);
|
||||
f.presenter.push(activity(true, { tools: [{ id: 'new-browser', name: 'browser', status: 'running' }] }));
|
||||
f.advance(250); assert.equal(f.last().isTyping, true);
|
||||
});
|
||||
|
||||
test('initial history and duplicate replay do not suppress genuine new activity', () => {
|
||||
const f = fixture();
|
||||
f.presenter.push(activity(true));
|
||||
f.presenter.push(page(7, [message(5), message(7)]), { replay: true });
|
||||
f.advance(250); assert.equal(f.last().isTyping, true);
|
||||
f.presenter.push(page(7, [message(5), message(7)])); f.advance(100);
|
||||
assert.equal(f.last().isTyping, true); assert.equal(f.last().room.messages.length, 2);
|
||||
f.presenter.push(page(9, [message(9)])); f.advance(100); assert.equal(f.last().isTyping, false);
|
||||
f.presenter.push(activity(false)); f.advance(100);
|
||||
f.presenter.push(activity(true)); f.advance(250); assert.equal(f.last().isTyping, true);
|
||||
f.presenter.push(page(9, [message(9)])); f.advance(100); assert.equal(f.last().isTyping, true);
|
||||
});
|
||||
|
||||
test('independent background work remains visible after the root yields', () => {
|
||||
const f = fixture();
|
||||
f.presenter.push(activity(true, { backgroundWorkers: 1 })); f.advance(250);
|
||||
f.presenter.push(page(2, [message(2)])); f.advance(100); assert.equal(f.last().isTyping, false);
|
||||
f.presenter.push(activity(false, { backgroundWorkers: 1 })); f.advance(250);
|
||||
assert.equal(f.last().isTyping, true); assert.equal(f.last().room.activity.isThinking, false);
|
||||
});
|
||||
|
||||
test('bursts preserve users, tools, queued messages, cursor, and read state in bounded publications', () => {
|
||||
const f = fixture();
|
||||
f.presenter.push(page(1, [message(1, 'user')])); f.advance(50);
|
||||
const tool = message(2, 'tool', { metadata: { kind: 'tool_call', toolCallId: 'browser-1', toolName: 'browser', status: 'initiated', entryId: 2 } });
|
||||
f.presenter.push(page(2, [tool])); f.advance(40);
|
||||
const queued = message(3, 'user', { metadata: { kind: 'message', status: 'queued' } });
|
||||
f.presenter.push(activity(true, { queued: 1, queuedMessages: [queued] }));
|
||||
f.presenter.update(state => applyAgentReadState(state, { cursor: '1', updatedAt: null })); f.advance(10);
|
||||
assert.equal(f.frames.length, 1); assert.equal(f.last().room.cursor, '2'); assert.equal(f.last().room.readCursor, '1');
|
||||
assert.equal(f.last().room.messages.length, 2); assert.deepEqual(f.last().room.activity.queuedMessages, [queued]);
|
||||
f.presenter.push(page(4, [{ ...tool, metadata: { ...tool.metadata, status: 'completed', entryId: 4 } }]));
|
||||
f.presenter.flush(); assert.equal(f.last().room.messages.length, 2); assert.equal(f.last().room.cursor, '4');
|
||||
});
|
||||
|
||||
test('disposing an unpublished batch leaves delivery and read cursors at the last rendered snapshot', () => {
|
||||
const f = fixture(); f.presenter.push(page(2, [message(2)])); f.presenter.dispose(); f.advance(1000);
|
||||
assert.equal(f.frames.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
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");
|
||||
});
|
||||
@@ -8,6 +8,14 @@ const workspaceItems = [
|
||||
{ type: "search", id: "selected-search" },
|
||||
];
|
||||
|
||||
test("persistent agent selection survives history refreshes and empty collections", () => {
|
||||
const agent = { kind: "agent", id: "root" };
|
||||
assert.deepEqual(resolveSidebarSelectionAfterRefresh(agent, workspaceItems, { selectFallback: true }), agent);
|
||||
assert.deepEqual(resolveSidebarSelectionAfterRefresh(agent, []), agent);
|
||||
assert.deepEqual(resolveSidebarSelectionAfterRefresh(null, workspaceItems, { initialSelection: agent, selectFallback: true }), agent);
|
||||
assert.deepEqual(resolveSidebarSelectionAfterRefresh({ kind: "chat", id: "selected-chat" }, workspaceItems, { initialSelection: agent }), { kind: "chat", id: "selected-chat" });
|
||||
});
|
||||
|
||||
test("a collection refresh preserves the current thread selection", () => {
|
||||
assert.deepEqual(
|
||||
resolveSidebarSelectionAfterRefresh({ kind: "chat", id: "selected-chat" }, workspaceItems),
|
||||
|
||||
Reference in New Issue
Block a user