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