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
+184
View File
@@ -0,0 +1,184 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { once } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { createServer } from 'node:http';
import { createConnection } from 'node:net';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import Fastify from 'fastify';
import sensible from '@fastify/sensible';
import { WebSocket, WebSocketServer } from 'ws';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const directory = mkdtempSync(join(tmpdir(), 'sybil-agent-'));
process.env.DATABASE_URL = `file:${join(directory, 'agent.db')}`;
process.env.ADMIN_TOKEN = 'sybil-test-token-0123456789';
execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], {
cwd: root, env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: '1' }, stdio: 'pipe',
});
const [{ registerAgentRoutes }, { prisma }, { env }] = await Promise.all([
import('../src/agent.js'), import('../src/db.js'), import('../src/env.js'),
]);
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` };
const privateKey = 'pi-private-key-for-test';
const requests: { method?: string; url?: string; body: any }[] = [];
const accepted = new Map<string, number>();
let upstreamReply: { status: number; body: string } | undefined;
let connections = 0;
const subscriptions: any[] = [];
const upstream = createServer(async (req, res) => {
assert.equal(req.headers.authorization, `Bearer ${privateKey}`);
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null;
requests.push({ method: req.method, url: req.url, body });
res.setHeader('Content-Type', 'application/json');
if (upstreamReply) { res.statusCode = upstreamReply.status; res.end(upstreamReply.body); return; }
if (req.method === 'POST') {
const id = accepted.get(body.requestId) || accepted.size + 1; accepted.set(body.requestId, id);
res.statusCode = 202; res.end(JSON.stringify({ id, status: 'queued', messageId: `inbound:sybil:${body.requestId}` }));
} else if (req.url?.includes('/messages')) {
res.end(JSON.stringify({ conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', beforeCursor: '43', hasMore: false }));
} else res.end(JSON.stringify({ id: '1', type: 'persistent-agent', title: 'Pi', websocket: '/api/ws', protocol: 'pi-room.v1', activity: { thinking: true } }));
});
const sourceSockets = new WebSocketServer({ noServer: true });
upstream.on('upgrade', (req, socket, head) => {
assert.equal(req.headers.authorization, `Bearer ${privateKey}`);
assert.equal(req.url, '/api/ws');
connections++;
sourceSockets.handleUpgrade(req, socket, head, ws => {
ws.on('message', raw => {
const frame = JSON.parse(raw.toString()); subscriptions.push(frame);
ws.send(JSON.stringify({ type: 'ready', protocol: 'pi-room.v1', conversationId: '1' }));
ws.send(JSON.stringify({ type: 'messages', conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', hasMore: false }));
ws.send(JSON.stringify({ type: 'activity', conversationId: '1', activity: { thinking: true, responding: true } }));
});
});
});
upstream.listen(0, '127.0.0.1'); await once(upstream, 'listening');
const upstreamAddress = upstream.address() as { port: number };
const app = Fastify({ logger: false }); await app.register(sensible);
await registerAgentRoutes(app, { baseUrl: `http://127.0.0.1:${upstreamAddress.port}`, apiKey: privateKey });
await app.listen({ port: 0, host: '127.0.0.1' });
const address = app.server.address() as { port: number };
const wsUrl = `ws://127.0.0.1:${address.port}/v1/agent/ws`;
test.after(async () => {
await app.close();
for (const socket of sourceSockets.clients) socket.terminate();
sourceSockets.close();
await new Promise<void>(resolve => upstream.close(() => resolve()));
await prisma.$disconnect();
rmSync(directory, { recursive: true, force: true });
});
async function eventually(check: () => boolean) {
for (let i = 0; i < 100; i++) { if (check()) return; await new Promise(resolve => setTimeout(resolve, 10)); }
assert.fail('Expected condition was not reached');
}
test('REST authenticates, proxies the shared room, and retains stable message IDs without revealing Pi credentials', async () => {
assert.equal((await app.inject({ url: '/v1/agent' })).statusCode, 401);
const descriptor = await app.inject({ url: '/v1/agent', headers: auth });
assert.equal(descriptor.statusCode, 200);
assert.equal(descriptor.json().websocket, '/v1/agent/ws');
assert.ok(!descriptor.body.includes(privateKey));
const page = await app.inject({ url: '/v1/agent/messages?after=42&limit=20', headers: auth });
assert.equal(page.json().messages[0].role, 'assistant');
assert.match(requests.at(-1)!.url!, /after=42&limit=20/);
const input = { content: ' Keep watching\n', requestId: 'same-client-message' };
const first = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
const second = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
assert.equal(first.statusCode, 202); assert.deepEqual(first.json(), second.json());
assert.deepEqual(requests.at(-1)!.body, { ...input, channel: 'sybil', whenBusy: 'followUp', wait: false });
assert.equal((await prisma.chat.count()), 0, 'Pi messages are not copied into ordinary Sybil chats');
assert.equal((await app.inject({ url: '/v1/agent/messages?after=2&before=3', headers: auth })).statusCode, 400);
assert.equal((await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: { content: 'Hello' } })).statusCode, 400);
});
test('the read cursor stays monotonic under concurrent tabs and survives reconnecting to SQLite', async () => {
const initial = await app.inject({ url: '/v1/agent/read-state', headers: auth });
assert.equal(initial.json().cursor, '0');
assert.equal(initial.headers['cache-control'], 'no-store');
await Promise.all(['900', '4', '100', '12'].map(cursor => app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor } })));
assert.equal((await app.inject({ url: '/v1/agent/read-state', headers: auth })).json().cursor, '900');
await prisma.$disconnect();
assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '5' } })).json().cursor, '900');
assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '-1' } })).statusCode, 400);
});
test('malformed upstream JSON and unconfirmed message receipts return 502, preserving safe retries', async () => {
const payload = { content: 'One durable message', requestId: 'invalid-receipt-retry' };
try {
upstreamReply = { status: 200, body: '{truncated' };
for (const url of ['/v1/agent', '/v1/agent/messages']) {
const response = await app.inject({ url, headers: auth });
assert.equal(response.statusCode, 502);
assert.equal(response.headers['cache-control'], 'no-store');
}
for (const body of ['{truncated', '{}', 'null', JSON.stringify({ id: 1, status: 'queued', messageId: 'wrong-message' }),
JSON.stringify({ id: 1, status: 'unknown', messageId: `inbound:sybil:${payload.requestId}` })]) {
upstreamReply = { status: 202, body };
const response = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload });
assert.equal(response.statusCode, 502, body);
assert.equal(response.headers['cache-control'], 'no-store');
}
} finally { upstreamReply = undefined; }
const retry = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload });
assert.equal(retry.statusCode, 202);
assert.equal(retry.json().messageId, `inbound:sybil:${payload.requestId}`);
});
test('malformed and unmatched upgrade targets close without crashing or opening Pi connections', async () => {
const before = connections;
const requestUpgrade = (target: string): Promise<string> => new Promise((resolve, reject) => {
let response = '';
const socket = createConnection({ host: '127.0.0.1', port: address.port }, () => {
socket.write(`GET ${target} HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n`);
});
socket.setTimeout(1000, () => socket.destroy(new Error('Upgrade rejection did not close the socket')));
socket.on('data', chunk => { response += chunk.toString(); });
socket.on('error', reject);
socket.on('end', () => { socket.destroy(); resolve(response); });
});
assert.match(await requestUpgrade('http://['), /^HTTP\/1\.1 400 /);
assert.match(await requestUpgrade('/v1/not-an-agent/ws'), /^HTTP\/1\.1 404 /);
assert.match(await requestUpgrade('/v1/agent/ws?token=forbidden'), /^HTTP\/1\.1 400 /);
assert.equal(connections, before);
assert.equal((await app.inject({ url: '/v1/agent', headers: auth })).statusCode, 200);
});
test('WebSocket first-frame authentication gates the private connection and forwards resumable native frames', async () => {
const before = connections;
const denied = new WebSocket(wsUrl); await once(denied, 'open');
denied.send(JSON.stringify({ type: 'auth', token: 'wrong' }));
const [code] = await once(denied, 'close');
assert.equal(code, 4401); assert.equal(connections, before);
const browser = new WebSocket(wsUrl);
const frames: any[] = []; browser.on('message', raw => frames.push(JSON.parse(raw.toString())));
await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: env.ADMIN_TOKEN, after: '42' }));
await eventually(() => frames.some(frame => frame.type === 'activity'));
assert.deepEqual(subscriptions.at(-1), { type: 'subscribe', after: '42' });
assert.deepEqual(frames.map(frame => frame.type), ['ready', 'messages', 'activity']);
assert.equal(frames[1].messages[0].content, 'Later result');
assert.equal(frames[2].activity.thinking, true);
assert.ok(!JSON.stringify(frames).includes(privateKey));
browser.send(JSON.stringify({ type: 'subscribe', after: '43' }));
await eventually(() => subscriptions.at(-1)?.after === '43');
browser.close(); await once(browser, 'close');
await eventually(() => sourceSockets.clients.size === 0);
});
test('open-mode WebSocket authentication accepts null and still keeps the Pi token server-side', async () => {
const saved = env.ADMIN_TOKEN; env.ADMIN_TOKEN = undefined;
try {
const browser = new WebSocket(wsUrl); const frames: any[] = [];
browser.on('message', raw => frames.push(JSON.parse(raw.toString())));
await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: null, after: '0' }));
await eventually(() => frames.some(frame => frame.type === 'ready'));
browser.close(); await once(browser, 'close');
} finally { env.ADMIN_TOKEN = saved; }
});