mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-08 13:59:03 +02:00
0afb805b19
* ui : update active conversation fields in place updateCurrentNode, applyConversationUpdate, updateConversationTimestamp and the pin toggle replaced the whole activeConversation object, so its identity changed on every send, tool result and rename. ChatMessages tracks that identity to refresh sibling info, so each replacement triggered a full refetch of every message in the conversation. Write the changed fields instead, mirroring updateMessageAtIndex. Assisted-by: pi:zai-org/GLM-5.3 * ui : reuse the conversation load read for sibling info Opening a conversation read every message from the database twice: once in loadConversation for the active path, once in ChatMessages for the sibling map. Hand the freshly read array over once so the chat screen builds sibling info from it, and set the conversation and its messages in one sync block so effects never see the new conversation paired with the previous one's messages. Assisted-by: pi:zai-org/GLM-5.3 * ui : memoize leaf walks in sibling map build buildSiblingInfoMap resolves each sibling's leaf by walking the last-child chain, once per sibling per message, so the walk repeats along the same chains for every message in the conversation ( O(messages^2) on long chats ). Memoize leaf resolution per build with path compression so each edge is walked once. Assisted-by: pi:zai-org/GLM-5.3 * ui : skip sibling refetch for in-place message edits refreshAllMessages refetches every message of the conversation just to rebuild sibling info, but preserve-responses and non-branching assistant edits never create branches, so the sibling map stays valid. Refresh only after actions that branch (editWithBranching kept) or delete. Assisted-by: pi:zai-org/GLM-5.3 * ui : drop unused currentResponse reactive writes Nothing reads chatStore.currentResponse, but setChatStreaming reassigned it on every streamed chunk, so each token paid a reactive write and string assignment for nothing. Remove the field and the clearUIState wrapper that only reset it. Assisted-by: pi:zai-org/GLM-5.3 * ui : reuse completed agentic turn sections during streaming deriveAgenticSections runs in a $derived invalidated per streamed chunk, but re-derived every turn of the session each time, so per-chunk cost grew with session length. Cache completed turns keyed by their assistant message plus reference checks on every field that feeds derivation; only the streaming turn recomputes. Cache hits return the same section objects, so tool block props stay stable and skip their per-chunk re-derive. Assisted-by: pi:zai-org/GLM-5.3 * ui : share markdown block infrastructure Every markdown block duplicated shared work: a full copy of the hljs theme CSS per instance, and the remark/rehype plugin chain rebuilt on every processMarkdown call ( once per block at mount, again per coalesced chunk while streaming ). Use the single theme style element already maintained by SyntaxHighlightedCode, and build pipelines once - shared process-wide for attachment-less blocks, cached by attachments identity otherwise. Assisted-by: pi:zai-org/GLM-5.3 * ui : measure assistant layout only for the last message Every assistant message ran getComputedStyle, getBoundingClientRect and a ResizeObserver over the previous user bubble at mount, even off-screen ones, forcing a layout pass per message while a long conversation renders. The measured vars only feed the :last-child min-height rule, so gate the effect on isLastAssistantMessage; one measurement and one observer remain, and the effect re-runs when the last message changes. Assisted-by: pi:zai-org/GLM-5.3 * ui : trim whole-blob scans in tool block headers Tool block headers parsed their entire blobs at mount, even collapsed, and most tool results and args are large plain text or embedded file content: skip JSON.parse unless the blob starts with a JSON container, prefilter search-result extraction with a Title:/URL: substring check, and match the end-anchored exit-code marker against only the tail of exec outputs. Assisted-by: pi:zai-org/GLM-5.3 * ui : parse write_file and edit_file titles without the content blob Both block headers parsed the full args JSON at mount, even collapsed, and write_file and edit_file args embed the whole file content or edit strings, so every block paid a full-blob JSON parse just to read the path. Split the meta into a title tier that extracts the path with a targeted key match (full parse only as fallback) and a body tier that keeps the full parse; Svelte deriveds are lazy, and the body snippet renders only while the block is expanded, so collapsed blocks no longer parse args. Assisted-by: pi:zai-org/GLM-5.3 * ui : mount chat messages lazily near the viewport Every message row mounted its full component tree on load, so the cycle collector, GC and layout invalidation kept walking every live object and DOM node even for rows the user never scrolls to - which dominated the profile of long conversations. Wrap each row in a placeholder with an IntersectionObserver ( two viewport heights of runway ) that swaps in the real ChatMessage when the row approaches the viewport; the row shell keeps the content-visibility sizing, and rows stay mounted once realized. Rows targeted by the pending-edit flow mount eagerly. Assisted-by: pi:zai-org/GLM-5.3 * ui : smooth the chat navigation animations Slide the centered new-chat form to the bottom edge with a transform instead of a bottom offset - layout-property transitions need the main thread every frame and stutter while a long conversation loads, while transform transitions run on the compositor. Fade the message list in with a CSS animation keyed to the conversation id, disabled under prefers-reduced-motion. Assisted-by: pi:zai-org/GLM-5.3 * ui : follow the svelte runes guidance in chat message code Two effects detected changes with manual previous-value refs and reset flags. The permission request carries object identity, so its dismissal is now a derived comparing the dismissed request; the continue request is a bare boolean, so its dismissal only shrinks to a reset while no request is pending. Also drop a dead if (browser) guard in the markdown theme loader - effects never run on the server. Assisted-by: pi:zai-org/GLM-5.3 * test : pin the chat perf invariants in the unit suite Cover the fixes whose silent regression would be stale or wrong UI rather than a crash: the turn-section cache must reuse unchanged turns yet recompute on every field it compares; the sibling map must resolve the same leaves after the leaf-walk memoization; the active conversation must keep its identity through field updates; and the blob gates ( exec tail window, plain-text result gate, search prefilter ) must keep accepting what they gate. Only the risky invariants are pinned - no coverage for coverage's sake. Assisted-by: pi:zai-org/GLM-5.3 * refactor : address review remarks Name the tool-arg string-field pattern, move the file tools' path field aliases and the JSON container gates into lib/constants, and export the write_file / edit_file meta types from $lib/types instead of the parser modules. Assisted-by: pi:zai-org/GLM-5.3
404 lines
14 KiB
TypeScript
404 lines
14 KiB
TypeScript
import { AgenticSectionType, MessageRole } from '$lib/enums';
|
|
import type { ApiChatCompletionToolCall } from '$lib/types/api';
|
|
import type { DatabaseMessage } from '$lib/types/database';
|
|
import { deriveAgenticSections, hasAgenticContent } from '$lib/utils/agentic';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
function makeAssistant(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
|
|
return {
|
|
children: [],
|
|
content: overrides.content ?? '',
|
|
convId: 'conv-1',
|
|
id: overrides.id ?? 'ast-1',
|
|
parent: null,
|
|
role: MessageRole.ASSISTANT,
|
|
timestamp: Date.now(),
|
|
type: 'text',
|
|
...overrides
|
|
} as DatabaseMessage;
|
|
}
|
|
|
|
function makeToolMsg(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
|
|
return {
|
|
children: [],
|
|
content: overrides.content ?? 'tool result',
|
|
convId: 'conv-1',
|
|
id: overrides.id ?? 'tool-1',
|
|
parent: null,
|
|
role: MessageRole.TOOL,
|
|
timestamp: Date.now(),
|
|
toolCallId: overrides.toolCallId ?? 'call_1',
|
|
type: 'text',
|
|
...overrides
|
|
} as DatabaseMessage;
|
|
}
|
|
|
|
describe('deriveAgenticSections', () => {
|
|
it('returns empty array for assistant with no content', () => {
|
|
const msg = makeAssistant({ content: '' });
|
|
const sections = deriveAgenticSections(msg);
|
|
|
|
expect(sections).toEqual([]);
|
|
});
|
|
|
|
it('returns text section for simple assistant message', () => {
|
|
const msg = makeAssistant({ content: 'Hello world' });
|
|
const sections = deriveAgenticSections(msg);
|
|
|
|
expect(sections).toHaveLength(1);
|
|
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
|
|
expect(sections[0].content).toBe('Hello world');
|
|
});
|
|
|
|
it('returns reasoning + text for message with reasoning', () => {
|
|
const msg = makeAssistant({
|
|
content: 'Answer is 4.',
|
|
reasoningContent: 'Let me think...'
|
|
});
|
|
const sections = deriveAgenticSections(msg);
|
|
|
|
expect(sections).toHaveLength(2);
|
|
expect(sections[0].type).toBe(AgenticSectionType.REASONING);
|
|
expect(sections[0].content).toBe('Let me think...');
|
|
expect(sections[1].type).toBe(AgenticSectionType.TEXT);
|
|
});
|
|
|
|
it('single turn: assistant with tool calls and results', () => {
|
|
const msg = makeAssistant({
|
|
content: 'Let me check.',
|
|
toolCalls: JSON.stringify([
|
|
{
|
|
function: { arguments: '{"q":"test"}', name: 'search' },
|
|
id: 'call_1',
|
|
type: 'function'
|
|
}
|
|
])
|
|
});
|
|
const toolResult = makeToolMsg({
|
|
content: 'Found 3 results',
|
|
toolCallId: 'call_1'
|
|
});
|
|
const sections = deriveAgenticSections(msg, [toolResult]);
|
|
|
|
expect(sections).toHaveLength(2);
|
|
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
|
|
expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL);
|
|
expect(sections[1].toolName).toBe('search');
|
|
expect(sections[1].toolResult).toBe('Found 3 results');
|
|
});
|
|
|
|
it('single turn: pending tool call without result', () => {
|
|
const msg = makeAssistant({
|
|
toolCalls: JSON.stringify([
|
|
{ function: { arguments: '{}', name: 'bash' }, id: 'call_1', type: 'function' }
|
|
])
|
|
});
|
|
const sections = deriveAgenticSections(msg, [], [], true);
|
|
|
|
expect(sections).toHaveLength(1);
|
|
expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL_PENDING);
|
|
expect(sections[0].toolName).toBe('bash');
|
|
});
|
|
|
|
it('chat-streaming write_file surfaces as TOOL_CALL_PENDING with partial toolArgs (not TOOL_CALL_STREAMING)', () => {
|
|
// Regression: while the LLM is emitting a write_file tool call's
|
|
// args, `chat.svelte.ts` JSON-encodes the partial tool-call array on
|
|
// every chunk, so `parseToolCalls` succeeds and the section is
|
|
// classified TOOL_CALL_PENDING - not TOOL_CALL_STREAMING (which is
|
|
// only produced from the `streamingToolCalls` parameter, never set
|
|
// by current UI callers). Streaming-only UI like auto-scroll in the
|
|
// code block must still trigger, driven by `isStreaming && (isPending
|
|
// || isStreamingCall)`, not `isStreamingCall` alone.
|
|
const partialArgs = '{"path":"/Users/fifa2026.html","content":"<!DOCTYPE h';
|
|
const msg = makeAssistant({
|
|
toolCalls: JSON.stringify([
|
|
{ function: { arguments: partialArgs, name: 'write_file' }, id: 'call_1', type: 'function' }
|
|
])
|
|
});
|
|
const sections = deriveAgenticSections(msg, [], [], true);
|
|
|
|
expect(sections).toHaveLength(1);
|
|
expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL_PENDING);
|
|
expect(sections[0].type).not.toBe(AgenticSectionType.TOOL_CALL_STREAMING);
|
|
expect(sections[0].toolName).toBe('write_file');
|
|
expect(sections[0].toolArgs).toBe(partialArgs);
|
|
});
|
|
|
|
it('multi-turn: two assistant turns grouped as one session', () => {
|
|
const assistant1 = makeAssistant({
|
|
content: 'Turn 1 text',
|
|
id: 'ast-1',
|
|
toolCalls: JSON.stringify([
|
|
{
|
|
function: { arguments: '{"q":"foo"}', name: 'search' },
|
|
id: 'call_1',
|
|
type: 'function'
|
|
}
|
|
])
|
|
});
|
|
const tool1 = makeToolMsg({ content: 'result 1', id: 'tool-1', toolCallId: 'call_1' });
|
|
const assistant2 = makeAssistant({
|
|
content: 'Final answer based on results.',
|
|
id: 'ast-2'
|
|
});
|
|
// toolMessages contains both tool result and continuation assistant
|
|
const sections = deriveAgenticSections(assistant1, [tool1, assistant2]);
|
|
|
|
expect(sections).toHaveLength(3);
|
|
// Turn 1
|
|
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
|
|
expect(sections[0].content).toBe('Turn 1 text');
|
|
expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL);
|
|
expect(sections[1].toolName).toBe('search');
|
|
expect(sections[1].toolResult).toBe('result 1');
|
|
// Turn 2 (final)
|
|
expect(sections[2].type).toBe(AgenticSectionType.TEXT);
|
|
expect(sections[2].content).toBe('Final answer based on results.');
|
|
});
|
|
|
|
it('multi-turn: three turns with tool calls', () => {
|
|
const assistant1 = makeAssistant({
|
|
content: '',
|
|
id: 'ast-1',
|
|
toolCalls: JSON.stringify([
|
|
{
|
|
function: { arguments: '{}', name: 'list_files' },
|
|
id: 'call_1',
|
|
type: 'function'
|
|
}
|
|
])
|
|
});
|
|
const tool1 = makeToolMsg({ content: 'file1 file2', id: 'tool-1', toolCallId: 'call_1' });
|
|
const assistant2 = makeAssistant({
|
|
content: 'Reading file1...',
|
|
id: 'ast-2',
|
|
toolCalls: JSON.stringify([
|
|
{
|
|
function: { arguments: '{"path":"file1"}', name: 'read_file' },
|
|
id: 'call_2',
|
|
type: 'function'
|
|
}
|
|
])
|
|
});
|
|
const tool2 = makeToolMsg({
|
|
content: 'contents of file1',
|
|
id: 'tool-2',
|
|
toolCallId: 'call_2'
|
|
});
|
|
const assistant3 = makeAssistant({
|
|
content: 'Here is the analysis.',
|
|
id: 'ast-3',
|
|
reasoningContent: 'The file contains...'
|
|
});
|
|
const sections = deriveAgenticSections(assistant1, [tool1, assistant2, tool2, assistant3]);
|
|
|
|
// Turn 1: tool_call (no text since content is empty)
|
|
// Turn 2: text + tool_call
|
|
// Turn 3: reasoning + text
|
|
expect(sections).toHaveLength(5);
|
|
expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL);
|
|
expect(sections[0].toolName).toBe('list_files');
|
|
expect(sections[1].type).toBe(AgenticSectionType.TEXT);
|
|
expect(sections[1].content).toBe('Reading file1...');
|
|
expect(sections[2].type).toBe(AgenticSectionType.TOOL_CALL);
|
|
expect(sections[2].toolName).toBe('read_file');
|
|
expect(sections[3].type).toBe(AgenticSectionType.REASONING);
|
|
expect(sections[4].type).toBe(AgenticSectionType.TEXT);
|
|
expect(sections[4].content).toBe('Here is the analysis.');
|
|
});
|
|
|
|
it('returns REASONING_PENDING when streaming with only reasoning content', () => {
|
|
const msg = makeAssistant({
|
|
reasoningContent: 'Let me think about this...'
|
|
});
|
|
const sections = deriveAgenticSections(msg, [], [], true);
|
|
|
|
expect(sections).toHaveLength(1);
|
|
expect(sections[0].type).toBe(AgenticSectionType.REASONING_PENDING);
|
|
expect(sections[0].content).toBe('Let me think about this...');
|
|
});
|
|
|
|
it('returns REASONING (not pending) when streaming but text content has appeared', () => {
|
|
const msg = makeAssistant({
|
|
content: 'The answer is',
|
|
reasoningContent: 'Let me think...'
|
|
});
|
|
const sections = deriveAgenticSections(msg, [], [], true);
|
|
|
|
expect(sections).toHaveLength(2);
|
|
expect(sections[0].type).toBe(AgenticSectionType.REASONING);
|
|
expect(sections[1].type).toBe(AgenticSectionType.TEXT);
|
|
});
|
|
|
|
it('returns REASONING (not pending) when not streaming', () => {
|
|
const msg = makeAssistant({
|
|
reasoningContent: 'Let me think...'
|
|
});
|
|
const sections = deriveAgenticSections(msg, [], [], false);
|
|
|
|
expect(sections).toHaveLength(1);
|
|
expect(sections[0].type).toBe(AgenticSectionType.REASONING);
|
|
});
|
|
|
|
it('multi-turn: streaming tool calls on last turn', () => {
|
|
const assistant1 = makeAssistant({
|
|
toolCalls: JSON.stringify([
|
|
{ function: { arguments: '{}', name: 'search' }, id: 'call_1', type: 'function' }
|
|
])
|
|
});
|
|
const tool1 = makeToolMsg({ content: 'result', toolCallId: 'call_1' });
|
|
const assistant2 = makeAssistant({ content: '', id: 'ast-2' });
|
|
const streamingToolCalls: ApiChatCompletionToolCall[] = [
|
|
{ function: { arguments: '{"pa', name: 'write_file' }, id: 'call_2', type: 'function' }
|
|
];
|
|
const sections = deriveAgenticSections(assistant1, [tool1, assistant2], streamingToolCalls);
|
|
|
|
// Turn 1: tool_call
|
|
// Turn 2 (streaming): streaming tool call
|
|
expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL)).toBe(true);
|
|
expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL_STREAMING)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('hasAgenticContent', () => {
|
|
it('returns false for plain assistant', () => {
|
|
const msg = makeAssistant({ content: 'Just text' });
|
|
|
|
expect(hasAgenticContent(msg)).toBe(false);
|
|
});
|
|
|
|
it('returns true when message has toolCalls', () => {
|
|
const msg = makeAssistant({
|
|
toolCalls: JSON.stringify([
|
|
{ function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
|
|
])
|
|
});
|
|
|
|
expect(hasAgenticContent(msg)).toBe(true);
|
|
});
|
|
|
|
it('returns true when toolMessages are provided', () => {
|
|
const msg = makeAssistant();
|
|
const tool = makeToolMsg();
|
|
|
|
expect(hasAgenticContent(msg, [tool])).toBe(true);
|
|
});
|
|
|
|
it('returns false for empty toolCalls JSON', () => {
|
|
const msg = makeAssistant({ toolCalls: '[]' });
|
|
|
|
expect(hasAgenticContent(msg)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// The turn-section cache: completed turns are immutable, so repeated
|
|
// derivations return the same section objects - which is what keeps tool
|
|
// block props stable while another turn streams. Every field the cache
|
|
// compares must invalidate it; a miss here renders stale content.
|
|
|
|
describe('completed turn section reuse', () => {
|
|
const toolCallsJson = JSON.stringify([
|
|
{ function: { arguments: '{"path":"/a"}', name: 'test' }, id: 'call_1', type: 'function' }
|
|
]);
|
|
|
|
function makeSession() {
|
|
return {
|
|
anchor: makeAssistant({
|
|
content: 'answer',
|
|
reasoningContent: 'thinking',
|
|
toolCalls: toolCallsJson
|
|
}),
|
|
tools: [makeToolMsg({ content: 'tool result', extra: [{ type: 'file' } as never] })]
|
|
};
|
|
}
|
|
|
|
it('returns the same section objects for unchanged inputs', () => {
|
|
const { anchor, tools } = makeSession();
|
|
const first = deriveAgenticSections(anchor, tools, [], false);
|
|
const second = deriveAgenticSections(anchor, tools, [], false);
|
|
|
|
expect(second[0]).toBe(first[0]);
|
|
expect(second[1]).toBe(first[1]);
|
|
});
|
|
|
|
it('recomputes when the assistant content changes', () => {
|
|
const { anchor, tools } = makeSession();
|
|
const first = deriveAgenticSections(anchor, tools, [], false);
|
|
|
|
anchor.content = 'edited';
|
|
const second = deriveAgenticSections(anchor, tools, [], false);
|
|
|
|
expect(second).not.toBe(first);
|
|
expect(second.some((s) => s.type === AgenticSectionType.TEXT && s.content === 'edited')).toBe(
|
|
true
|
|
);
|
|
});
|
|
|
|
it('recomputes when reasoning content changes', () => {
|
|
const { anchor, tools } = makeSession();
|
|
const first = deriveAgenticSections(anchor, tools, [], false);
|
|
|
|
anchor.reasoningContent = 'new thinking';
|
|
const second = deriveAgenticSections(anchor, tools, [], false);
|
|
|
|
expect(second).not.toBe(first);
|
|
});
|
|
|
|
it('recomputes when toolCalls change', () => {
|
|
const { anchor, tools } = makeSession();
|
|
const first = deriveAgenticSections(anchor, tools, [], false);
|
|
|
|
anchor.toolCalls = '[]';
|
|
const second = deriveAgenticSections(anchor, tools, [], false);
|
|
|
|
expect(second).not.toBe(first);
|
|
});
|
|
|
|
it('recomputes when a tool result or its extras change', () => {
|
|
const { anchor, tools } = makeSession();
|
|
const first = deriveAgenticSections(anchor, tools, [], false);
|
|
|
|
tools[0].content = 'new tool result';
|
|
expect(deriveAgenticSections(anchor, tools, [], false)).not.toBe(first);
|
|
|
|
const firstAfterContent = deriveAgenticSections(anchor, tools, [], false);
|
|
|
|
tools[0].extra = [{ type: 'image' } as never];
|
|
expect(deriveAgenticSections(anchor, tools, [], false)).not.toBe(firstAfterContent);
|
|
});
|
|
|
|
it('never reuses the streaming turn', () => {
|
|
const { anchor, tools } = makeSession();
|
|
const first = deriveAgenticSections(anchor, tools, [], true);
|
|
const second = deriveAgenticSections(anchor, tools, [], true);
|
|
|
|
expect(second).not.toBe(first);
|
|
});
|
|
|
|
it('keeps completed turns stable while the last turn streams', () => {
|
|
const anchor = makeAssistant({
|
|
content: 'turn one',
|
|
id: 'ast-1',
|
|
toolCalls: JSON.stringify([
|
|
{ function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
|
|
])
|
|
});
|
|
const continuation = makeAssistant({ content: 'turn two', id: 'ast-2' });
|
|
const tools = [
|
|
makeToolMsg({ content: 'r1', id: 'tool-1', toolCallId: 'call_1' }),
|
|
continuation,
|
|
makeToolMsg({ content: 'r2', id: 'tool-2', toolCallId: 'call_2' })
|
|
];
|
|
const first = deriveAgenticSections(anchor, tools, [], true);
|
|
const second = deriveAgenticSections(anchor, tools, [], true);
|
|
|
|
// turn one is complete: identical section objects across derivations
|
|
expect(second.slice(0, 2)).toEqual(first.slice(0, 2));
|
|
expect(second[0]).toBe(first[0]);
|
|
expect(second[1]).toBe(first[1]);
|
|
|
|
// the streaming last turn recomputed: fresh section objects
|
|
expect(second[second.length - 1]).not.toBe(first[first.length - 1]);
|
|
});
|
|
});
|