Files
Sybil-2/web/tests/agent-room-presentation.test.mjs
T

101 lines
6.2 KiB
JavaScript
Raw Normal View History

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);
});