import assert from 'node:assert/strict'; import test from 'node:test'; import { agentDisplayMessages, agentUnreadCount, applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentRoomState, parseAgentRoomEvent } from '../src/lib/agent-room.ts'; import { agentTranscriptMessages, 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('loaded history hides tool calls and results without removing records or changing normal messages', () => { const user = message(1, 'user', { content: 'Explain this tool_call and tool_result JSON.' }); const reply = message(5, 'assistant', { content: '**Result**\n\nThe lookup failed; please try another source.' }); const tools = ['initiated', 'completed', 'failed'].map((status, index) => message(index + 2, 'tool', { id: `tool:1:lookup-${index}`, name: 'lookup', content: 'Raw tool details', metadata: { kind: 'tool_call', entryId: index + 2, toolCallId: `lookup-${index}`, status, args: { query: 'raw input' }, resultPreview: 'raw output', error: status === 'failed' ? 'raw error' : null }, })); const room = applyAgentPage(createAgentRoomState(), page(5, [message(0, 'system'), user, ...tools, reply])); const original = structuredClone(room); const transcript = agentTranscriptMessages(agentDisplayMessages(room)); assert.deepEqual(transcript, [user, reply]); assert.strictEqual(transcript[0], user); assert.strictEqual(transcript[1], reply); assert.deepEqual(room, original, 'presentation must leave tool records and cursor intact'); assert.equal(room.messages.length, 6); assert.equal(agentUnreadCount(room), 1); }); test('tool-only history produces no transcript rows regardless of metadata or status', () => { const messages = [null, {}, [], 'legacy metadata', { kind: 'tool_result' }, { kind: 'tool_call', status: 'failed' }] .map((metadata, index) => message(index + 1, 'tool', { metadata, createdAt: `2026-10-${10 + index}T00:00:00.000Z` })); assert.deepEqual(agentTranscriptMessages(Object.freeze(messages)), []); assert.deepEqual(agentTranscriptMessages([]), []); }); test('user delivery errors, queued messages, and assistant content remain visible even with tool-like metadata', () => { const users = ['sending', 'queued', 'failed', 'unconfirmed'].map((status, index) => message(index + 1, 'user', { metadata: { kind: 'message', status, error: 'Could not send; retry this message.' }, })); const reply = message(5, 'assistant', { content: 'The tool_result contained an error. **Please retry.**', metadata: { kind: 'tool_call', status: 'failed', error: 'Keep this assistant message.' }, }); const room = applyAgentEvent(applyAgentPage(createAgentRoomState(), page(5, [users[0], ...users.slice(2), reply])), activity(false, { queued: 1, queuedMessages: [users[1]] })); const transcript = agentTranscriptMessages(agentDisplayMessages(room)); assert.deepEqual(transcript, [...users, reply]); for (const [index, original] of [...users, reply].entries()) assert.strictEqual(transcript[index], original); }); for (const status of ['completed', 'failed']) { test(`live ${status} tool updates and replay stay hidden while progress, replies, and cursors survive`, () => { const f = fixture(); const user = message(1, 'user'); const started = message(2, 'tool', { id: 'tool:1:lookup', name: 'lookup', metadata: { kind: 'tool_call', toolCallId: 'lookup', toolName: 'lookup', entryId: 2, status: 'initiated', args: { query: 'input' } }, }); const finished = { ...started, content: 'Raw tool output', metadata: { kind: 'tool_call', toolCallId: 'lookup', entryId: 3, status, resultPreview: 'output', error: status === 'failed' ? 'Lookup failed' : null }, }; const progress = activity(true, { tools: [{ id: 'lookup', name: 'lookup', status: 'running' }], backgroundWorkers: 1 }); const receive = event => f.presenter.push(parseAgentRoomEvent(JSON.stringify(event))); f.presenter.push(page(1, [user]), { replay: true }); receive(progress); f.advance(250); for (const event of [page(2, [started]), page(3, [finished]), page(2, [started])]) { receive(event); f.advance(100); const { room, isTyping } = f.last(); assert.deepEqual(agentTranscriptMessages(agentDisplayMessages(room)), [user]); assert.equal(isTyping, true, 'tool visibility must not suppress ongoing progress'); assert.deepEqual(room.activity, progress.activity); assert.equal(room.messages.length, 2, 'tool records still merge by their stable ID'); } assert.equal(f.last().room.cursor, '3'); assert.equal(f.last().room.readCursor, '0'); assert.equal(agentUnreadCount(f.last().room), 0); assert.equal(f.last().room.messages[1].metadata.status, status); assert.deepEqual(f.last().room.messages[1].metadata.args, { query: 'input' }); assert.equal(f.last().room.messages[1].metadata.resultPreview, 'output'); const reply = message(4, 'assistant', { content: status === 'failed' ? 'The lookup failed. Please try again.' : 'Here is the answer.' }); receive(page(4, [reply])); f.advance(100); assert.deepEqual(agentTranscriptMessages(agentDisplayMessages(f.last().room)), [user, reply]); assert.equal(f.last().isTyping, false, 'the answer still clears the old typing indicator'); assert.equal(f.last().room.cursor, '4'); assert.equal(agentUnreadCount(f.last().room), 1); f.presenter.update(room => applyAgentReadState(room, { cursor: '4', updatedAt: null })); f.presenter.flush(); assert.equal(agentUnreadCount(f.last().room), 0); assert.equal(f.last().room.messages.length, 3); f.presenter.dispose(); }); } 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); });