mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-17 20:31:47 +02:00
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
This commit is contained in:
@@ -290,3 +290,114 @@ describe('hasAgenticContent', () => {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Sibling-info correctness for buildSiblingInfoMap, including the memoized
|
||||
// leaf resolution. A wrong leaf id here breaks branch navigation, so the
|
||||
// deep-chain and multi-branch cases below pin the resolution down.
|
||||
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
import { buildSiblingInfoMap, findLeafNode } from '$lib/utils/branching';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function msg(id: string, parent: string | null, children: string[] = []): DatabaseMessage {
|
||||
return {
|
||||
children,
|
||||
content: '',
|
||||
convId: 'c1',
|
||||
id,
|
||||
parent,
|
||||
role: MessageRole.USER,
|
||||
timestamp: 0,
|
||||
type: MessageType.TEXT
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
|
||||
/** root -> m1 -> ... -> m depth, each node with a single child. */
|
||||
function linearChain(depth: number): DatabaseMessage[] {
|
||||
const messages = [msg('m0', null, ['m1'])];
|
||||
|
||||
for (let i = 1; i <= depth; i++) {
|
||||
messages.push(msg(`m${i}`, `m${i - 1}`, i < depth ? [`m${i + 1}`] : []));
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
describe('buildSiblingInfoMap', () => {
|
||||
it('resolves the deepest leaf for every node of a long single chain', () => {
|
||||
const messages = linearChain(50);
|
||||
const map = buildSiblingInfoMap(messages);
|
||||
const leafId = messages[messages.length - 1].id;
|
||||
|
||||
// every non-root message of the chain is an only child, and its
|
||||
// navigation target is the chain's deepest leaf
|
||||
for (const m of messages.slice(1)) {
|
||||
const info = map.get(m.id);
|
||||
|
||||
expect(info?.totalSiblings).toBe(1);
|
||||
expect(info?.siblingIds).toEqual([leafId]);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports sibling position and leaf targets on a branched tree', () => {
|
||||
// m0 -> m1, m4 ; m1 -> m2 ; m2 -> m3, m6 ; m4 -> m5
|
||||
const root = msg('m0', null, ['m1', 'm4']);
|
||||
const m1 = msg('m1', 'm0', ['m2']);
|
||||
const m2 = msg('m2', 'm1', ['m3', 'm6']);
|
||||
const m3 = msg('m3', 'm2');
|
||||
const m4 = msg('m4', 'm0', ['m5']);
|
||||
const m5 = msg('m5', 'm4');
|
||||
const m6 = msg('m6', 'm2');
|
||||
const map = buildSiblingInfoMap([root, m1, m2, m3, m4, m5, m6]);
|
||||
|
||||
// m1 and m4 share the root as parent; their nav targets are the
|
||||
// leaves of their subtrees ( m6 for the first branch, m5 for the second )
|
||||
expect(map.get(m1.id)).toMatchObject({
|
||||
currentIndex: 0,
|
||||
siblingIds: [m6.id, m5.id],
|
||||
totalSiblings: 2
|
||||
});
|
||||
expect(map.get(m4.id)).toMatchObject({
|
||||
currentIndex: 1,
|
||||
siblingIds: [m6.id, m5.id],
|
||||
totalSiblings: 2
|
||||
});
|
||||
|
||||
// m3 and m6 are siblings under m2; both are leaves
|
||||
expect(map.get(m3.id)?.siblingIds).toEqual([m3.id, m6.id]);
|
||||
expect(map.get(m6.id)?.currentIndex).toBe(1);
|
||||
|
||||
// the root has no parent and reports itself
|
||||
expect(map.get(root.id)).toMatchObject({
|
||||
currentIndex: 0,
|
||||
siblingIds: [root.id],
|
||||
totalSiblings: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('agrees with findLeafNode for arbitrary nodes', () => {
|
||||
const messages = linearChain(20);
|
||||
const leafId = messages[messages.length - 1].id;
|
||||
|
||||
// every node of the chain resolves to the deepest leaf
|
||||
for (const m of messages) {
|
||||
expect(findLeafNode(messages, m.id), `leaf of ${m.id}`).toBe(leafId);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// Field updates to the active conversation must keep the object identity
|
||||
// stable: effects that track the identity ( the chat screen's sibling-info
|
||||
// refresh ) refire on every identity change, which used to trigger a full
|
||||
// message refetch on every send and tool result.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/services/database.service', () => ({
|
||||
DatabaseService: {
|
||||
getConversation: vi.fn(),
|
||||
getConversationMessages: vi.fn(),
|
||||
updateConversation: vi.fn(),
|
||||
updateCurrentNode: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
|
||||
import type { DatabaseConversation, DatabaseMessage } from '$lib/types/database';
|
||||
|
||||
const getConversationMock = vi.mocked(DatabaseService.getConversation);
|
||||
const getMessagesMock = vi.mocked(DatabaseService.getConversationMessages);
|
||||
const updateCurrentNodeMock = vi.mocked(DatabaseService.updateCurrentNode);
|
||||
|
||||
function makeConversation(overrides: Partial<DatabaseConversation> = {}): DatabaseConversation {
|
||||
return {
|
||||
currNode: 'node-1',
|
||||
id: 'conv-1',
|
||||
lastModified: 1000,
|
||||
name: 'conversation',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
async function loadActive(conversation: DatabaseConversation, messages: DatabaseMessage[]) {
|
||||
getConversationMock.mockResolvedValue(conversation);
|
||||
getMessagesMock.mockResolvedValue(messages);
|
||||
|
||||
expect(await conversationsStore.loadConversation(conversation.id)).toBe(true);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getConversationMock.mockReset();
|
||||
getMessagesMock.mockReset();
|
||||
updateCurrentNodeMock.mockReset();
|
||||
updateCurrentNodeMock.mockResolvedValue(undefined);
|
||||
vi.mocked(DatabaseService.updateConversation).mockReset();
|
||||
vi.mocked(DatabaseService.updateConversation).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('active conversation identity', () => {
|
||||
it('hands the load read off exactly once', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
|
||||
expect(conversationsStore.consumeLastLoadedMessages('conv-1')).toEqual([]);
|
||||
// a second consume is a miss: branch actions must fall back to a refetch
|
||||
expect(conversationsStore.consumeLastLoadedMessages('conv-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('writes currNode in place on updateCurrentNode', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
const before = conversationsStore.activeConversation;
|
||||
|
||||
await conversationsStore.updateCurrentNode('node-2');
|
||||
|
||||
expect(conversationsStore.activeConversation).toBe(before);
|
||||
expect(conversationsStore.activeConversation?.currNode).toBe('node-2');
|
||||
});
|
||||
|
||||
it('writes renamed and pinned fields in place on applyConversationUpdate', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
const before = conversationsStore.activeConversation;
|
||||
|
||||
conversationsStore.applyConversationUpdate('conv-1', { name: 'renamed', pinned: true });
|
||||
|
||||
expect(conversationsStore.activeConversation).toBe(before);
|
||||
expect(conversationsStore.activeConversation?.name).toBe('renamed');
|
||||
expect(conversationsStore.activeConversation?.pinned).toBe(true);
|
||||
});
|
||||
|
||||
it('writes lastModified in place on updateConversationTimestamp', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
const before = conversationsStore.activeConversation;
|
||||
|
||||
conversationsStore.updateConversationTimestamp('conv-1');
|
||||
|
||||
expect(conversationsStore.activeConversation).toBe(before);
|
||||
expect(conversationsStore.activeConversation?.lastModified).toBeGreaterThan(1000);
|
||||
});
|
||||
});
|
||||
@@ -71,3 +71,21 @@ describe('isExitCodeSummaryLine', () => {
|
||||
expect(isExitCodeSummaryLine('[exit code: 7]', undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExecShellCommandExitStatus tail scan', () => {
|
||||
it('finds the marker at the end of a blob larger than the tail window', () => {
|
||||
// the parser matches only the last ~128 chars; a marker past that
|
||||
// window must still parse, and an earlier fake must not match
|
||||
const blob = `${'the shell prints [exit code: 1] mid-stream\n'.repeat(2000)}[exit code: 0]`;
|
||||
const status = parseExecShellCommandExitStatus(blob);
|
||||
|
||||
expect(status?.code).toBe(0);
|
||||
expect(status?.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps rejecting markers that are not at the absolute end', () => {
|
||||
const blob = `${'stdout\n'.repeat(2000)}[exit code: 0]\nsome trailing log line`;
|
||||
|
||||
expect(parseExecShellCommandExitStatus(blob)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
extractSearchQuery,
|
||||
extractSearchResults,
|
||||
faviconForUrl,
|
||||
isWebSearchToolName
|
||||
isWebSearchToolName,
|
||||
looksLikeSearchResult
|
||||
} from '$lib/utils/search-results';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -119,3 +120,27 @@ describe('isWebSearchToolName', () => {
|
||||
expect(isWebSearchToolName('exec_shell_command')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractSearchResults prefilter', () => {
|
||||
it('returns the shared empty array for blobs without the wire format', () => {
|
||||
// exec/file tool results never carry Title:/URL: field lines; the
|
||||
// cheap prefilter must skip the line-split parse for them
|
||||
const stdout = `${'make[1]: entering directory\n'.repeat(5000)}`;
|
||||
|
||||
expect(extractSearchResults(stdout)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty result when only one required field is present', () => {
|
||||
expect(extractSearchResults('URL: https://example.com')).toEqual([]);
|
||||
expect(extractSearchResults('Title: only a title')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksLikeSearchResult', () => {
|
||||
it('requires both Title and URL field markers', () => {
|
||||
expect(looksLikeSearchResult('Title: a\nURL: https://b')).toBe(true);
|
||||
expect(looksLikeSearchResult('URL: https://b')).toBe(false);
|
||||
expect(looksLikeSearchResult('plain stdout')).toBe(false);
|
||||
expect(looksLikeSearchResult(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,3 +28,15 @@ describe('tryParseToolResultObject', () => {
|
||||
expect(tryParseToolResultObject('{bad')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryParseToolResultObject gating', () => {
|
||||
it('parses JSON objects that start after leading whitespace', () => {
|
||||
expect(tryParseToolResultObject('\n {"result":"ok"}')).toEqual({ result: 'ok' });
|
||||
});
|
||||
|
||||
it('skips the parse for large plain-text results', () => {
|
||||
// most tool results are file contents or stdout; the gate avoids a
|
||||
// doomed JSON.parse over the whole blob
|
||||
expect(tryParseToolResultObject(`${'stdout line\n'.repeat(2000)}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user