chore: establish learning module baseline
This commit is contained in:
138
OpenMAIC/tests/orchestration/conversation-summary.test.ts
Normal file
138
OpenMAIC/tests/orchestration/conversation-summary.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
summarizeConversation,
|
||||
type OpenAIMessage,
|
||||
} from '@/lib/orchestration/summarizers/conversation-summary';
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
// summarizeConversation() takes OpenAI-format messages from the director path.
|
||||
// In the director path (no currentAgentId), message-converter.ts produces:
|
||||
// - human turns: role:'user', content: '[You]: <text>' (senderName prefix applied)
|
||||
// - agent turns: role:'assistant', content: '<json or text>' (stay as assistant)
|
||||
// There are NO role:'user' messages from agents in the director path.
|
||||
|
||||
const humanMsg = (content: string): OpenAIMessage => ({
|
||||
role: 'user',
|
||||
content: `[You]: ${content}`,
|
||||
});
|
||||
const agentMsg = (content: string): OpenAIMessage => ({ role: 'assistant', content });
|
||||
|
||||
// ==================== summarizeConversation ====================
|
||||
|
||||
describe('summarizeConversation — empty input', () => {
|
||||
test('returns sentinel string for empty message array', () => {
|
||||
expect(summarizeConversation([])).toBe('No conversation history yet.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeConversation — role label correctness (issue #511 core fix)', () => {
|
||||
test('human message with [You]: prefix is labelled [Student (Human)] with prefix stripped', () => {
|
||||
const out = summarizeConversation([humanMsg('Can a 3D object be axisymmetric?')]);
|
||||
expect(out).toContain('[Student (Human)]');
|
||||
expect(out).toContain('Can a 3D object be axisymmetric?');
|
||||
// The [You]: prefix must not appear in summary output
|
||||
expect(out).not.toContain('[You]:');
|
||||
});
|
||||
|
||||
test('human message without any prefix is also labelled [Student (Human)]', () => {
|
||||
// Edge case: if senderName is absent, content has no prefix
|
||||
const bare: OpenAIMessage = { role: 'user', content: 'Bare question' };
|
||||
const out = summarizeConversation([bare]);
|
||||
expect(out).toContain('[Student (Human)]');
|
||||
expect(out).toContain('Bare question');
|
||||
});
|
||||
|
||||
test('agent (assistant role) message is labelled [Agent]', () => {
|
||||
const out = summarizeConversation([agentMsg('Let us examine this together.')]);
|
||||
expect(out).toContain('[Agent]');
|
||||
expect(out).not.toContain('[User]');
|
||||
expect(out).not.toContain('[Student (Human)]');
|
||||
});
|
||||
|
||||
test('mixed conversation: human and agent correctly labelled', () => {
|
||||
const messages: OpenAIMessage[] = [
|
||||
humanMsg('What is axial symmetry?'),
|
||||
agentMsg('Axial symmetry means the shape looks the same after rotation.'),
|
||||
humanMsg('But can a 3D object really be axisymmetric?'),
|
||||
];
|
||||
const out = summarizeConversation(messages);
|
||||
expect(out).toContain('[Student (Human)] What is axial symmetry?');
|
||||
expect(out).toContain('[Agent]');
|
||||
expect(out).toContain('[Student (Human)] But can a 3D object really be axisymmetric?');
|
||||
expect(out).not.toContain('[User]');
|
||||
expect(out).not.toContain('[You]:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeConversation — issue #511 exact scenario', () => {
|
||||
/**
|
||||
* Reproduces the exact failure from issue #511 as it appears in the director path.
|
||||
* The director must distinguish an unanswered human challenge from agent exchanges.
|
||||
*/
|
||||
test('#511 scenario: human challenge and agent reply are distinguishable', () => {
|
||||
const messages: OpenAIMessage[] = [
|
||||
agentMsg('Today we study axial symmetry. The Tiananmen gate is a great example.'),
|
||||
agentMsg('Yes, the gate looks symmetric from the front!'),
|
||||
humanMsg(
|
||||
'Wait — the gate is a 3D structure. Can we really call a 3D object axisymmetric? Symmetry is usually for 2D shapes.',
|
||||
),
|
||||
];
|
||||
|
||||
const out = summarizeConversation(messages);
|
||||
|
||||
expect(out).toContain('[Student (Human)]');
|
||||
expect(out).toContain('3D structure');
|
||||
expect(out).toContain('[Agent]');
|
||||
expect(out).not.toContain('[User]');
|
||||
expect(out).not.toContain('[You]:');
|
||||
|
||||
const lines = out.split('\n');
|
||||
const humanLine = lines.find((l) => l.startsWith('[Student (Human)]'));
|
||||
const agentLine = lines.find((l) => l.startsWith('[Agent]'));
|
||||
expect(humanLine).toBeDefined();
|
||||
expect(agentLine).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeConversation — content truncation', () => {
|
||||
test('content longer than maxContentLength is truncated with ellipsis', () => {
|
||||
const longContent = 'A'.repeat(300);
|
||||
const out = summarizeConversation([humanMsg(longContent)], 10, 200);
|
||||
expect(out).toContain('A'.repeat(200) + '...');
|
||||
expect(out).not.toContain('A'.repeat(201));
|
||||
});
|
||||
|
||||
test('content exactly at maxContentLength is NOT truncated', () => {
|
||||
const exactContent = 'B'.repeat(200);
|
||||
const out = summarizeConversation([humanMsg(exactContent)], 10, 200);
|
||||
expect(out).not.toContain('...');
|
||||
});
|
||||
|
||||
test('agent message content is truncated correctly', () => {
|
||||
const longBody = 'C'.repeat(300);
|
||||
const out = summarizeConversation([agentMsg(longBody)], 10, 200);
|
||||
expect(out).toContain('[Agent]');
|
||||
expect(out).toContain('C'.repeat(200) + '...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeConversation — maxMessages slicing', () => {
|
||||
test('returns only the last maxMessages messages', () => {
|
||||
const messages: OpenAIMessage[] = Array.from({ length: 15 }, (_, i) =>
|
||||
humanMsg(`Message ${i + 1}`),
|
||||
);
|
||||
const out = summarizeConversation(messages, 5);
|
||||
expect(out).toContain('Message 15');
|
||||
expect(out).toContain('Message 11');
|
||||
expect(out).not.toContain('Message 10');
|
||||
});
|
||||
|
||||
test('fewer messages than maxMessages returns all messages', () => {
|
||||
const messages = [humanMsg('Only one message')];
|
||||
const out = summarizeConversation(messages, 10);
|
||||
expect(out).toContain('Only one message');
|
||||
const lines = out.split('\n').filter(Boolean);
|
||||
expect(lines).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { buildStructuredPrompt } from '@/lib/orchestration/prompt-builder';
|
||||
import type { AgentConfig } from '@/lib/orchestration/registry/types';
|
||||
import { getActionDescriptions } from '@/lib/orchestration/tool-schemas';
|
||||
import type { StatelessChatRequest } from '@/lib/types/chat';
|
||||
|
||||
const agent: AgentConfig = {
|
||||
id: 'teacher-1',
|
||||
name: 'Teacher',
|
||||
role: 'teacher',
|
||||
persona: 'A clear math teacher.',
|
||||
avatar: '',
|
||||
color: '#000000',
|
||||
allowedActions: ['wb_draw_text', 'wb_draw_latex'],
|
||||
priority: 100,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
isDefault: true,
|
||||
};
|
||||
|
||||
const storeState: StatelessChatRequest['storeState'] = {
|
||||
stage: null,
|
||||
scenes: [],
|
||||
currentSceneId: null,
|
||||
mode: 'autonomous',
|
||||
whiteboardOpen: false,
|
||||
};
|
||||
|
||||
describe('whiteboard LaTeX prompt guardrails', () => {
|
||||
test('wb_draw_text excludes formulas and directs equations to wb_draw_latex', () => {
|
||||
const description = getActionDescriptions(['wb_draw_text']);
|
||||
|
||||
expect(description.toLowerCase()).not.toContain('formulas');
|
||||
expect(description).toContain('Use wb_draw_latex for mathematical equations');
|
||||
});
|
||||
|
||||
test('structured prompt warns against raw LaTeX in wb_draw_text', () => {
|
||||
const prompt = buildStructuredPrompt(agent, storeState);
|
||||
|
||||
expect(prompt).toContain(
|
||||
"Don't pass raw LaTeX to wb_draw_text; use wb_draw_latex for equations!",
|
||||
);
|
||||
});
|
||||
});
|
||||
171
OpenMAIC/tests/orchestration/whiteboard-conflicts.test.ts
Normal file
171
OpenMAIC/tests/orchestration/whiteboard-conflicts.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { buildWhiteboardConflicts } from '@/lib/orchestration/summarizers/whiteboard-conflicts';
|
||||
|
||||
// Minimal PPTElement stand-ins — the summarizer only reads geometry fields.
|
||||
const text = (id: string, left: number, top: number, width: number, height: number) => ({
|
||||
type: 'text',
|
||||
id,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
content: '<p>sample</p>',
|
||||
});
|
||||
|
||||
const table = (id: string, left: number, top: number, width: number, height: number) => ({
|
||||
type: 'table',
|
||||
id,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
data: [[{ text: 'a' }]],
|
||||
});
|
||||
|
||||
const line = (
|
||||
id: string,
|
||||
left: number,
|
||||
top: number,
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
) => ({ type: 'line', id, left, top, start, end });
|
||||
|
||||
describe('buildWhiteboardConflicts — no conflicts', () => {
|
||||
test('empty element list returns empty string', () => {
|
||||
expect(buildWhiteboardConflicts([])).toBe('');
|
||||
});
|
||||
|
||||
test('two well-separated elements return empty string', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
text('t1', 20, 20, 200, 60),
|
||||
text('t2', 400, 200, 200, 60),
|
||||
]);
|
||||
expect(out).toBe('');
|
||||
});
|
||||
|
||||
test('just-touching bboxes (intersection area = 0) are not reported', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
text('t1', 0, 0, 100, 100),
|
||||
text('t2', 100, 0, 100, 100), // shares only the x=100 edge
|
||||
]);
|
||||
expect(out).toBe('');
|
||||
});
|
||||
|
||||
test('line routed clear of all elements produces no conflict', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
text('t1', 100, 100, 200, 60),
|
||||
line('l1', 0, 0, [50, 50], [50, 400]),
|
||||
]);
|
||||
expect(out).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWhiteboardConflicts — bbox overlap', () => {
|
||||
test('one element fully inside another reports ~100% overlap', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
table('big', 0, 0, 500, 400),
|
||||
text('small', 50, 50, 100, 80), // entirely inside the table
|
||||
]);
|
||||
expect(out).toContain('OVERLAP:');
|
||||
expect(out).toContain('100%');
|
||||
});
|
||||
|
||||
test('50% overlap is reported; 10% is not (30% threshold)', () => {
|
||||
// Each bbox 100×100; smaller area = 10000. Overlap area = 50×100 = 5000 → 50%.
|
||||
const overlapping = buildWhiteboardConflicts([
|
||||
text('a', 0, 0, 100, 100),
|
||||
text('b', 50, 0, 100, 100),
|
||||
]);
|
||||
expect(overlapping).toContain('OVERLAP:');
|
||||
expect(overlapping).toContain('50%');
|
||||
|
||||
// Overlap area = 10×100 = 1000 → 10% — below threshold.
|
||||
const tiny = buildWhiteboardConflicts([text('a', 0, 0, 100, 100), text('b', 90, 0, 100, 100)]);
|
||||
expect(tiny).toBe('');
|
||||
});
|
||||
|
||||
test('non-line elements without width/height are skipped, not crashed', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
text('t1', 0, 0, 100, 100),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ type: 'text', id: 'broken', left: 10, top: 10 } as any, // missing width/height
|
||||
]);
|
||||
// Only one valid element remaining → no overlap to report.
|
||||
expect(out).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWhiteboardConflicts — line crossing elements', () => {
|
||||
test('line passing through the middle of a text box is reported', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
text('t1', 100, 100, 200, 60), // covers x∈[100,300], y∈[100,160]
|
||||
line('l1', 0, 0, [0, 130], [400, 130]), // horizontal line through y=130, cuts the box
|
||||
]);
|
||||
expect(out).toContain('LINE CROSSES:');
|
||||
expect(out).toContain('t1');
|
||||
});
|
||||
|
||||
test('line whose endpoint is inside a bbox is reported', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
text('t1', 100, 100, 200, 60),
|
||||
line('l1', 0, 0, [50, 50], [200, 130]), // endpoint (200,130) is inside t1
|
||||
]);
|
||||
expect(out).toContain('LINE CROSSES:');
|
||||
});
|
||||
|
||||
test('line with endpoints on opposite sides of a box but path above the box is clean', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
text('t1', 100, 100, 200, 60),
|
||||
line('l1', 0, 0, [50, 50], [400, 50]), // y=50, above the box (y∈[100,160])
|
||||
]);
|
||||
expect(out).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWhiteboardConflicts — canvas edge clipping', () => {
|
||||
test('element extending past right edge is reported', () => {
|
||||
const out = buildWhiteboardConflicts([text('wide', 900, 100, 200, 60)]);
|
||||
expect(out).toContain('OUT OF CANVAS:');
|
||||
expect(out).toContain('right edge by 100px');
|
||||
});
|
||||
|
||||
test('element extending past bottom edge is reported (canvas height = 563)', () => {
|
||||
const out = buildWhiteboardConflicts([text('tall', 100, 500, 100, 80)]);
|
||||
expect(out).toContain('OUT OF CANVAS:');
|
||||
expect(out).toContain('bottom edge by 17px'); // 500+80-563 = 17
|
||||
});
|
||||
|
||||
test('element with negative left is reported', () => {
|
||||
const out = buildWhiteboardConflicts([text('negx', -10, 100, 50, 50)]);
|
||||
expect(out).toContain('OUT OF CANVAS:');
|
||||
expect(out).toContain('left edge by 10px');
|
||||
});
|
||||
|
||||
test('element exactly at right edge (x+w == 1000) is NOT reported', () => {
|
||||
const out = buildWhiteboardConflicts([text('edge', 900, 100, 100, 60)]);
|
||||
expect(out).toBe('');
|
||||
});
|
||||
|
||||
test('element exactly at bottom edge (y+h == 563) is NOT reported', () => {
|
||||
const out = buildWhiteboardConflicts([text('edge', 100, 500, 100, 63)]);
|
||||
expect(out).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWhiteboardConflicts — output format', () => {
|
||||
test('renders a single markdown block with a header and bullet list', () => {
|
||||
const out = buildWhiteboardConflicts([text('a', 0, 0, 100, 100), text('b', 50, 0, 100, 100)]);
|
||||
expect(out).toMatch(/## ⚠ Layout Conflicts Detected/);
|
||||
expect(out).toMatch(/\n {2}- OVERLAP:/);
|
||||
});
|
||||
|
||||
test('lists multiple conflicts in one block', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
text('a', 0, 0, 100, 100),
|
||||
text('b', 50, 0, 100, 100), // overlap with a
|
||||
text('outside', 950, 100, 200, 60), // out of canvas
|
||||
]);
|
||||
const bullets = out.split('\n').filter((l) => l.trim().startsWith('- '));
|
||||
expect(bullets.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user