Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
386 lines
18 KiB
TypeScript
386 lines
18 KiB
TypeScript
/**
|
|
* groupMessagesIntoTurns — Unit Tests
|
|
*
|
|
* Tests the message grouping logic that converts flat ChatMessage[]
|
|
* into Turn[] for rendering in the chat UI.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { groupMessagesIntoTurns, type Turn } from '../groupMessagesIntoTurns';
|
|
import type { ChatMessage } from '../../types';
|
|
|
|
// Helper to create a minimal ChatMessage
|
|
function msg(overrides: Partial<ChatMessage> & { id: string; role: ChatMessage['role'] }): ChatMessage {
|
|
return {
|
|
content: '',
|
|
timestamp: new Date(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const NO_REACTIONS = {};
|
|
|
|
describe('groupMessagesIntoTurns', () => {
|
|
// ─── Empty input ─────────────────────────────────────────────
|
|
it('returns empty array for empty messages', () => {
|
|
const turns = groupMessagesIntoTurns([], NO_REACTIONS, false);
|
|
expect(turns).toEqual([]);
|
|
});
|
|
|
|
// ─── Single user message ─────────────────────────────────────
|
|
it('creates a human turn for a single user message', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: '1', role: 'user', content: 'hello', sender_id: 10 }),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, false, 10);
|
|
expect(turns).toHaveLength(1);
|
|
expect(turns[0].turnType).toBe('human');
|
|
expect(turns[0].senderName).toBe('You');
|
|
expect(turns[0].messages).toHaveLength(1);
|
|
});
|
|
|
|
// ─── Consecutive user messages from same sender ─────────────
|
|
it('groups consecutive user messages from the same sender', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: '1', role: 'user', content: 'hello', sender_id: 10 }),
|
|
msg({ id: '2', role: 'user', content: 'world', sender_id: 10 }),
|
|
msg({ id: '3', role: 'user', content: '!', sender_id: 10 }),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, false, 10);
|
|
expect(turns).toHaveLength(1);
|
|
expect(turns[0].messages).toHaveLength(3);
|
|
});
|
|
|
|
// ─── Different senders split into separate turns ────────────
|
|
it('splits user messages from different senders into separate turns', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: '1', role: 'user', content: 'hi', sender_id: 10 }),
|
|
msg({ id: '2', role: 'user', content: 'hey', sender_id: 20, sender_name: 'Alice' }),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, false, 10);
|
|
expect(turns).toHaveLength(2);
|
|
expect(turns[0].senderName).toBe('You');
|
|
expect(turns[1].senderName).toBe('Alice');
|
|
});
|
|
|
|
// ─── Standalone assistant text (no tools) ───────────────────
|
|
it('creates agent turn for standalone assistant text', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: '1', role: 'assistant', content: 'response' }),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, false);
|
|
expect(turns).toHaveLength(1);
|
|
expect(turns[0].turnType).toBe('agent');
|
|
expect(turns[0].senderName).toBe('AI');
|
|
});
|
|
|
|
// ─── Agent steps: thinking + tool_call + tool_result + text ──
|
|
it('groups agent steps (thinking, tool_call, tool_result, text) into one turn', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: '1', role: 'user', content: 'do something' }),
|
|
msg({ id: '2', role: 'assistant', content: 'thinking...', contentType: 'thinking', agentName: 'Agent' }),
|
|
msg({ id: '3', role: 'assistant', content: '{"tool":"search"}', contentType: 'tool_call', agentName: 'Agent' }),
|
|
msg({ id: '4', role: 'tool', content: 'result data', contentType: 'tool_result', agentName: 'Agent' }),
|
|
msg({ id: '5', role: 'assistant', content: 'Here is the answer', contentType: 'text', agentName: 'Agent' }),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, false);
|
|
expect(turns).toHaveLength(2); // 1 human + 1 agent
|
|
expect(turns[0].turnType).toBe('human');
|
|
expect(turns[1].turnType).toBe('agent');
|
|
expect(turns[1].messages).toHaveLength(4); // thinking + tool_call + tool_result + text
|
|
expect(turns[1].senderName).toBe('Agent');
|
|
expect(turns[1].isProcessing).toBe(false);
|
|
});
|
|
|
|
// ─── isProcessing flag on last agent turn ───────────────────
|
|
it('marks last agent turn as processing when isAgentProcessing=true and no final text', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: '1', role: 'user', content: 'do something' }),
|
|
msg({ id: '2', role: 'assistant', content: '{"tool":"search"}', contentType: 'tool_call' }),
|
|
msg({ id: '3', role: 'tool', content: 'result', contentType: 'tool_result' }),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, true);
|
|
expect(turns).toHaveLength(2);
|
|
expect(turns[1].turnType).toBe('agent');
|
|
expect(turns[1].isProcessing).toBe(true);
|
|
});
|
|
|
|
it('does NOT mark agent turn as processing when it has final text', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: '1', role: 'user', content: 'do something' }),
|
|
msg({ id: '2', role: 'assistant', content: '{"tool":"search"}', contentType: 'tool_call' }),
|
|
msg({ id: '3', role: 'tool', content: 'result', contentType: 'tool_result' }),
|
|
msg({ id: '4', role: 'assistant', content: 'done!', contentType: 'text' }),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, true);
|
|
expect(turns).toHaveLength(2);
|
|
expect(turns[1].isProcessing).toBe(false);
|
|
});
|
|
|
|
// ─── Race condition fix: processing with only tool steps ────
|
|
it('marks last agent turn as processing via post-process when only tool steps exist', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: '1', role: 'user', content: 'query' }),
|
|
msg({ id: '2', role: 'assistant', content: 'thinking', contentType: 'thinking' }),
|
|
msg({ id: '3', role: 'assistant', content: '{"tool":"db_query"}', contentType: 'tool_call' }),
|
|
];
|
|
// Agent is still processing (tool results not arrived yet)
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, true);
|
|
const agentTurn = turns.find(t => t.turnType === 'agent');
|
|
expect(agentTurn).toBeDefined();
|
|
expect(agentTurn!.isProcessing).toBe(true);
|
|
});
|
|
|
|
// ─── Human turn does not get processing flag ────────────────
|
|
it('does not mark human turns as processing even when isAgentProcessing=true', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: '1', role: 'user', content: 'hello' }),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, true);
|
|
expect(turns[0].isProcessing).toBe(false);
|
|
});
|
|
|
|
// ─── Mixed conversation: user → agent → user → agent ────────
|
|
it('handles alternating human and agent turns', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: '1', role: 'user', content: 'q1', sender_id: 1 }),
|
|
msg({ id: '2', role: 'assistant', content: 'a1' }),
|
|
msg({ id: '3', role: 'user', content: 'q2', sender_id: 1 }),
|
|
msg({ id: '4', role: 'assistant', content: 'a2' }),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, false, 1);
|
|
expect(turns).toHaveLength(4);
|
|
expect(turns.map(t => t.turnType)).toEqual(['human', 'agent', 'human', 'agent']);
|
|
});
|
|
|
|
// ─── isFirstInGroup / isLastInGroup ─────────────────────────
|
|
it('computes isFirstInGroup and isLastInGroup for consecutive same-sender turns', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: '1', role: 'assistant', content: 'a1', agentName: 'Bot' }),
|
|
msg({ id: '2', role: 'user', content: 'u1', sender_id: 1 }),
|
|
msg({ id: '3', role: 'assistant', content: 'a2', agentName: 'Bot' }),
|
|
msg({ id: '4', role: 'user', content: 'u2', sender_id: 1 }),
|
|
msg({ id: '5', role: 'user', content: 'u3', sender_id: 1 }),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, false, 1);
|
|
// Should be: agent, human(u1), agent, human(u2+u3)
|
|
expect(turns).toHaveLength(4);
|
|
// No consecutive same-type turns, so all should be first and last
|
|
turns.forEach(t => {
|
|
expect(t.isFirstInGroup).toBe(true);
|
|
expect(t.isLastInGroup).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── Reactions ──────────────────────────────────────────────
|
|
it('aggregates reactions for agent turn messages', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: '10', role: 'assistant', content: 'tool', contentType: 'tool_call' }),
|
|
msg({ id: '11', role: 'tool', content: 'result', contentType: 'tool_result' }),
|
|
msg({ id: '12', role: 'assistant', content: 'done', contentType: 'text' }),
|
|
];
|
|
const reactions = {
|
|
12: { '👍': [{ user_id: 1, user_name: 'Alice' }] },
|
|
};
|
|
const turns = groupMessagesIntoTurns(messages, reactions, false);
|
|
expect(turns).toHaveLength(1);
|
|
expect(turns[0].reactions).toHaveProperty('👍');
|
|
});
|
|
|
|
// ─── Optimistic messages (no sender_id) group with real msgs ─
|
|
it('groups optimistic messages (no sender_id) with real messages from same user', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: 'temp-1', role: 'user', content: 'optimistic' }), // no sender_id
|
|
msg({ id: '100', role: 'user', content: 'confirmed', sender_id: 5 }),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, false, 5);
|
|
expect(turns).toHaveLength(1);
|
|
expect(turns[0].messages).toHaveLength(2);
|
|
});
|
|
|
|
// ─── System messages treated as agent ───────────────────────
|
|
it('treats system role messages as agent turns', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: '1', role: 'system', content: 'Agent not found' }),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, false);
|
|
expect(turns).toHaveLength(1);
|
|
expect(turns[0].turnType).toBe('agent');
|
|
});
|
|
|
|
// ─── Ticket #42123: metadata.agent_name takes priority ──────
|
|
it('resolves senderName from metadata.agent_name when agentName is missing', () => {
|
|
// Backend sends metadata.agent_name (snake_case) but frontend ChatMessage
|
|
// type uses agentName (camelCase). The hook returns raw API data, so
|
|
// agentName is often undefined while metadata.agent_name is set.
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: '1', role: 'user', content: 'hello', sender_id: 10 }),
|
|
msg({
|
|
id: '2',
|
|
role: 'assistant',
|
|
content: 'thinking...',
|
|
contentType: 'thinking',
|
|
// agentName is NOT set (simulates raw API response)
|
|
metadata: { agent_name: 'Architect', agent_row_id: 100 },
|
|
sender_name: 'Orchestrator', // wrong name from conversation-level JOIN
|
|
}),
|
|
msg({
|
|
id: '3',
|
|
role: 'assistant',
|
|
content: '{"tool":"search"}',
|
|
contentType: 'tool_call',
|
|
metadata: { agent_name: 'Architect', agent_row_id: 100 },
|
|
sender_name: 'Orchestrator',
|
|
}),
|
|
msg({
|
|
id: '4',
|
|
role: 'tool',
|
|
content: 'result',
|
|
contentType: 'tool_result',
|
|
metadata: { agent_name: 'Architect', agent_row_id: 100 },
|
|
sender_name: 'Orchestrator',
|
|
}),
|
|
msg({
|
|
id: '5',
|
|
role: 'assistant',
|
|
content: 'Here is the answer',
|
|
contentType: 'text',
|
|
metadata: { agent_name: 'Architect', agent_row_id: 100 },
|
|
sender_name: 'Orchestrator',
|
|
}),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, false, 10);
|
|
expect(turns).toHaveLength(2); // human + agent
|
|
// Must show "Architect" from metadata, NOT "Orchestrator" from sender_name
|
|
expect(turns[1].senderName).toBe('Architect');
|
|
});
|
|
|
|
it('resolves senderName from metadata.agent_name for standalone agent text', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({
|
|
id: '1',
|
|
role: 'assistant',
|
|
content: 'plain response',
|
|
metadata: { agent_name: 'Developer Ralph' },
|
|
sender_name: 'AI Agent', // generic fallback
|
|
}),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, false);
|
|
expect(turns).toHaveLength(1);
|
|
expect(turns[0].senderName).toBe('Developer Ralph');
|
|
});
|
|
|
|
it('prefers agentName (camelCase) over metadata.agent_name when both exist', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({
|
|
id: '1',
|
|
role: 'assistant',
|
|
content: 'response',
|
|
agentName: 'Frontend Dev',
|
|
metadata: { agent_name: 'Frontend Developer' },
|
|
sender_name: 'Some Other Name',
|
|
}),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, false);
|
|
expect(turns).toHaveLength(1);
|
|
expect(turns[0].senderName).toBe('Frontend Dev');
|
|
});
|
|
|
|
it('resolves metadata.agent_name for human turn with agent senderType', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({
|
|
id: '1',
|
|
role: 'user',
|
|
content: 'agent message in user role',
|
|
senderType: 'agent',
|
|
metadata: { agent_name: 'Test Agent' },
|
|
}),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, false);
|
|
expect(turns).toHaveLength(1);
|
|
expect(turns[0].senderName).toBe('Test Agent');
|
|
});
|
|
|
|
// ─── Ticket #42123: different agents split into separate turns (grouping bug) ──
|
|
it('creates separate turns for consecutive standalone messages from different agents', () => {
|
|
// Scenario: @architect called but Orchestrator message arrives first as standalone text,
|
|
// then Architect responds. Both should appear under their own names.
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: '1', role: 'user', content: '@architect do something', sender_id: 1 }),
|
|
msg({
|
|
id: '2',
|
|
role: 'assistant',
|
|
content: 'Routing to Architect...',
|
|
metadata: { agent_name: 'Orchestrator' },
|
|
sender_name: 'Orchestrator',
|
|
}),
|
|
msg({
|
|
id: '3',
|
|
role: 'assistant',
|
|
content: 'Hello, I am Architect!',
|
|
metadata: { agent_name: 'Architect' },
|
|
sender_name: 'Orchestrator', // wrong chatPartner-level name — must be ignored
|
|
}),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, false, 1);
|
|
// Must produce 3 turns: human, Orchestrator, Architect
|
|
expect(turns).toHaveLength(3);
|
|
expect(turns[0].turnType).toBe('human');
|
|
expect(turns[1].senderName).toBe('Orchestrator');
|
|
expect(turns[2].senderName).toBe('Architect');
|
|
});
|
|
|
|
it('groups consecutive standalone messages from the SAME agent into one turn', () => {
|
|
// Multiple messages from Orchestrator in a row → single agent turn
|
|
const messages: ChatMessage[] = [
|
|
msg({
|
|
id: '1',
|
|
role: 'assistant',
|
|
content: 'First paragraph',
|
|
metadata: { agent_name: 'Orchestrator' },
|
|
}),
|
|
msg({
|
|
id: '2',
|
|
role: 'assistant',
|
|
content: 'Second paragraph',
|
|
metadata: { agent_name: 'Orchestrator' },
|
|
}),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, false);
|
|
expect(turns).toHaveLength(1);
|
|
expect(turns[0].senderName).toBe('Orchestrator');
|
|
expect(turns[0].messages).toHaveLength(2);
|
|
});
|
|
|
|
it('separates step-based turns from different agents correctly', () => {
|
|
// Orchestrator runs tool steps, then Architect runs its own tool steps
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: '1', role: 'user', content: 'query', sender_id: 1 }),
|
|
msg({ id: '2', role: 'assistant', content: 'thinking', contentType: 'thinking', metadata: { agent_name: 'Orchestrator' } }),
|
|
msg({ id: '3', role: 'assistant', content: '{"tool":"route"}', contentType: 'tool_call', metadata: { agent_name: 'Orchestrator' } }),
|
|
msg({ id: '4', role: 'tool', content: 'routed', contentType: 'tool_result', metadata: { agent_name: 'Orchestrator' } }),
|
|
msg({ id: '5', role: 'assistant', content: 'done', contentType: 'text', metadata: { agent_name: 'Orchestrator' } }),
|
|
msg({ id: '6', role: 'assistant', content: 'arch thinking', contentType: 'thinking', metadata: { agent_name: 'Architect' } }),
|
|
msg({ id: '7', role: 'assistant', content: 'arch answer', contentType: 'text', metadata: { agent_name: 'Architect' } }),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, false, 1);
|
|
expect(turns).toHaveLength(3); // human, Orchestrator steps+text, Architect steps+text
|
|
expect(turns[1].senderName).toBe('Orchestrator');
|
|
expect(turns[2].senderName).toBe('Architect');
|
|
});
|
|
|
|
// ─── Sub-agent multi-turn conversation ──────────────────────
|
|
it('handles sub-agent conversation with different agent names', () => {
|
|
const messages: ChatMessage[] = [
|
|
msg({ id: '1', role: 'user', content: 'translate this', sender_id: 1 }),
|
|
msg({ id: '2', role: 'assistant', content: '{"tool":"translate"}', contentType: 'tool_call', agentName: 'MainAgent' }),
|
|
msg({ id: '3', role: 'tool', content: 'translated text', contentType: 'tool_result', agentName: 'MainAgent' }),
|
|
msg({ id: '4', role: 'assistant', content: 'Here you go', contentType: 'text', agentName: 'MainAgent' }),
|
|
msg({ id: '5', role: 'assistant', content: 'Sub-agent summary', agentName: 'Summarizer' }),
|
|
];
|
|
const turns = groupMessagesIntoTurns(messages, NO_REACTIONS, false, 1);
|
|
expect(turns).toHaveLength(3); // human, MainAgent steps, Summarizer text
|
|
expect(turns[1].senderName).toBe('MainAgent');
|
|
expect(turns[2].senderName).toBe('Summarizer');
|
|
});
|
|
});
|