char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Aleksander Grygierui: Improve Chat Messages rendering performance (#28460)0afb805b1

char/aliceai-t5-moe
13.5 KiB403 linesraw
1import { AgenticSectionType, MessageRole } from '$lib/enums';
2import type { ApiChatCompletionToolCall } from '$lib/types/api';
3import type { DatabaseMessage } from '$lib/types/database';
4import { deriveAgenticSections, hasAgenticContent } from '$lib/utils/agentic';
5import { describe, expect, it } from 'vitest';
6
7function makeAssistant(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
8	return {
9		children: [],
10		content: overrides.content ?? '',
11		convId: 'conv-1',
12		id: overrides.id ?? 'ast-1',
13		parent: null,
14		role: MessageRole.ASSISTANT,
15		timestamp: Date.now(),
16		type: 'text',
17		...overrides
18	} as DatabaseMessage;
19}
20
21function makeToolMsg(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
22	return {
23		children: [],
24		content: overrides.content ?? 'tool result',
25		convId: 'conv-1',
26		id: overrides.id ?? 'tool-1',
27		parent: null,
28		role: MessageRole.TOOL,
29		timestamp: Date.now(),
30		toolCallId: overrides.toolCallId ?? 'call_1',
31		type: 'text',
32		...overrides
33	} as DatabaseMessage;
34}
35
36describe('deriveAgenticSections', () => {
37	it('returns empty array for assistant with no content', () => {
38		const msg = makeAssistant({ content: '' });
39		const sections = deriveAgenticSections(msg);
40
41		expect(sections).toEqual([]);
42	});
43
44	it('returns text section for simple assistant message', () => {
45		const msg = makeAssistant({ content: 'Hello world' });
46		const sections = deriveAgenticSections(msg);
47
48		expect(sections).toHaveLength(1);
49		expect(sections[0].type).toBe(AgenticSectionType.TEXT);
50		expect(sections[0].content).toBe('Hello world');
51	});
52
53	it('returns reasoning + text for message with reasoning', () => {
54		const msg = makeAssistant({
55			content: 'Answer is 4.',
56			reasoningContent: 'Let me think...'
57		});
58		const sections = deriveAgenticSections(msg);
59
60		expect(sections).toHaveLength(2);
61		expect(sections[0].type).toBe(AgenticSectionType.REASONING);
62		expect(sections[0].content).toBe('Let me think...');
63		expect(sections[1].type).toBe(AgenticSectionType.TEXT);
64	});
65
66	it('single turn: assistant with tool calls and results', () => {
67		const msg = makeAssistant({
68			content: 'Let me check.',
69			toolCalls: JSON.stringify([
70				{
71					function: { arguments: '{"q":"test"}', name: 'search' },
72					id: 'call_1',
73					type: 'function'
74				}
75			])
76		});
77		const toolResult = makeToolMsg({
78			content: 'Found 3 results',
79			toolCallId: 'call_1'
80		});
81		const sections = deriveAgenticSections(msg, [toolResult]);
82
83		expect(sections).toHaveLength(2);
84		expect(sections[0].type).toBe(AgenticSectionType.TEXT);
85		expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL);
86		expect(sections[1].toolName).toBe('search');
87		expect(sections[1].toolResult).toBe('Found 3 results');
88	});
89
90	it('single turn: pending tool call without result', () => {
91		const msg = makeAssistant({
92			toolCalls: JSON.stringify([
93				{ function: { arguments: '{}', name: 'bash' }, id: 'call_1', type: 'function' }
94			])
95		});
96		const sections = deriveAgenticSections(msg, [], [], true);
97
98		expect(sections).toHaveLength(1);
99		expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL_PENDING);
100		expect(sections[0].toolName).toBe('bash');
101	});
102
103	it('chat-streaming write_file surfaces as TOOL_CALL_PENDING with partial toolArgs (not TOOL_CALL_STREAMING)', () => {
104		// Regression: while the LLM is emitting a write_file tool call's
105		// args, `chat.svelte.ts` JSON-encodes the partial tool-call array on
106		// every chunk, so `parseToolCalls` succeeds and the section is
107		// classified TOOL_CALL_PENDING - not TOOL_CALL_STREAMING (which is
108		// only produced from the `streamingToolCalls` parameter, never set
109		// by current UI callers). Streaming-only UI like auto-scroll in the
110		// code block must still trigger, driven by `isStreaming && (isPending
111		// || isStreamingCall)`, not `isStreamingCall` alone.
112		const partialArgs = '{"path":"/Users/fifa2026.html","content":"<!DOCTYPE h';
113		const msg = makeAssistant({
114			toolCalls: JSON.stringify([
115				{ function: { arguments: partialArgs, name: 'write_file' }, id: 'call_1', type: 'function' }
116			])
117		});
118		const sections = deriveAgenticSections(msg, [], [], true);
119
120		expect(sections).toHaveLength(1);
121		expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL_PENDING);
122		expect(sections[0].type).not.toBe(AgenticSectionType.TOOL_CALL_STREAMING);
123		expect(sections[0].toolName).toBe('write_file');
124		expect(sections[0].toolArgs).toBe(partialArgs);
125	});
126
127	it('multi-turn: two assistant turns grouped as one session', () => {
128		const assistant1 = makeAssistant({
129			content: 'Turn 1 text',
130			id: 'ast-1',
131			toolCalls: JSON.stringify([
132				{
133					function: { arguments: '{"q":"foo"}', name: 'search' },
134					id: 'call_1',
135					type: 'function'
136				}
137			])
138		});
139		const tool1 = makeToolMsg({ content: 'result 1', id: 'tool-1', toolCallId: 'call_1' });
140		const assistant2 = makeAssistant({
141			content: 'Final answer based on results.',
142			id: 'ast-2'
143		});
144		// toolMessages contains both tool result and continuation assistant
145		const sections = deriveAgenticSections(assistant1, [tool1, assistant2]);
146
147		expect(sections).toHaveLength(3);
148		// Turn 1
149		expect(sections[0].type).toBe(AgenticSectionType.TEXT);
150		expect(sections[0].content).toBe('Turn 1 text');
151		expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL);
152		expect(sections[1].toolName).toBe('search');
153		expect(sections[1].toolResult).toBe('result 1');
154		// Turn 2 (final)
155		expect(sections[2].type).toBe(AgenticSectionType.TEXT);
156		expect(sections[2].content).toBe('Final answer based on results.');
157	});
158
159	it('multi-turn: three turns with tool calls', () => {
160		const assistant1 = makeAssistant({
161			content: '',
162			id: 'ast-1',
163			toolCalls: JSON.stringify([
164				{
165					function: { arguments: '{}', name: 'list_files' },
166					id: 'call_1',
167					type: 'function'
168				}
169			])
170		});
171		const tool1 = makeToolMsg({ content: 'file1 file2', id: 'tool-1', toolCallId: 'call_1' });
172		const assistant2 = makeAssistant({
173			content: 'Reading file1...',
174			id: 'ast-2',
175			toolCalls: JSON.stringify([
176				{
177					function: { arguments: '{"path":"file1"}', name: 'read_file' },
178					id: 'call_2',
179					type: 'function'
180				}
181			])
182		});
183		const tool2 = makeToolMsg({
184			content: 'contents of file1',
185			id: 'tool-2',
186			toolCallId: 'call_2'
187		});
188		const assistant3 = makeAssistant({
189			content: 'Here is the analysis.',
190			id: 'ast-3',
191			reasoningContent: 'The file contains...'
192		});
193		const sections = deriveAgenticSections(assistant1, [tool1, assistant2, tool2, assistant3]);
194
195		// Turn 1: tool_call (no text since content is empty)
196		// Turn 2: text + tool_call
197		// Turn 3: reasoning + text
198		expect(sections).toHaveLength(5);
199		expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL);
200		expect(sections[0].toolName).toBe('list_files');
201		expect(sections[1].type).toBe(AgenticSectionType.TEXT);
202		expect(sections[1].content).toBe('Reading file1...');
203		expect(sections[2].type).toBe(AgenticSectionType.TOOL_CALL);
204		expect(sections[2].toolName).toBe('read_file');
205		expect(sections[3].type).toBe(AgenticSectionType.REASONING);
206		expect(sections[4].type).toBe(AgenticSectionType.TEXT);
207		expect(sections[4].content).toBe('Here is the analysis.');
208	});
209
210	it('returns REASONING_PENDING when streaming with only reasoning content', () => {
211		const msg = makeAssistant({
212			reasoningContent: 'Let me think about this...'
213		});
214		const sections = deriveAgenticSections(msg, [], [], true);
215
216		expect(sections).toHaveLength(1);
217		expect(sections[0].type).toBe(AgenticSectionType.REASONING_PENDING);
218		expect(sections[0].content).toBe('Let me think about this...');
219	});
220
221	it('returns REASONING (not pending) when streaming but text content has appeared', () => {
222		const msg = makeAssistant({
223			content: 'The answer is',
224			reasoningContent: 'Let me think...'
225		});
226		const sections = deriveAgenticSections(msg, [], [], true);
227
228		expect(sections).toHaveLength(2);
229		expect(sections[0].type).toBe(AgenticSectionType.REASONING);
230		expect(sections[1].type).toBe(AgenticSectionType.TEXT);
231	});
232
233	it('returns REASONING (not pending) when not streaming', () => {
234		const msg = makeAssistant({
235			reasoningContent: 'Let me think...'
236		});
237		const sections = deriveAgenticSections(msg, [], [], false);
238
239		expect(sections).toHaveLength(1);
240		expect(sections[0].type).toBe(AgenticSectionType.REASONING);
241	});
242
243	it('multi-turn: streaming tool calls on last turn', () => {
244		const assistant1 = makeAssistant({
245			toolCalls: JSON.stringify([
246				{ function: { arguments: '{}', name: 'search' }, id: 'call_1', type: 'function' }
247			])
248		});
249		const tool1 = makeToolMsg({ content: 'result', toolCallId: 'call_1' });
250		const assistant2 = makeAssistant({ content: '', id: 'ast-2' });
251		const streamingToolCalls: ApiChatCompletionToolCall[] = [
252			{ function: { arguments: '{"pa', name: 'write_file' }, id: 'call_2', type: 'function' }
253		];
254		const sections = deriveAgenticSections(assistant1, [tool1, assistant2], streamingToolCalls);
255
256		// Turn 1: tool_call
257		// Turn 2 (streaming): streaming tool call
258		expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL)).toBe(true);
259		expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL_STREAMING)).toBe(true);
260	});
261});
262
263describe('hasAgenticContent', () => {
264	it('returns false for plain assistant', () => {
265		const msg = makeAssistant({ content: 'Just text' });
266
267		expect(hasAgenticContent(msg)).toBe(false);
268	});
269
270	it('returns true when message has toolCalls', () => {
271		const msg = makeAssistant({
272			toolCalls: JSON.stringify([
273				{ function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
274			])
275		});
276
277		expect(hasAgenticContent(msg)).toBe(true);
278	});
279
280	it('returns true when toolMessages are provided', () => {
281		const msg = makeAssistant();
282		const tool = makeToolMsg();
283
284		expect(hasAgenticContent(msg, [tool])).toBe(true);
285	});
286
287	it('returns false for empty toolCalls JSON', () => {
288		const msg = makeAssistant({ toolCalls: '[]' });
289
290		expect(hasAgenticContent(msg)).toBe(false);
291	});
292});
293
294// The turn-section cache: completed turns are immutable, so repeated
295// derivations return the same section objects - which is what keeps tool
296// block props stable while another turn streams. Every field the cache
297// compares must invalidate it; a miss here renders stale content.
298
299describe('completed turn section reuse', () => {
300	const toolCallsJson = JSON.stringify([
301		{ function: { arguments: '{"path":"/a"}', name: 'test' }, id: 'call_1', type: 'function' }
302	]);
303
304	function makeSession() {
305		return {
306			anchor: makeAssistant({
307				content: 'answer',
308				reasoningContent: 'thinking',
309				toolCalls: toolCallsJson
310			}),
311			tools: [makeToolMsg({ content: 'tool result', extra: [{ type: 'file' } as never] })]
312		};
313	}
314
315	it('returns the same section objects for unchanged inputs', () => {
316		const { anchor, tools } = makeSession();
317		const first = deriveAgenticSections(anchor, tools, [], false);
318		const second = deriveAgenticSections(anchor, tools, [], false);
319
320		expect(second[0]).toBe(first[0]);
321		expect(second[1]).toBe(first[1]);
322	});
323
324	it('recomputes when the assistant content changes', () => {
325		const { anchor, tools } = makeSession();
326		const first = deriveAgenticSections(anchor, tools, [], false);
327
328		anchor.content = 'edited';
329		const second = deriveAgenticSections(anchor, tools, [], false);
330
331		expect(second).not.toBe(first);
332		expect(second.some((s) => s.type === AgenticSectionType.TEXT && s.content === 'edited')).toBe(
333			true
334		);
335	});
336
337	it('recomputes when reasoning content changes', () => {
338		const { anchor, tools } = makeSession();
339		const first = deriveAgenticSections(anchor, tools, [], false);
340
341		anchor.reasoningContent = 'new thinking';
342		const second = deriveAgenticSections(anchor, tools, [], false);
343
344		expect(second).not.toBe(first);
345	});
346
347	it('recomputes when toolCalls change', () => {
348		const { anchor, tools } = makeSession();
349		const first = deriveAgenticSections(anchor, tools, [], false);
350
351		anchor.toolCalls = '[]';
352		const second = deriveAgenticSections(anchor, tools, [], false);
353
354		expect(second).not.toBe(first);
355	});
356
357	it('recomputes when a tool result or its extras change', () => {
358		const { anchor, tools } = makeSession();
359		const first = deriveAgenticSections(anchor, tools, [], false);
360
361		tools[0].content = 'new tool result';
362		expect(deriveAgenticSections(anchor, tools, [], false)).not.toBe(first);
363
364		const firstAfterContent = deriveAgenticSections(anchor, tools, [], false);
365
366		tools[0].extra = [{ type: 'image' } as never];
367		expect(deriveAgenticSections(anchor, tools, [], false)).not.toBe(firstAfterContent);
368	});
369
370	it('never reuses the streaming turn', () => {
371		const { anchor, tools } = makeSession();
372		const first = deriveAgenticSections(anchor, tools, [], true);
373		const second = deriveAgenticSections(anchor, tools, [], true);
374
375		expect(second).not.toBe(first);
376	});
377
378	it('keeps completed turns stable while the last turn streams', () => {
379		const anchor = makeAssistant({
380			content: 'turn one',
381			id: 'ast-1',
382			toolCalls: JSON.stringify([
383				{ function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
384			])
385		});
386		const continuation = makeAssistant({ content: 'turn two', id: 'ast-2' });
387		const tools = [
388			makeToolMsg({ content: 'r1', id: 'tool-1', toolCallId: 'call_1' }),
389			continuation,
390			makeToolMsg({ content: 'r2', id: 'tool-2', toolCallId: 'call_2' })
391		];
392		const first = deriveAgenticSections(anchor, tools, [], true);
393		const second = deriveAgenticSections(anchor, tools, [], true);
394
395		// turn one is complete: identical section objects across derivations
396		expect(second.slice(0, 2)).toEqual(first.slice(0, 2));
397		expect(second[0]).toBe(first[0]);
398		expect(second[1]).toBe(first[1]);
399
400		// the streaming last turn recomputed: fresh section objects
401		expect(second[second.length - 1]).not.toBe(first[first.length - 1]);
402	});
403});