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:
Aleksander Grygier
2026-09-06 02:05:03 +02:00
parent e5f145bdef
commit bd62827935
6 changed files with 352 additions and 1 deletions
@@ -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]);
});
});
+95
View File
@@ -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();
});
});
+26 -1
View File
@@ -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();
});
});