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