import { describe, expect, it } from 'vitest'; import { createTurnGrouper } from './messages'; import type { PersonaMessage } from './types'; const user = (id: string, text: string): PersonaMessage => ({ id, role: 'user', type: 'text', text, createdAt: new Date() }) as PersonaMessage; const reasoning = (id: string, text: string): PersonaMessage => ({ id, role: 'assistant', type: 'reasoning', text, finishReason: 'stop', createdAt: new Date() }) as PersonaMessage; const tool = (id: string, name: string, done = true): PersonaMessage => ({ id, role: 'function', type: 'text', text: '', functionCalls: [{ id: `call-${id}`, name, args: {} }], ...(done ? { functionResponse: { name, result: 'ok' }, finishReason: 'stop' } : {}), createdAt: new Date(), }) as unknown as PersonaMessage; const answer = (id: string, text: string, done = true): PersonaMessage => ({ id, role: 'assistant', type: 'text', text, ...(done ? { finishReason: 'stop' } : {}), createdAt: new Date() }) as PersonaMessage; const notice = (id: string): PersonaMessage => ({ id, role: 'assistant', type: 'text', text: '', finishReason: 'stop', metadata: { custom: { notice: 'cancelled' } }, createdAt: new Date(), }) as unknown as PersonaMessage; const partTypes = (message: { content: unknown }) => (message.content as { type: string }[]).map((p) => p.type); describe('createTurnGrouper', () => { it('folds an agent turn into one assistant message', () => { const group = createTurnGrouper(); const out = group([user('u1', 'hi'), reasoning('r1', 'think'), tool('t1', 'a'), tool('t2', 'b'), answer('a1', 'done')]); expect(out).toHaveLength(2); expect(out[0].role).toBe('user'); expect(out[1].id).toBe('r1'); expect(partTypes(out[1])).toEqual(['reasoning', 'tool-call', 'tool-call', 'text']); }); it('records when each part arrived', () => { const group = createTurnGrouper(); const r1 = { ...reasoning('r1', 'think'), createdAt: new Date(1000) } as PersonaMessage; const t1 = { ...tool('t1', 'a'), createdAt: new Date(4000) } as PersonaMessage; const a1 = { ...answer('a1', 'done'), createdAt: new Date(9000) } as PersonaMessage; const [turn] = group([r1, t1, a1]); expect((turn.metadata?.custom as { partTimestamps?: unknown }).partTimestamps).toEqual([1000, 4000, 9000]); }); it("names the turn after the agent that answered, even when reasoning has no sender", () => { const group = createTurnGrouper(); const t1 = { ...tool('t1', 'a'), sender: 'Giulia' } as PersonaMessage; const a1 = { ...answer('a1', 'done'), sender: 'Mario' } as PersonaMessage; const [turn] = group([reasoning('r1', 'think'), t1, a1]); expect((turn.metadata?.custom as { sender?: string }).sender).toBe('Mario'); }); it('keeps user messages and notices as turn boundaries', () => { const group = createTurnGrouper(); const out = group([ user('u1', 'hi'), tool('t1', 'a'), notice('n1'), tool('t2', 'b'), user('u2', 'again'), answer('a2', 'ok'), ]); expect(out.map((m) => m.id)).toEqual(['u1', 't1', 'n1', 't2', 'u2', 'a2']); }); it('takes the status of the last message: a streaming tail is not complete', () => { const group = createTurnGrouper(); const [, turn] = group([user('u1', 'hi'), tool('t1', 'a'), answer('a1', 'part', false)]); expect(turn.status).toBeUndefined(); }); it('reuses finished turns while the last one streams', () => { const group = createTurnGrouper(); const u1 = user('u1', 'hi'); const t1 = tool('t1', 'a'); const a1 = answer('a1', 'done'); const u2 = user('u2', 'more'); const t2 = tool('t2', 'b', false); const first = group([u1, t1, a1, u2, t2]); const second = group([u1, t1, a1, u2, t2, answer('a2', 'streaming', false)]); expect(second[1]).toBe(first[1]); expect(second[3]).not.toBe(first[3]); expect(partTypes(second[3])).toEqual(['tool-call', 'text']); }); it("shows a user message's attachments as image/file parts", () => { const group = createTurnGrouper(); const withAttachments: PersonaMessage = { ...user('u1', 'guarda qui'), attachments: [ { image: { contentType: 'image/png', content: 'aW1n' } }, { file: { name: 'report.pdf', base64: 'cGRm', contentType: 'application/pdf' } }, ], }; const [echo] = group([withAttachments]); expect(echo.role).toBe('user'); const parts = echo.content as { type: string; image?: string; data?: string; filename?: string }[]; expect(parts.map((p) => p.type)).toEqual(['text', 'image', 'file']); expect(parts[1].image).toBe('data:image/png;base64,aW1n'); expect(parts[2].data).toBe('data:application/pdf;base64,cGRm'); expect(parts[2].filename).toBe('report.pdf'); }); it('renders a legacy user image as an image part', () => { const group = createTurnGrouper(); const [echo] = group([{ ...user('u1', ''), image: { contentType: 'image/jpeg', content: 'anBn' } }]); const parts = echo.content as { type: string; image?: string }[]; expect(parts.map((p) => p.type)).toEqual(['text', 'image']); expect(parts[1].image).toBe('data:image/jpeg;base64,anBn'); }); });