101 lines
6.2 KiB
JavaScript
101 lines
6.2 KiB
JavaScript
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);
|
||
|
|
});
|