Felipe97/llama-cpp-compiled
01.2k
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 ...overrides18 } 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 ...overrides33 } 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's105 // args, `chat.svelte.ts` JSON-encodes the partial tool-call array on106 // every chunk, so `parseToolCalls` succeeds and the section is107 // classified TOOL_CALL_PENDING - not TOOL_CALL_STREAMING (which is108 // only produced from the `streamingToolCalls` parameter, never set109 // by current UI callers). Streaming-only UI like auto-scroll in the110 // code block must still trigger, driven by `isStreaming && (isPending111 // || 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 assistant145 const sections = deriveAgenticSections(assistant1, [tool1, assistant2]);146 147 expect(sections).toHaveLength(3);148 // Turn 1149 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_call197 // Turn 3: reasoning + text198 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_call257 // Turn 2 (streaming): streaming tool call258 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 repeated295// derivations return the same section objects - which is what keeps tool296// block props stable while another turn streams. Every field the cache297// 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: toolCallsJson310 }),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 true334 );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 derivations396 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 objects401 expect(second[second.length - 1]).not.toBe(first[first.length - 1]);402 });403});404 