168 lines
5.2 KiB
TypeScript
168 lines
5.2 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { render } from 'vitest-browser-svelte';
|
|
import TestMessagesWrapper from './components/TestMessagesWrapper.svelte';
|
|
|
|
const waitForText = async (container: HTMLElement, text: string, timeoutMs = 2000) => {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if ((container.textContent || '').includes(text)) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 16));
|
|
}
|
|
throw new Error(`Timed out waiting for text: ${text}`);
|
|
};
|
|
|
|
const msg = (
|
|
id: string,
|
|
role: 'user' | 'assistant' | 'tool' | 'system',
|
|
content: string,
|
|
parent: string | null,
|
|
timestamp: number,
|
|
extra: Partial<DatabaseMessage> = {}
|
|
): DatabaseMessage => ({
|
|
id,
|
|
convId: 'c1',
|
|
type: role === 'tool' ? 'tool' : 'text',
|
|
role,
|
|
content,
|
|
thinking: '',
|
|
toolCalls: '',
|
|
parent: parent ?? '-1',
|
|
children: [],
|
|
timestamp,
|
|
...extra
|
|
});
|
|
|
|
describe('ChatMessages multi-tool chaining and ordering', () => {
|
|
it('keeps all tool results visible when tool messages are parent-chained', async () => {
|
|
const ts = Date.now();
|
|
const user = msg('u1', 'user', 'Use two tools', null, ts);
|
|
const a1 = msg('a1', 'assistant', '', user.id, ts + 1, {
|
|
thinking: 'planning',
|
|
toolCalls: JSON.stringify([
|
|
{
|
|
id: 'call-1',
|
|
type: 'function',
|
|
function: { name: 'calculator', arguments: JSON.stringify({ expression: '2+2' }) }
|
|
},
|
|
{
|
|
id: 'call-2',
|
|
type: 'function',
|
|
function: { name: 'calculator', arguments: JSON.stringify({ expression: '10-3' }) }
|
|
}
|
|
])
|
|
});
|
|
// Parent-chained tool messages (the branch that should be persisted and reloaded correctly)
|
|
const t1 = msg('t1', 'tool', JSON.stringify({ result: '4' }), a1.id, ts + 2, {
|
|
toolCallId: 'call-1'
|
|
});
|
|
const t2 = msg('t2', 'tool', JSON.stringify({ result: '7' }), t1.id, ts + 3, {
|
|
toolCallId: 'call-2'
|
|
});
|
|
const a2 = msg('a2', 'assistant', 'done', t2.id, ts + 4);
|
|
|
|
const { container } = render(TestMessagesWrapper, {
|
|
target: document.body,
|
|
props: { messages: [user, a1, t1, t2, a2] }
|
|
});
|
|
|
|
await waitForText(container, 'done');
|
|
|
|
const text = container.textContent || '';
|
|
expect(text).toContain('2+2');
|
|
expect(text).toContain('10-3');
|
|
expect(text).toContain('4');
|
|
expect(text).toContain('7');
|
|
expect(text).toContain('done');
|
|
});
|
|
|
|
it('renders deterministic reasoning/tool order when timestamps tie and input is shuffled', async () => {
|
|
const ts = Date.now();
|
|
const user = msg('u1', 'user', 'Question', null, ts);
|
|
const a1 = msg('a1', 'assistant', '', user.id, ts, {
|
|
thinking: 'reason-step-1',
|
|
toolCalls: JSON.stringify([
|
|
{
|
|
id: 'call-1',
|
|
type: 'function',
|
|
function: { name: 'calculator', arguments: JSON.stringify({ expression: '1+1' }) }
|
|
}
|
|
])
|
|
});
|
|
const t1 = msg('t1', 'tool', JSON.stringify({ result: '2' }), a1.id, ts, {
|
|
toolCallId: 'call-1'
|
|
});
|
|
const a2 = msg('a2', 'assistant', 'final-answer', t1.id, ts, {
|
|
thinking: 'reason-step-2'
|
|
});
|
|
|
|
// Intentionally shuffled; deterministic sort should still render the same chain order.
|
|
const { container } = render(TestMessagesWrapper, {
|
|
target: document.body,
|
|
props: { messages: [a2, t1, user, a1] }
|
|
});
|
|
|
|
await waitForText(container, 'final-answer');
|
|
|
|
const text = container.textContent || '';
|
|
const idxReason1 = text.indexOf('reason-step-1');
|
|
const idxTool = text.indexOf('1+1');
|
|
const idxReason2 = text.indexOf('reason-step-2');
|
|
const idxFinal = text.indexOf('final-answer');
|
|
|
|
expect(idxReason1).toBeGreaterThanOrEqual(0);
|
|
expect(idxTool).toBeGreaterThan(idxReason1);
|
|
expect(idxReason2).toBeGreaterThan(idxTool);
|
|
expect(idxFinal).toBeGreaterThan(idxReason2);
|
|
});
|
|
|
|
it('does not hoist later thinking above earlier visible output when phases interleave', async () => {
|
|
const ts = Date.now();
|
|
const user = msg('u1', 'user', 'Interleave phases', null, ts);
|
|
const a1 = msg('a1', 'assistant', 'outside-1', user.id, ts + 1, {
|
|
thinking: 'reason-1',
|
|
toolCalls: JSON.stringify([
|
|
{
|
|
id: 'call-1',
|
|
type: 'function',
|
|
function: { name: 'calculator', arguments: JSON.stringify({ expression: '3*3' }) }
|
|
}
|
|
])
|
|
});
|
|
const t1 = msg('t1', 'tool', JSON.stringify({ result: '9' }), a1.id, ts + 2, {
|
|
toolCallId: 'call-1'
|
|
});
|
|
const a2 = msg('a2', 'assistant', '', t1.id, ts + 3, {
|
|
thinking: 'reason-2',
|
|
toolCalls: JSON.stringify([
|
|
{
|
|
id: 'call-2',
|
|
type: 'function',
|
|
function: { name: 'calculator', arguments: JSON.stringify({ expression: '5+2' }) }
|
|
}
|
|
])
|
|
});
|
|
const t2 = msg('t2', 'tool', JSON.stringify({ result: '7' }), a2.id, ts + 4, {
|
|
toolCallId: 'call-2'
|
|
});
|
|
const a3 = msg('a3', 'assistant', 'outside-2', t2.id, ts + 5);
|
|
|
|
const { container } = render(TestMessagesWrapper, {
|
|
target: document.body,
|
|
props: { messages: [user, a1, t1, a2, t2, a3] }
|
|
});
|
|
|
|
await waitForText(container, 'outside-2');
|
|
|
|
const text = container.textContent || '';
|
|
const idxReason1 = text.indexOf('reason-1');
|
|
const idxOutside1 = text.indexOf('outside-1');
|
|
const idxReason2 = text.indexOf('reason-2');
|
|
const idxOutside2 = text.indexOf('outside-2');
|
|
|
|
expect(idxReason1).toBeGreaterThanOrEqual(0);
|
|
expect(idxOutside1).toBeGreaterThan(idxReason1);
|
|
expect(idxReason2).toBeGreaterThan(idxOutside1);
|
|
expect(idxOutside2).toBeGreaterThan(idxReason2);
|
|
});
|
|
});
|