char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Tom Tanui: export conversations from database instead of cached store (#27432)1863ac033

char/aliceai-t5-moe
5.7 KiB175 linesraw
1import { beforeEach, describe, expect, it, vi } from 'vitest';
2
3vi.mock('$lib/services/database.service', () => ({
4	DatabaseService: { getConversationsWithMessages: vi.fn() }
5}));
6
7import { MessageRole, MessageType } from '$lib/enums';
8import { ConversationTransferService } from '$lib/services/conversation-transfer.service';
9import { DatabaseService } from '$lib/services/database.service';
10import { conversationsStore } from '$lib/stores/conversations/index.svelte';
11import type { DatabaseConversation, DatabaseMessage } from '$lib/types/database';
12import { filterByLeafNodeId } from '$lib/utils/branching';
13
14/**
15 * Reproduces the exported-conversation bug:
16 *
17 * A conversation created in the current page session keeps `currNode: ''` in the
18 * sidebar list, because that list is only loaded at init while IndexedDB is stamped
19 * on every message insert.
20 *
21 * Exporting from the cached record resulted in no branch pointer, and importing
22 * the file showed every branch at once.
23 */
24
25const fetchMock = vi.mocked(DatabaseService.getConversationsWithMessages);
26
27beforeEach(() => {
28	fetchMock.mockReset();
29});
30
31const CONV_ID = 'c1';
32
33function message(
34	id: string,
35	parent: string | null,
36	timestamp: number,
37	role: MessageRole,
38	type: MessageType = MessageType.TEXT
39): DatabaseMessage {
40	return {
41		children: [],
42		content: id,
43		convId: CONV_ID,
44		id,
45		parent,
46		role,
47		timestamp,
48		toolCalls: '',
49		type
50	} as DatabaseMessage;
51}
52
53/** root -> u1 -> a1 -> { u2a -> a2a (older) | u2b -> a2b (newer) } */
54function branchedMessages(): DatabaseMessage[] {
55	const messages = [
56		message('root', null, 10, MessageRole.USER, MessageType.ROOT),
57		message('u1', 'root', 20, MessageRole.USER),
58		message('a1', 'u1', 30, MessageRole.ASSISTANT),
59		message('u2a', 'a1', 40, MessageRole.USER),
60		message('a2a', 'u2a', 50, MessageRole.ASSISTANT),
61		message('u2b', 'a1', 60, MessageRole.USER),
62		message('a2b', 'u2b', 70, MessageRole.ASSISTANT)
63	];
64
65	for (const m of messages) {
66		m.children = messages.filter((c) => c.parent === m.id).map((c) => c.id);
67	}
68
69	return messages;
70}
71
72/** A second conversation with a single linear path: root -> u1 -> a1. */
73function linearMessages(convId: string): DatabaseMessage[] {
74	return [
75		{ ...message('root', null, 10, MessageRole.USER, MessageType.ROOT), children: ['u1'], convId },
76		{ ...message('u1', 'root', 20, MessageRole.USER), children: ['a1'], convId },
77		{ ...message('a1', 'u1', 30, MessageRole.ASSISTANT), convId }
78	];
79}
80
81function conversation(currNode: string, id: string = CONV_ID): DatabaseConversation {
82	return { currNode, id, lastModified: 100, name: `Chat ${id}` };
83}
84
85/** Mirrors `conversationsStore.loadConversation` */
86function displayedIds(imported: { conv: DatabaseConversation; messages: DatabaseMessage[] }) {
87	if (imported.conv.currNode) {
88		return filterByLeafNodeId(imported.messages, imported.conv.currNode, false).map((m) => m.id);
89	}
90
91	return imported.messages.map((m) => m.id);
92}
93
94/** Export then re-import */
95function roundTrip(conv: DatabaseConversation) {
96	const jsonl = ConversationTransferService.serializeSessionToJsonl({
97		conv,
98		messages: branchedMessages()
99	});
100	const [imported] = ConversationTransferService.parseSessionsJsonl(jsonl);
101
102	return { imported, sessionLine: JSON.parse(jsonl.split('\n')[0]) };
103}
104
105describe('conversation export source', () => {
106	it('reads the database record rather than the stale sidebar list', async () => {
107		conversationsStore.conversations = [conversation('')];
108
109		fetchMock.mockResolvedValue(
110			new Map([[CONV_ID, { conv: conversation('a2a'), messages: branchedMessages() }]])
111		);
112
113		const [exported] = await conversationsStore.getConversationsForExport([CONV_ID]);
114
115		expect(exported.conv.currNode).toBe('a2a');
116		expect(conversationsStore.conversations[0].currNode).toBe('');
117	});
118
119	it('reads every selected conversation from the database on bulk export', async () => {
120		conversationsStore.conversations = [conversation(''), conversation('', 'c2')];
121		conversationsStore.activeConversation = conversation('');
122
123		fetchMock.mockResolvedValue(
124			new Map([
125				['c2', { conv: conversation('a1', 'c2'), messages: linearMessages('c2') }],
126				[CONV_ID, { conv: conversation('a2a'), messages: branchedMessages() }]
127			])
128		);
129
130		const archive = vi
131			.spyOn(ConversationTransferService, 'downloadConversationsArchive')
132			.mockImplementation(() => {});
133
134		await conversationsStore.bulkExportConversations([CONV_ID, 'c2']);
135
136		expect(fetchMock).toHaveBeenCalledWith([CONV_ID, 'c2']);
137		expect(archive).toHaveBeenCalledTimes(1);
138
139		const payload = archive.mock.calls[0][0];
140
141		expect(payload.map((entry) => entry.conv.id)).toEqual([CONV_ID, 'c2']);
142		// Each entry carries its own database currNode.
143		expect(payload.map((entry) => entry.conv.currNode)).toEqual(['a2a', 'a1']);
144		expect(payload[1].messages.map((m: DatabaseMessage) => m.id)).toEqual(['root', 'u1', 'a1']);
145
146		archive.mockRestore();
147	});
148});
149
150describe('exported conversation branch pointer', () => {
151	it('carries the database currNode, so the import restores the current branch', () => {
152		// The user regenerated to create a2b, then switched back to the a2a branch,
153		// so the stored leaf is NOT the newest message.
154		const { imported, sessionLine } = roundTrip(conversation('a2a'));
155
156		expect(sessionLine.currNode).toBe('a2a');
157		expect(displayedIds(imported)).toEqual(['u1', 'a1', 'u2a', 'a2a']);
158		expect(imported.messages.map((m: DatabaseMessage) => m.id).sort()).toEqual([
159			'a1',
160			'a2a',
161			'a2b',
162			'root',
163			'u1',
164			'u2a',
165			'u2b'
166		]);
167	});
168
169	it('shows every branch on import when the cache entry exported an empty currNode', () => {
170		const { imported, sessionLine } = roundTrip(conversation(''));
171
172		expect(sessionLine.currNode).toBe('');
173		expect(displayedIds(imported)).toEqual(['root', 'u1', 'a1', 'u2a', 'a2a', 'u2b', 'a2b']);
174	});
175});