2026-10-14 12:20:11 -07:00
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
import test from 'node:test';
|
2026-10-14 15:29:50 -07:00
|
|
|
import { agentDisplayMessages, agentUnreadCount, applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentRoomState, parseAgentRoomEvent } from '../src/lib/agent-room.ts';
|
|
|
|
|
import { agentTranscriptMessages, createAgentRoomPresenter } from '../src/lib/agent-room-presentation.ts';
|
2026-10-14 12:20:11 -07:00
|
|
|
|
|
|
|
|
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) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-14 15:29:50 -07:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-14 12:20:11 -07:00
|
|
|
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);
|
|
|
|
|
});
|