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,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);
|
||||
});
|
||||
Reference in New Issue
Block a user