diff --git a/tools/ui/tests/unit/agentic-sections.test.ts b/tools/ui/tests/unit/agentic-sections.test.ts index 4096a17107..fdb3b22178 100644 --- a/tools/ui/tests/unit/agentic-sections.test.ts +++ b/tools/ui/tests/unit/agentic-sections.test.ts @@ -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]); + }); +}); diff --git a/tools/ui/tests/unit/branching.test.ts b/tools/ui/tests/unit/branching.test.ts new file mode 100644 index 0000000000..8a752ae2f1 --- /dev/null +++ b/tools/ui/tests/unit/branching.test.ts @@ -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); + } + }); +}); diff --git a/tools/ui/tests/unit/conversations-store.test.ts b/tools/ui/tests/unit/conversations-store.test.ts new file mode 100644 index 0000000000..e06546597b --- /dev/null +++ b/tools/ui/tests/unit/conversations-store.test.ts @@ -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 { + 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); + }); +}); diff --git a/tools/ui/tests/unit/parse-exec-shell-status.test.ts b/tools/ui/tests/unit/parse-exec-shell-status.test.ts index ed499d078e..7e22bf9eeb 100644 --- a/tools/ui/tests/unit/parse-exec-shell-status.test.ts +++ b/tools/ui/tests/unit/parse-exec-shell-status.test.ts @@ -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(); + }); +}); diff --git a/tools/ui/tests/unit/search-results.test.ts b/tools/ui/tests/unit/search-results.test.ts index c168dec258..561ab935a8 100644 --- a/tools/ui/tests/unit/search-results.test.ts +++ b/tools/ui/tests/unit/search-results.test.ts @@ -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); + }); +}); diff --git a/tools/ui/tests/unit/tool-call-meta.test.ts b/tools/ui/tests/unit/tool-call-meta.test.ts index bb28e3830c..f94d2279fe 100644 --- a/tools/ui/tests/unit/tool-call-meta.test.ts +++ b/tools/ui/tests/unit/tool-call-meta.test.ts @@ -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(); + }); +});