kiku-agent 1.3.1 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +4 -12
- package/server/agent/eval/AGENTS.md +228 -0
- package/server/agent/eval/analyze.py +684 -0
- package/server/agent/eval/gates.json +15 -0
- package/server/agent/eval/modules/analysis/compare.js +198 -0
- package/server/agent/eval/modules/analysis/gates.js +72 -0
- package/server/agent/eval/modules/analysis/judge.js +70 -0
- package/server/agent/eval/modules/core/cli.js +453 -0
- package/server/agent/eval/modules/core/config.js +60 -0
- package/server/agent/eval/modules/core/store.js +236 -0
- package/server/agent/eval/modules/evaluation/cases.js +689 -0
- package/server/agent/eval/modules/evaluation/runner.js +298 -0
- package/server/agent/eval/modules/evaluation/scoring.js +351 -0
- package/server/agent/eval/modules/evaluation/worker.js +122 -0
- package/server/agent/eval/modules/reporting/benchmark.js +265 -0
- package/server/agent/eval/modules/reporting/benchmarkPng.js +487 -0
- package/server/agent/eval/modules/reporting/dashboard.js +1406 -0
- package/server/agent/eval/modules/server/evalApi.js +1099 -0
- package/server/agent/eval/modules/ui/evalUi.js +4796 -0
- package/server/agent/eval/modules/utils/smokes.js +119 -0
- package/server/agent/tests/agentBubbleSystem.test.js +65 -0
- package/server/agent/tests/agentFollowUps.test.js +127 -0
- package/server/agent/tests/apiRouter.test.js +108 -0
- package/server/agent/tests/architectureFixes.test.js +81 -0
- package/server/agent/tests/architectureRefactoring.test.js +194 -0
- package/server/agent/tests/autoResearch.test.js +64 -0
- package/server/agent/tests/bilibiliEmbed.test.js +251 -0
- package/server/agent/tests/catalogSync.test.js +130 -0
- package/server/agent/tests/citations.test.js +180 -0
- package/server/agent/tests/clarificationAnalytics.test.js +219 -0
- package/server/agent/tests/clarifyingQuestions.test.js +594 -0
- package/server/agent/tests/cliAutomation.test.js +121 -0
- package/server/agent/tests/cloudProviders.test.js +234 -0
- package/server/agent/tests/commandInput.test.js +24 -0
- package/server/agent/tests/commands.test.js +417 -0
- package/server/agent/tests/contextIntelligence.test.js +162 -0
- package/server/agent/tests/conversationSafety.test.js +53 -0
- package/server/agent/tests/decisionAndSearchEnhancements.test.js +135 -0
- package/server/agent/tests/dramaAnswerQuality.test.js +73 -0
- package/server/agent/tests/dynamicCatalogIngestion.test.js +285 -0
- package/server/agent/tests/dynamicRoutingAndSearch.test.js +92 -0
- package/server/agent/tests/edgeCasesScenarios.test.js +49 -0
- package/server/agent/tests/entertainmentMetrics.test.js +126 -0
- package/server/agent/tests/envBootstrap.test.js +87 -0
- package/server/agent/tests/envCommand.test.js +215 -0
- package/server/agent/tests/errorHandling.test.js +80 -0
- package/server/agent/tests/errors.test.js +13 -0
- package/server/agent/tests/evalAnalyze.test.js +54 -0
- package/server/agent/tests/evalCommand.test.js +54 -0
- package/server/agent/tests/evalCorpus.test.js +126 -0
- package/server/agent/tests/evalHarness.test.js +62 -0
- package/server/agent/tests/evalJudgeContext.test.js +38 -0
- package/server/agent/tests/evalMetrics.test.js +312 -0
- package/server/agent/tests/evalStore.test.js +190 -0
- package/server/agent/tests/evalStudio.test.js +179 -0
- package/server/agent/tests/feedbackLearning.test.js +186 -0
- package/server/agent/tests/firstRun.test.js +44 -0
- package/server/agent/tests/gemma4eOptimization.test.js +95 -0
- package/server/agent/tests/hardwareTiers.test.js +206 -0
- package/server/agent/tests/hybridWebSearch.test.js +192 -0
- package/server/agent/tests/judgeService.test.js +251 -0
- package/server/agent/tests/lanDiscovery.test.js +319 -0
- package/server/agent/tests/mcpClient.test.js +201 -0
- package/server/agent/tests/modelManager.test.js +109 -0
- package/server/agent/tests/multilingualScenarios.test.js +69 -0
- package/server/agent/tests/newCommands.test.js +347 -0
- package/server/agent/tests/newCommandsTui.test.js +141 -0
- package/server/agent/tests/newTools.test.js +113 -0
- package/server/agent/tests/newsIntelligence.test.js +178 -0
- package/server/agent/tests/nextGenArchitecture.test.js +99 -0
- package/server/agent/tests/offlineInput.test.js +217 -0
- package/server/agent/tests/packagingGuard.test.js +57 -0
- package/server/agent/tests/presentation.test.js +61 -0
- package/server/agent/tests/providerPersistence.test.js +39 -0
- package/server/agent/tests/queryBenchmarks.test.js +83 -0
- package/server/agent/tests/queryDetails.test.js +126 -0
- package/server/agent/tests/realWorldValidation.test.js +391 -0
- package/server/agent/tests/resilienceAndConcurrency.test.js +117 -0
- package/server/agent/tests/runtimeWatchdog.test.js +32 -0
- package/server/agent/tests/sanitizer.test.js +37 -0
- package/server/agent/tests/scenariosData.js +378 -0
- package/server/agent/tests/schemaRouting.test.js +54 -0
- package/server/agent/tests/securityAuditRemediation.test.js +214 -0
- package/server/agent/tests/securityHardening.test.js +146 -0
- package/server/agent/tests/securitySanitizer.test.js +38 -0
- package/server/agent/tests/sessionAndCopy.test.js +232 -0
- package/server/agent/tests/sidebarControls.test.js +101 -0
- package/server/agent/tests/statefulMemory.test.js +270 -0
- package/server/agent/tests/subagent.test.js +216 -0
- package/server/agent/tests/subagentProgress.test.js +109 -0
- package/server/agent/tests/subagentTextFallback.test.js +115 -0
- package/server/agent/tests/suggestions.test.js +199 -0
- package/server/agent/tests/suggestionsTui.test.js +251 -0
- package/server/agent/tests/telemetryAndProfiles.test.js +179 -0
- package/server/agent/tests/terminalImage.test.js +80 -0
- package/server/agent/tests/terminalMarkdown.test.js +166 -0
- package/server/agent/tests/terminalText.test.js +133 -0
- package/server/agent/tests/textProtocolLoop.test.js +126 -0
- package/server/agent/tests/textToolParsing.test.js +139 -0
- package/server/agent/tests/theme.test.js +26 -0
- package/server/agent/tests/toolRegistryParity.test.js +53 -0
- package/server/agent/tests/toolRoutingRegressions.test.js +119 -0
- package/server/agent/tests/tools.test.js +259 -0
- package/server/agent/tests/unifiedEndpoints.test.js +152 -0
- package/server/agent/tests/v110Features.test.js +262 -0
- package/server/agent/tests/versionBump.test.js +69 -0
- package/server/agent/tests/vramEstimator.test.js +520 -0
- package/server/agent/tests/webChatInspection.test.js +300 -0
- package/server/agent/tests/webChatMemory.test.js +112 -0
- package/server/agent/tests/webChatSecurity.test.js +169 -0
- package/server/agent/tests/webExtract.test.js +261 -0
- package/server/agent/tests/webSources.test.js +247 -0
- package/server/eval/judgeRubrics.js +110 -0
- package/server/eval/judgeService.js +290 -0
- package/server/eval/runBenchmark.js +182 -0
- package/server/mcp/examples/librechat.example.yaml +16 -0
- package/server/mcp/tests/connection.test.js +156 -0
- package/server/supabase_schema.sql +63 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
extractArticleDate,
|
|
8
|
+
classifySource,
|
|
9
|
+
classifyTopics,
|
|
10
|
+
dedupeNewsItems,
|
|
11
|
+
rankNewsItems,
|
|
12
|
+
mergeNewsResults,
|
|
13
|
+
newsFingerprint,
|
|
14
|
+
} from '../newsIntelligence.js';
|
|
15
|
+
import { checkNewsWatch, loadWatchState, saveWatchState } from '../newsWatch.js';
|
|
16
|
+
|
|
17
|
+
const NOW = new Date('2026-09-12T12:00:00Z');
|
|
18
|
+
|
|
19
|
+
test('extractArticleDate parses ISO, Chinese, Thai BE, and English dates', () => {
|
|
20
|
+
const iso = extractArticleDate({ title: 'Ju Jingyi new drama 2026-09-01 announced', snippet: '' }, { now: NOW });
|
|
21
|
+
assert.equal(iso.source, 'iso');
|
|
22
|
+
assert.equal(iso.date.toISOString().slice(0, 10), '2026-09-01');
|
|
23
|
+
|
|
24
|
+
const zh = extractArticleDate({ title: '鞠婧祎 2026年9月10日 发新歌', snippet: '' }, { now: NOW });
|
|
25
|
+
assert.equal(zh.source, 'zh');
|
|
26
|
+
assert.equal(zh.date.toISOString().slice(0, 10), '2026-09-10');
|
|
27
|
+
|
|
28
|
+
const thBE = extractArticleDate({ title: 'จวีจิ้งอีปล่อยเพลงใหม่ 12 ก.ย. 2568', snippet: '' }, { now: NOW });
|
|
29
|
+
assert.equal(thBE.source, 'th');
|
|
30
|
+
assert.equal(thBE.date.toISOString().slice(0, 10), '2025-09-12', 'Buddhist Era 2568 must convert to CE 2025');
|
|
31
|
+
|
|
32
|
+
const thFull = extractArticleDate({ title: '', snippet: '12 กันยายน 2026 จวีจิ้งอีคว้ารางวัล' }, { now: NOW });
|
|
33
|
+
assert.equal(thFull.source, 'th');
|
|
34
|
+
assert.equal(thFull.date.toISOString().slice(0, 10), '2026-09-12');
|
|
35
|
+
|
|
36
|
+
const en1 = extractArticleDate({ title: 'Ju Jingyi appears at event Sep 12, 2026', snippet: '' }, { now: NOW });
|
|
37
|
+
assert.equal(en1.source, 'en');
|
|
38
|
+
assert.equal(en1.date.toISOString().slice(0, 10), '2026-09-12');
|
|
39
|
+
|
|
40
|
+
const en2 = extractArticleDate({ title: '', snippet: '12 September 2026 studio update' }, { now: NOW });
|
|
41
|
+
assert.equal(en2.date.toISOString().slice(0, 10), '2026-09-12');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('extractArticleDate prefers RSS pubDate and supports relative dates', () => {
|
|
45
|
+
const rss = extractArticleDate({ publishedAt: '2026-09-08T06:30:00Z', title: 'x 2026-01-01' }, { now: NOW });
|
|
46
|
+
assert.equal(rss.source, 'rss');
|
|
47
|
+
assert.equal(rss.date.toISOString().slice(0, 10), '2026-09-08');
|
|
48
|
+
|
|
49
|
+
const enRel = extractArticleDate({ title: 'Ju Jingyi new single posted 3 hours ago', snippet: '' }, { now: NOW });
|
|
50
|
+
assert.equal(enRel.source, 'relative');
|
|
51
|
+
assert.equal(enRel.date.toISOString(), '2026-09-12T09:00:00.000Z');
|
|
52
|
+
|
|
53
|
+
const zhRel = extractArticleDate({ title: '鞠婧祎工作室 5小时前 发布', snippet: '' }, { now: NOW });
|
|
54
|
+
assert.equal(zhRel.date.toISOString(), '2026-09-12T07:00:00.000Z');
|
|
55
|
+
|
|
56
|
+
const thRel = extractArticleDate({ title: 'จวีจิ้งอี 2 วันที่แล้ว', snippet: '' }, { now: NOW });
|
|
57
|
+
assert.equal(thRel.date.toISOString(), '2026-09-10T12:00:00.000Z');
|
|
58
|
+
|
|
59
|
+
const none = extractArticleDate({ title: 'Ju Jingyi something happened', snippet: '' }, { now: NOW });
|
|
60
|
+
assert.equal(none.date, null);
|
|
61
|
+
assert.equal(none.source, null);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('classifySource assigns trust tiers to official, press, and community hosts', () => {
|
|
65
|
+
assert.deepEqual(classifySource('https://weibo.com/u/123456'), { tier: 1, label: 'official' });
|
|
66
|
+
assert.deepEqual(classifySource('https://m.weibo.cn/status/abc'), { tier: 1, label: 'official' });
|
|
67
|
+
assert.deepEqual(classifySource('https://www.youtube.com/watch?v=x'), { tier: 1, label: 'official' });
|
|
68
|
+
assert.equal(classifySource('https://www.thairath.co.th/entertain/x').tier, 2);
|
|
69
|
+
assert.equal(classifySource('https://ent.sohu.com/a/1').tier, 2);
|
|
70
|
+
assert.deepEqual(classifySource('https://fanblog.example.com/post'), { tier: 3, label: 'community' });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('classifyTopics tags music, drama, brand, award, and event coverage', () => {
|
|
74
|
+
assert.equal(classifyTopics({ title: 'Ju Jingyi new single MV released', snippet: '' }).primary, 'music');
|
|
75
|
+
assert.equal(classifyTopics({ title: 'จวีจิ้งอี รับบท leading role ซีรีส์ใหม่', snippet: '' }).primary, 'drama');
|
|
76
|
+
assert.equal(classifyTopics({ title: 'Ju Jingyi named brand ambassador', snippet: '' }).primary, 'brand');
|
|
77
|
+
assert.ok(classifyTopics({ title: 'Ju Jingyi wins award', snippet: '' }).tags.includes('award'));
|
|
78
|
+
assert.equal(classifyTopics({ title: 'Unrelated headline', snippet: '' }).primary, 'general');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('dedupeNewsItems collapses near-duplicate stories and keeps distinct ones', () => {
|
|
82
|
+
const a = { title: 'Ju Jingyi releases new single 0.2s topping three regional charts', snippet: 'The single topped charts this week across regional platforms.', url: 'https://press-a.com/1' };
|
|
83
|
+
const b = { title: 'Ju Jingyi releases new single 0.2s topping three regional charts!', snippet: 'The single topped charts this week across regional platforms.', url: 'https://press-b.com/2' };
|
|
84
|
+
const c = { title: 'Ju Jingyi opening fanmeeting tour in Bangkok next month', snippet: 'Tickets go on sale for the Bangkok fanmeeting tour next month.', url: 'https://press-c.com/3' };
|
|
85
|
+
|
|
86
|
+
const kept = dedupeNewsItems([a, b, c]);
|
|
87
|
+
assert.equal(kept.length, 2);
|
|
88
|
+
assert.equal(kept[0].url, 'https://press-a.com/1');
|
|
89
|
+
assert.equal(kept[1].url, 'https://press-c.com/3');
|
|
90
|
+
|
|
91
|
+
const urlDupes = dedupeNewsItems([
|
|
92
|
+
{ ...a, url: 'https://x.com/same?utm=1' },
|
|
93
|
+
{ ...a, url: 'https://x.com/same?utm=2' },
|
|
94
|
+
]);
|
|
95
|
+
assert.equal(urlDupes.length, 1, 'URL normalization must drop same-article copies');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('rankNewsItems sorts by recency, filters by cutoff, and applies officialOnly', () => {
|
|
99
|
+
const fresh = { title: 'fresh official weibo post', snippet: '', url: 'https://weibo.com/u/1/1', publishedAt: '2026-09-12T10:00:00Z' };
|
|
100
|
+
const monthOld = { title: 'older press story', snippet: '', url: 'https://www.thairath.co.th/e/2', publishedAt: '2026-08-10T00:00:00Z' };
|
|
101
|
+
const undated = { title: 'undated fan blog post', snippet: '', url: 'https://blog.example.com/3' };
|
|
102
|
+
|
|
103
|
+
const ranked = rankNewsItems([monthOld, undated, fresh], { now: NOW });
|
|
104
|
+
assert.equal(ranked[0].title, fresh.title, 'fresh items must outrank older and undated ones');
|
|
105
|
+
|
|
106
|
+
const cutoffFiltered = rankNewsItems([monthOld, fresh], { now: NOW, cutoff: new Date('2026-09-01T00:00:00Z') });
|
|
107
|
+
assert.equal(cutoffFiltered.length, 1);
|
|
108
|
+
assert.equal(cutoffFiltered[0].title, fresh.title);
|
|
109
|
+
|
|
110
|
+
const official = rankNewsItems([fresh, monthOld], { now: NOW, officialOnly: true });
|
|
111
|
+
assert.equal(official.length, 1);
|
|
112
|
+
assert.equal(official[0].tier, 1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('mergeNewsResults fuses RSS and web groups with dedup and budget', () => {
|
|
116
|
+
const rss = [
|
|
117
|
+
{ title: 'Studio announces comeback single', snippet: 'Official studio announcement of the comeback single this month.', url: 'https://press-a.com/comeback', publishedAt: '2026-09-11T00:00:00Z' },
|
|
118
|
+
{ title: 'Drama filming wraps', snippet: 'The drama filming has wrapped after three months of production.', url: 'https://press-b.com/drama', publishedAt: '2026-09-05T00:00:00Z' },
|
|
119
|
+
];
|
|
120
|
+
const web = [
|
|
121
|
+
{ title: 'Studio announces comeback single', snippet: 'Official studio announcement of the comeback single this month.', url: 'https://aggregator.example/comeback' },
|
|
122
|
+
{ title: 'Brand ambassador campaign launches', snippet: 'New brand ambassador campaign launches with a nationwide rollout.', url: 'https://press-c.com/brand', publishedAt: '2026-09-09T00:00:00Z' },
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
const { items, totalUnique } = mergeNewsResults({
|
|
126
|
+
groups: [{ name: 'news-rss', results: rss }, { name: 'web', results: web }],
|
|
127
|
+
now: NOW,
|
|
128
|
+
budget: 10,
|
|
129
|
+
});
|
|
130
|
+
assert.equal(totalUnique, 3, 'same story from two groups must collapse to one');
|
|
131
|
+
assert.equal(items[0].title, 'Studio announces comeback single', 'freshest official story must rank first');
|
|
132
|
+
assert.ok(items[0].via, 'merge must record which group each item came from');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
function tmpStatePath() {
|
|
136
|
+
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'kiku-news-')), 'watch-state.json');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
test('checkNewsWatch detects first-run items, zero delta on repeat, and fresh items on change', async () => {
|
|
140
|
+
const statePath = tmpStatePath();
|
|
141
|
+
const fetcher = () => Promise.resolve([
|
|
142
|
+
{ title: 'Ju Jingyi comeback stage confirmed for this weekend', snippet: 'Comeback stage confirmed with a special performance lineup.', url: 'https://press-a.com/1', publishedAt: '2026-09-11T00:00:00Z' },
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
const first = await checkNewsWatch({ topic: 'comeback', days: 7, fetcher, statePath, now: NOW });
|
|
146
|
+
assert.equal(first.fresh.length, 1);
|
|
147
|
+
|
|
148
|
+
const second = await checkNewsWatch({ topic: 'comeback', days: 7, fetcher, statePath, now: new Date(NOW.getTime() + 60000) });
|
|
149
|
+
assert.equal(second.fresh.length, 0, 'unchanged stories must not re-appear as fresh');
|
|
150
|
+
|
|
151
|
+
const changedFetcher = () => Promise.resolve([
|
|
152
|
+
{ title: 'Ju Jingyi comeback stage confirmed for this weekend', snippet: 'Comeback stage confirmed with a special performance lineup.', url: 'https://press-a.com/1', publishedAt: '2026-09-11T00:00:00Z' },
|
|
153
|
+
{ title: 'Ju Jingyi surprise digital single drops at midnight', snippet: 'A surprise digital single drops at midnight with an accompanying music video.', url: 'https://press-a.com/2', publishedAt: '2026-09-12T09:00:00Z' },
|
|
154
|
+
]);
|
|
155
|
+
const third = await checkNewsWatch({ topic: 'comeback', days: 7, fetcher: changedFetcher, statePath, now: new Date(NOW.getTime() + 120000) });
|
|
156
|
+
assert.equal(third.fresh.length, 1, 'only the genuinely new story counts as fresh');
|
|
157
|
+
assert.equal(third.fresh[0].url, 'https://press-a.com/2');
|
|
158
|
+
|
|
159
|
+
const persisted = loadWatchState(statePath);
|
|
160
|
+
assert.ok(persisted.watches['comeback'].lastHashes.length >= 1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('watch state persists snapshots across processes', () => {
|
|
164
|
+
const statePath = tmpStatePath();
|
|
165
|
+
const state = loadWatchState(statePath);
|
|
166
|
+
state.watches['music'] = { lastHashes: ['abc'], lastCheckedAt: '2026-09-12T00:00:00.000Z', lastCount: 3 };
|
|
167
|
+
saveWatchState(state, statePath);
|
|
168
|
+
|
|
169
|
+
const reloaded = loadWatchState(statePath);
|
|
170
|
+
assert.equal(reloaded.watches['music'].lastHashes[0], 'abc');
|
|
171
|
+
assert.equal(reloaded.watches['music'].lastCount, 3);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('newsFingerprint is stable for identical items', () => {
|
|
175
|
+
const item = { title: 'identical headline for fingerprint check', snippet: 'same body text' };
|
|
176
|
+
assert.equal(newsFingerprint(item), newsFingerprint({ ...item }));
|
|
177
|
+
assert.notEqual(newsFingerprint(item), newsFingerprint({ title: 'different headline entirely', snippet: 'other body' }));
|
|
178
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { DynamicToolRouter, TOOL_CLUSTERS } from '../toolRouter.js';
|
|
4
|
+
import { ContextCompactor, estimateTokenCount } from '../contextCompactor.js';
|
|
5
|
+
import { AgentMemory } from '../memory.js';
|
|
6
|
+
import { getUnifiedProfile, getUnifiedWorks, getUnifiedDiscography } from '../../mcp/dataAdapter.js';
|
|
7
|
+
import { eventBus, Events } from '../eventBus.js';
|
|
8
|
+
|
|
9
|
+
test('DynamicToolRouter filters tools based on prompt intent', () => {
|
|
10
|
+
const router = new DynamicToolRouter();
|
|
11
|
+
|
|
12
|
+
const mediaTools = router.routeToolsForQuery('เปิดเพลงของจวีจิ้งอีหน่อย');
|
|
13
|
+
assert.ok(mediaTools.some(t => t.function.name === 'play_music'));
|
|
14
|
+
assert.ok(mediaTools.some(t => t.function.name === 'search_discography'));
|
|
15
|
+
assert.ok(mediaTools.length < router.allTools.length);
|
|
16
|
+
|
|
17
|
+
const dramaTools = router.routeToolsForQuery('Ju Jingyi In Blossom drama synopsis');
|
|
18
|
+
assert.ok(dramaTools.some(t => t.function.name === 'search_works'));
|
|
19
|
+
assert.ok(dramaTools.some(t => t.function.name === 'get_work_detail'));
|
|
20
|
+
|
|
21
|
+
const fallbackTools = router.routeToolsForQuery('สวัสดีครับ');
|
|
22
|
+
assert.ok(fallbackTools.some(t => t.function.name === 'global_search'));
|
|
23
|
+
assert.ok(fallbackTools.length <= 10);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('ContextCompactor calculates tokens and summarizes history without losing system message', () => {
|
|
27
|
+
const compactor = new ContextCompactor({ maxTokens: 50, keepRecentMessages: 2 });
|
|
28
|
+
const messages = [
|
|
29
|
+
{ role: 'system', content: 'You are Kiku AI.' },
|
|
30
|
+
{ role: 'user', content: 'Who is Ju Jingyi?' },
|
|
31
|
+
{ role: 'assistant', content: 'Ju Jingyi is a singer and actress.' },
|
|
32
|
+
{ role: 'user', content: 'Tell me about her songs.' },
|
|
33
|
+
{ role: 'assistant', content: 'She has many solo singles.' },
|
|
34
|
+
{ role: 'user', content: 'What is her latest drama in 2026?' },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const tokens = estimateTokenCount(messages);
|
|
38
|
+
assert.ok(tokens > 0);
|
|
39
|
+
|
|
40
|
+
const compacted = compactor.compact(messages, { maxTokens: 20, keepRecentMessages: 2 });
|
|
41
|
+
assert.equal(compacted[0].role, 'system');
|
|
42
|
+
assert.equal(compacted[0].content, 'You are Kiku AI.');
|
|
43
|
+
assert.equal(compacted[compacted.length - 1].content, 'What is her latest drama in 2026?');
|
|
44
|
+
assert.ok(compacted.some(m => m.content.includes('[CONVERSATION CONTEXT SUMMARY')));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('AgentMemory scores fact relevance and applies recency weighting', () => {
|
|
48
|
+
const memory = new AgentMemory();
|
|
49
|
+
const f1 = memory.addFact('Ju Jingyi won Top Female Singer in 2026', {
|
|
50
|
+
category: 'awards',
|
|
51
|
+
tags: ['singer', 'award', '2026'],
|
|
52
|
+
confidence: 'high'
|
|
53
|
+
});
|
|
54
|
+
const f2 = memory.addFact('Ju Jingyi starred in Legend of White Snake', {
|
|
55
|
+
category: 'drama',
|
|
56
|
+
tags: ['drama', 'white snake'],
|
|
57
|
+
confidence: 'high'
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const relevant = memory.getRelevantFacts('awards 2026', 2);
|
|
61
|
+
assert.ok(relevant.length > 0);
|
|
62
|
+
assert.equal(relevant[0].content, f1.content);
|
|
63
|
+
|
|
64
|
+
const context = memory.buildMemoryContext('white snake drama');
|
|
65
|
+
assert.ok(context.includes('Legend of White Snake'));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('SSOT DataAdapter provides consistent and valid records for MCP', () => {
|
|
69
|
+
const profile = getUnifiedProfile();
|
|
70
|
+
assert.equal(profile.nameCn, '鞠婧祎');
|
|
71
|
+
assert.ok(profile.fandomName.includes('Miju'));
|
|
72
|
+
|
|
73
|
+
const works = getUnifiedWorks();
|
|
74
|
+
assert.ok(works.length > 10);
|
|
75
|
+
assert.ok(works.some(w => w.year >= 2024));
|
|
76
|
+
|
|
77
|
+
const songs = getUnifiedDiscography();
|
|
78
|
+
assert.ok(songs.length > 20);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('EventBus emits AGENT_PLAN lifecycle events for TUI Task Planning', () => {
|
|
82
|
+
const emitted = [];
|
|
83
|
+
const unsub1 = eventBus.on(Events.AGENT_PLAN_START, (d) => emitted.push({ type: 'start', data: d }));
|
|
84
|
+
const unsub2 = eventBus.on(Events.AGENT_PLAN_STEP_UPDATE, (d) => emitted.push({ type: 'update', data: d }));
|
|
85
|
+
const unsub3 = eventBus.on(Events.AGENT_PLAN_COMPLETE, (d) => emitted.push({ type: 'complete', data: d }));
|
|
86
|
+
|
|
87
|
+
eventBus.emit(Events.AGENT_PLAN_START, { planId: 'p1', steps: [{ id: 's1', state: 'pending' }] });
|
|
88
|
+
eventBus.emit(Events.AGENT_PLAN_STEP_UPDATE, { planId: 'p1', stepId: 's1', state: 'running' });
|
|
89
|
+
eventBus.emit(Events.AGENT_PLAN_COMPLETE, { planId: 'p1' });
|
|
90
|
+
|
|
91
|
+
assert.equal(emitted.length, 3);
|
|
92
|
+
assert.equal(emitted[0].type, 'start');
|
|
93
|
+
assert.equal(emitted[1].data.state, 'running');
|
|
94
|
+
assert.equal(emitted[2].type, 'complete');
|
|
95
|
+
|
|
96
|
+
unsub1();
|
|
97
|
+
unsub2();
|
|
98
|
+
unsub3();
|
|
99
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { routeCommand } from '../commands.js';
|
|
5
|
+
|
|
6
|
+
test('TUI prompt reflects offline and online state accurately', async () => {
|
|
7
|
+
|
|
8
|
+
let catalogOnly = true;
|
|
9
|
+
const mockPrompt = {
|
|
10
|
+
isConnected: false,
|
|
11
|
+
modelName: 'gemma-4-e4b',
|
|
12
|
+
renderCalls: 0,
|
|
13
|
+
render() {
|
|
14
|
+
this.renderCalls += 1;
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const mockModelManager = {
|
|
19
|
+
async resolve() {
|
|
20
|
+
return { connected: true, selectedModel: 'google/gemma-4-e4b' };
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const ctx = {
|
|
25
|
+
sub: 'reconnect',
|
|
26
|
+
modelManager: mockModelManager,
|
|
27
|
+
tuiPrompt: mockPrompt,
|
|
28
|
+
client: { provider: 'lmstudio', providerName: 'LM Studio' },
|
|
29
|
+
setCatalogOnlyMode(val) {
|
|
30
|
+
catalogOnly = val;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const res = await routeCommand('/model reconnect', ctx);
|
|
35
|
+
assert.equal(res, 'HANDLED');
|
|
36
|
+
assert.equal(catalogOnly, false);
|
|
37
|
+
assert.equal(mockPrompt.isConnected, true);
|
|
38
|
+
assert.equal(mockPrompt.modelName, 'google/gemma-4-e4b');
|
|
39
|
+
assert.ok(mockPrompt.renderCalls > 0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('TUI prompt stays offline when /model reconnect fails', async () => {
|
|
43
|
+
let catalogOnly = false;
|
|
44
|
+
const mockPrompt = {
|
|
45
|
+
isConnected: true,
|
|
46
|
+
modelName: 'gemma-4-e4b',
|
|
47
|
+
renderCalls: 0,
|
|
48
|
+
render() {
|
|
49
|
+
this.renderCalls += 1;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const mockModelManager = {
|
|
54
|
+
async resolve() {
|
|
55
|
+
return { connected: false, error: 'Connection refused at http://127.0.0.1:1234' };
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const ctx = {
|
|
60
|
+
sub: 'reconnect',
|
|
61
|
+
modelManager: mockModelManager,
|
|
62
|
+
tuiPrompt: mockPrompt,
|
|
63
|
+
client: { provider: 'lmstudio', providerName: 'LM Studio' },
|
|
64
|
+
setCatalogOnlyMode(val) {
|
|
65
|
+
catalogOnly = val;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const res = await routeCommand('/model reconnect', ctx);
|
|
70
|
+
assert.equal(res, 'HANDLED');
|
|
71
|
+
assert.equal(catalogOnly, true);
|
|
72
|
+
assert.equal(mockPrompt.isConnected, false);
|
|
73
|
+
assert.ok(mockPrompt.renderCalls > 0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('Provider switch updates tuiPrompt isConnected and provider info', async () => {
|
|
77
|
+
let catalogOnly = true;
|
|
78
|
+
const mockPrompt = {
|
|
79
|
+
isConnected: false,
|
|
80
|
+
modelName: 'gemma-4-e4b',
|
|
81
|
+
provider: 'LM Studio',
|
|
82
|
+
render() {},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const mockClient = {
|
|
86
|
+
provider: 'lmstudio',
|
|
87
|
+
providerName: 'LM Studio',
|
|
88
|
+
switchProvider(target, opts) {
|
|
89
|
+
return {
|
|
90
|
+
provider: target,
|
|
91
|
+
providerName: 'OpenAI',
|
|
92
|
+
model: opts?.model || 'gpt-4o',
|
|
93
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
94
|
+
hasApiKey: true,
|
|
95
|
+
isLocal: false,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const mockModelManager = {
|
|
101
|
+
client: mockClient,
|
|
102
|
+
requestedModel: 'gpt-4o',
|
|
103
|
+
async resolve() {
|
|
104
|
+
return { connected: true, selectedModel: 'gpt-4o' };
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const ctx = {
|
|
109
|
+
sub: 'use',
|
|
110
|
+
extra: 'openai gpt-4o',
|
|
111
|
+
parts: ['/provider', 'use', 'openai', 'gpt-4o'],
|
|
112
|
+
client: mockClient,
|
|
113
|
+
modelManager: mockModelManager,
|
|
114
|
+
tuiPrompt: mockPrompt,
|
|
115
|
+
setCatalogOnlyMode(val) {
|
|
116
|
+
catalogOnly = val;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const res = await routeCommand('/provider use openai gpt-4o', ctx);
|
|
121
|
+
assert.equal(res, 'HANDLED');
|
|
122
|
+
assert.equal(catalogOnly, false);
|
|
123
|
+
assert.equal(mockPrompt.isConnected, true);
|
|
124
|
+
assert.equal(mockPrompt.provider, 'OpenAI');
|
|
125
|
+
assert.equal(mockPrompt.modelName, 'gpt-4o');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('/model list automatically connects when model is loaded in VRAM without /model reconnect', async () => {
|
|
129
|
+
let catalogOnly = true;
|
|
130
|
+
const mockPrompt = {
|
|
131
|
+
isConnected: false,
|
|
132
|
+
modelName: 'gemma-4-e4b',
|
|
133
|
+
renderCalls: 0,
|
|
134
|
+
render() {
|
|
135
|
+
this.renderCalls += 1;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const mockModelManager = {
|
|
140
|
+
async inspect() {
|
|
141
|
+
return {
|
|
142
|
+
connected: true,
|
|
143
|
+
models: ['google/gemma-4-e4b', 'text-embedding-nomic-embed-text-v1.5'],
|
|
144
|
+
activeLoadedModel: 'google/gemma-4-e4b',
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
async resolve() {
|
|
148
|
+
return {
|
|
149
|
+
connected: true,
|
|
150
|
+
selectedModel: 'google/gemma-4-e4b',
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const ctx = {
|
|
156
|
+
sub: 'list',
|
|
157
|
+
modelManager: mockModelManager,
|
|
158
|
+
tuiPrompt: mockPrompt,
|
|
159
|
+
client: { provider: 'lmstudio', providerName: 'LM Studio', model: 'google/gemma-4-e4b' },
|
|
160
|
+
setCatalogOnlyMode(val) {
|
|
161
|
+
catalogOnly = val;
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const res = await routeCommand('/model list', ctx);
|
|
166
|
+
assert.equal(res, 'HANDLED');
|
|
167
|
+
assert.equal(catalogOnly, false, 'catalogOnlyMode should automatically become false');
|
|
168
|
+
assert.equal(mockPrompt.isConnected, true, 'tuiPrompt should automatically become connected');
|
|
169
|
+
assert.equal(mockPrompt.modelName, 'google/gemma-4-e4b');
|
|
170
|
+
assert.ok(mockPrompt.renderCalls > 0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('/model status automatically connects when model is loaded in VRAM', async () => {
|
|
174
|
+
let catalogOnly = true;
|
|
175
|
+
const mockPrompt = {
|
|
176
|
+
isConnected: false,
|
|
177
|
+
modelName: 'gemma-4-e4b',
|
|
178
|
+
renderCalls: 0,
|
|
179
|
+
render() {
|
|
180
|
+
this.renderCalls += 1;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const mockModelManager = {
|
|
185
|
+
async inspect() {
|
|
186
|
+
return {
|
|
187
|
+
connected: true,
|
|
188
|
+
models: ['google/gemma-4-e4b'],
|
|
189
|
+
activeLoadedModel: 'google/gemma-4-e4b',
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
async resolve() {
|
|
193
|
+
return {
|
|
194
|
+
connected: true,
|
|
195
|
+
selectedModel: 'google/gemma-4-e4b',
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const ctx = {
|
|
201
|
+
sub: 'status',
|
|
202
|
+
modelManager: mockModelManager,
|
|
203
|
+
tuiPrompt: mockPrompt,
|
|
204
|
+
client: { provider: 'lmstudio', providerName: 'LM Studio', model: 'google/gemma-4-e4b' },
|
|
205
|
+
setCatalogOnlyMode(val) {
|
|
206
|
+
catalogOnly = val;
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const res = await routeCommand('/model status', ctx);
|
|
211
|
+
assert.equal(res, 'HANDLED');
|
|
212
|
+
assert.equal(catalogOnly, false);
|
|
213
|
+
assert.equal(mockPrompt.isConnected, true);
|
|
214
|
+
assert.equal(mockPrompt.modelName, 'google/gemma-4-e4b');
|
|
215
|
+
assert.ok(mockPrompt.renderCalls > 0);
|
|
216
|
+
});
|
|
217
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import {
|
|
7
|
+
CRITICAL_RUNTIME_FILES,
|
|
8
|
+
getPackagingIssues,
|
|
9
|
+
isCoveredByFiles,
|
|
10
|
+
matchesFilesPattern,
|
|
11
|
+
} from '../../../scripts/verifyPackaging.js';
|
|
12
|
+
|
|
13
|
+
const REPO_ROOT = fileURLToPath(new URL('../../../', import.meta.url));
|
|
14
|
+
|
|
15
|
+
test('packaging guard covers the websearch crash regression (webSearch.js -> websearch/index.js)', () => {
|
|
16
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8'));
|
|
17
|
+
assert.ok(Array.isArray(pkg.files), 'package.json must define a "files" whitelist');
|
|
18
|
+
assert.ok(
|
|
19
|
+
isCoveredByFiles('server/agent/websearch/index.js', pkg.files),
|
|
20
|
+
'websearch/index.js must be packed or kiku-agent/kiku-mcp crash with ERR_MODULE_NOT_FOUND',
|
|
21
|
+
);
|
|
22
|
+
assert.ok(
|
|
23
|
+
isCoveredByFiles('server/agent/webSearch.js', pkg.files),
|
|
24
|
+
'webSearch.js must be packed',
|
|
25
|
+
);
|
|
26
|
+
for (const rel of CRITICAL_RUNTIME_FILES) {
|
|
27
|
+
assert.ok(
|
|
28
|
+
fs.existsSync(path.join(REPO_ROOT, rel)),
|
|
29
|
+
`critical runtime file must exist on disk: ${rel}`,
|
|
30
|
+
);
|
|
31
|
+
assert.ok(isCoveredByFiles(rel, pkg.files), `critical runtime file must be packed: ${rel}`);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('packaging guard uses future-proof globs instead of per-directory enumeration', () => {
|
|
36
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8'));
|
|
37
|
+
const hasBroad = pkg.files.some((p) => p === 'server/**/*' || p === 'server/**/*.js');
|
|
38
|
+
assert.ok(hasBroad, 'package.json "files" must contain "server/**/*" or "server/**/*.js" so new subdirectories are packed automatically');
|
|
39
|
+
assert.ok(
|
|
40
|
+
matchesFilesPattern('server/agent/futureModule/nested.js', 'server/**/*.js'),
|
|
41
|
+
'broad glob must match future nested modules',
|
|
42
|
+
);
|
|
43
|
+
assert.ok(
|
|
44
|
+
matchesFilesPattern('server/agent/ink/App.jsx', 'server/**/*'),
|
|
45
|
+
'broad glob must match .jsx entries',
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('packaging guard reports zero issues for the release tree', () => {
|
|
50
|
+
const { issues } = getPackagingIssues({ rootDir: REPO_ROOT });
|
|
51
|
+
assert.deepEqual(issues, []);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('packaging guard detects an unpacked runtime file', () => {
|
|
55
|
+
assert.equal(isCoveredByFiles('server/agent/websearch/index.js', ['server/agent/*.js']), false);
|
|
56
|
+
assert.equal(isCoveredByFiles('server/agent/cli.js', ['server/agent/*.js']), true);
|
|
57
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { extname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const agentRoot = fileURLToPath(new URL('../', import.meta.url));
|
|
8
|
+
const ignoredDirectories = new Set(['docs', 'tests']);
|
|
9
|
+
const emojiPattern = /[\p{Extended_Pictographic}\p{Regional_Indicator}\uFE0F\u20E3]/gu;
|
|
10
|
+
const approvedEmoji = new Set(['📖', '🎵', '📢', '🌸', '✨', '💬', '🎨', '📸', '🖼', '🔗']);
|
|
11
|
+
|
|
12
|
+
function collectRuntimeSources(directory, output = []) {
|
|
13
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
14
|
+
if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue;
|
|
15
|
+
const fullPath = join(directory, entry.name);
|
|
16
|
+
if (entry.isDirectory()) collectRuntimeSources(fullPath, output);
|
|
17
|
+
else if (['.js', '.jsx'].includes(extname(entry.name))) output.push(fullPath);
|
|
18
|
+
}
|
|
19
|
+
return output;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('Kiku Agent runtime presentation only uses approved semantic emoji', () => {
|
|
23
|
+
const offenders = [];
|
|
24
|
+
for (const file of collectRuntimeSources(agentRoot)) {
|
|
25
|
+
const matches = readFileSync(file, 'utf8').match(emojiPattern) || [];
|
|
26
|
+
const unapproved = matches.filter(character => !approvedEmoji.has(character));
|
|
27
|
+
if (unapproved.length > 0) {
|
|
28
|
+
offenders.push(`${file.slice(agentRoot.length)}: ${[...new Set(unapproved)].join(' ')}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
assert.deepEqual(offenders, []);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('VirtualScreenBuffer performs flicker-free differential row rendering', async () => {
|
|
36
|
+
const { VirtualScreenBuffer } = await import('../ui.js');
|
|
37
|
+
const writes = [];
|
|
38
|
+
const buffer = new VirtualScreenBuffer(40, 5, s => writes.push(s));
|
|
39
|
+
buffer.clear();
|
|
40
|
+
buffer.setRow(0, 'Header');
|
|
41
|
+
buffer.setRow(1, 'Content A');
|
|
42
|
+
buffer.setRow(2, 'Content B');
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
buffer.render();
|
|
46
|
+
assert.equal(buffer.forceFullRedraw, false);
|
|
47
|
+
assert.ok(writes.length > 0);
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
writes.length = 0;
|
|
51
|
+
buffer.setRow(1, 'Content A Updated');
|
|
52
|
+
assert.notEqual(buffer.rows[1], buffer.prevRows[1]);
|
|
53
|
+
assert.equal(buffer.rows[0], buffer.prevRows[0]);
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
buffer.render();
|
|
57
|
+
assert.equal(buffer.rows[1], buffer.prevRows[1]);
|
|
58
|
+
assert.ok(writes.length > 0);
|
|
59
|
+
assert.ok(!writes[0].includes('\x1b[2K'));
|
|
60
|
+
});
|
|
61
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { SETTINGS_FILE } from '../appPaths.js';
|
|
5
|
+
import {
|
|
6
|
+
savePersistedActiveProvider,
|
|
7
|
+
savePersistedProviderModel,
|
|
8
|
+
getPersistedActiveProvider,
|
|
9
|
+
loadPersistedSettings,
|
|
10
|
+
CLOUD_PROVIDERS,
|
|
11
|
+
} from '../config.js';
|
|
12
|
+
import { LLMClient } from '../llmClient.js';
|
|
13
|
+
|
|
14
|
+
test('Provider and Model settings persist across restarts', () => {
|
|
15
|
+
const originalGroqModel = CLOUD_PROVIDERS.groq.defaultModel;
|
|
16
|
+
const originalLmStudioModel = CLOUD_PROVIDERS.lmstudio.defaultModel;
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
savePersistedActiveProvider('groq', 'openai/gpt-oss-120b');
|
|
20
|
+
savePersistedProviderModel('groq', 'llama-3.3-70b-versatile');
|
|
21
|
+
|
|
22
|
+
assert.equal(getPersistedActiveProvider(), 'groq');
|
|
23
|
+
const settings = loadPersistedSettings();
|
|
24
|
+
assert.equal(settings.activeProvider, 'groq');
|
|
25
|
+
assert.equal(settings.providerModels?.groq, 'llama-3.3-70b-versatile');
|
|
26
|
+
|
|
27
|
+
const client = new LLMClient();
|
|
28
|
+
assert.equal(client.provider, 'groq');
|
|
29
|
+
assert.equal(client.model, 'llama-3.3-70b-versatile');
|
|
30
|
+
|
|
31
|
+
client.switchProvider('openrouter', { model: 'openrouter/auto' });
|
|
32
|
+
assert.equal(getPersistedActiveProvider(), 'openrouter');
|
|
33
|
+
} finally {
|
|
34
|
+
try { if (fs.existsSync(SETTINGS_FILE)) fs.unlinkSync(SETTINGS_FILE); } catch {}
|
|
35
|
+
CLOUD_PROVIDERS.groq.defaultModel = originalGroqModel || 'openai/gpt-oss-120b';
|
|
36
|
+
CLOUD_PROVIDERS.lmstudio.defaultModel = originalLmStudioModel || 'google/gemma-4-e4b';
|
|
37
|
+
loadPersistedSettings();
|
|
38
|
+
}
|
|
39
|
+
});
|