kiku-agent 1.3.2 → 1.3.3
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 +69 -12
- package/package.json +2 -1
- package/server/agent/agentCore.js +319 -46
- package/server/agent/appPaths.js +31 -0
- package/server/agent/audioPlayer.js +339 -12
- package/server/agent/autoContext.js +193 -0
- package/server/agent/catalogEntityResolver.js +170 -28
- package/server/agent/clarifyingQuestions.js +137 -35
- package/server/agent/cli.js +177 -48
- package/server/agent/cloudAdapters.js +116 -0
- package/server/agent/commandRegistry.js +409 -80
- package/server/agent/commands.js +1056 -125
- package/server/agent/config.js +1088 -690
- package/server/agent/contextCompactor.js +213 -21
- package/server/agent/cpuMetrics.js +199 -0
- package/server/agent/engineService.js +591 -0
- package/server/agent/eval/modules/evaluation/runner.js +4 -14
- package/server/agent/eval/modules/evaluation/scoring.js +15 -3
- package/server/agent/eval/modules/server/evalApi.js +5 -5
- package/server/agent/eval/modules/ui/evalUi.js +7 -4
- package/server/agent/eventBus.js +1 -0
- package/server/agent/ink/main.jsx +196 -191
- package/server/agent/jobManager.js +11 -1
- package/server/agent/lanDiscovery.js +721 -23
- package/server/agent/llmClient.js +317 -71
- package/server/agent/lmStudioWakeup.js +517 -65
- package/server/agent/locationService.js +797 -0
- package/server/agent/mcpQuizData.js +509 -0
- package/server/agent/memory/contextAllocator.js +19 -7
- package/server/agent/memory.js +3 -1
- package/server/agent/modelCompatibility.js +352 -0
- package/server/agent/modelDefaults.js +1 -0
- package/server/agent/modelManager.js +46 -2
- package/server/agent/newsWatch.js +9 -3
- package/server/agent/processManager.js +229 -0
- package/server/agent/prompts.js +174 -8
- package/server/agent/routingInterceptor.js +9 -1
- package/server/agent/sanitizer.js +41 -6
- package/server/agent/searchQueryPlanner.js +202 -0
- package/server/agent/session.js +59 -4
- package/server/agent/tests/agentFollowUps.test.js +128 -2
- package/server/agent/tests/agentMusicControl.test.js +245 -0
- package/server/agent/tests/apiRouter.test.js +1 -1
- package/server/agent/tests/autoContext.test.js +176 -0
- package/server/agent/tests/bilibiliEmbed.test.js +45 -0
- package/server/agent/tests/chatHardening.test.js +121 -0
- package/server/agent/tests/chineseWebSearchEscalation.test.js +62 -0
- package/server/agent/tests/clarifyingQuestions.test.js +33 -0
- package/server/agent/tests/cliAutomation.test.js +38 -1
- package/server/agent/tests/cloudProviders.test.js +91 -3
- package/server/agent/tests/commands.test.js +92 -3
- package/server/agent/tests/contextIntelligence.test.js +50 -3
- package/server/agent/tests/cpuMetrics.test.js +101 -0
- package/server/agent/tests/decisionAndSearchEnhancements.test.js +32 -0
- package/server/agent/tests/edgeCasesScenarios.test.js +82 -0
- package/server/agent/tests/engineService.test.js +436 -0
- package/server/agent/tests/engineUnload.test.js +94 -0
- package/server/agent/tests/envCommand.test.js +69 -0
- package/server/agent/tests/evalMetrics.test.js +8 -0
- package/server/agent/tests/fetchApi.test.js +99 -0
- package/server/agent/tests/hybridWebSearch.test.js +2 -0
- package/server/agent/tests/lanDiscovery.test.js +5 -0
- package/server/agent/tests/lanInterfaces.test.js +510 -0
- package/server/agent/tests/lmStudioWakeup.test.js +278 -0
- package/server/agent/tests/locationTool.test.js +278 -0
- package/server/agent/tests/memoryRoute.test.js +173 -0
- package/server/agent/tests/memoryUiNormalization.test.js +75 -0
- package/server/agent/tests/modelCompatibility.test.js +148 -0
- package/server/agent/tests/modelManager.test.js +32 -0
- package/server/agent/tests/musicControlUiBridge.test.js +113 -0
- package/server/agent/tests/nextGenArchitecture.test.js +4 -2
- package/server/agent/tests/openDomainPrompt.test.js +197 -0
- package/server/agent/tests/openDomainSearch.test.js +121 -0
- package/server/agent/tests/processManager.test.js +144 -0
- package/server/agent/tests/providerPersistence.test.js +10 -5
- package/server/agent/tests/quickActionsAndModals.test.js +180 -0
- package/server/agent/tests/searchQueryPlanner.test.js +108 -0
- package/server/agent/tests/securityAuditRemediation.test.js +16 -9
- package/server/agent/tests/securityAuditRemediations2.test.js +154 -0
- package/server/agent/tests/securityHardening.test.js +4 -4
- package/server/agent/tests/sessionAndCopy.test.js +39 -4
- package/server/agent/tests/sidebarControls.test.js +34 -0
- package/server/agent/tests/statefulMemory.test.js +8 -1
- package/server/agent/tests/textProtocolLoop.test.js +71 -0
- package/server/agent/tests/textToolParsing.test.js +48 -0
- package/server/agent/tests/toolRoutingRegressions.test.js +41 -0
- package/server/agent/tests/unifiedEndpoints.test.js +13 -6
- package/server/agent/tests/v110Features.test.js +1 -1
- package/server/agent/tests/vramEstimator.test.js +75 -0
- package/server/agent/tests/webChatDocs.test.js +199 -0
- package/server/agent/tests/webChatInferenceControl.test.js +298 -0
- package/server/agent/tests/webChatInspection.test.js +256 -5
- package/server/agent/tests/webChatMcp.test.js +94 -0
- package/server/agent/tests/webExtract.test.js +23 -0
- package/server/agent/tests/webSources.test.js +46 -12
- package/server/agent/textToolProtocol.js +173 -46
- package/server/agent/toolRouter.js +318 -44
- package/server/agent/tools.js +877 -96
- package/server/agent/ui.js +39 -34
- package/server/agent/vramService.js +35 -54
- package/server/agent/webSearch.js +441 -162
- package/server/agent/websearch/orchestrator.js +15 -5
- package/server/agent/websearch/resilience/cache.js +9 -2
- package/server/agent/websearch/security/ssrf.js +41 -10
- package/server/agent/youtubeAuth.js +5 -20
- package/server/apiRouter.js +5558 -4422
- package/server/mcp/data/discography.js +2 -2
- package/server/mcp/examples/antigravity.example.json +17 -0
- package/server/mcp/handler.js +8 -4
- package/server/mcp/security.js +220 -21
- package/server/mcp/server.js +2 -1
- package/server/mcp/tests/connection.test.js +14 -8
- package/server/mcp/tests/mcpKeyAuth.test.js +156 -0
- package/server/mcp/tools.js +7 -1
- package/server/supabase_migration_prefix.sql +102 -0
- package/server/supabase_schema.sql +18 -18
- package/server/web-chat/core/connection.js +179 -218
- package/server/web-chat/core/conversationContext.js +9 -2
- package/server/web-chat/core/directCatalogEngine.js +52 -28
- package/server/web-chat/core/dramaKnowledge.js +33 -22
- package/server/web-chat/core/followUpEngine.js +735 -81
- package/server/web-chat/core/futureEventGuard.js +5 -1
- package/server/web-chat/core/musicControlAction.js +70 -0
- package/server/web-chat/core/security.js +32 -21
- package/server/web-chat/core/toolInspector.js +6 -6
- package/server/web-chat/core/urlGuard.js +45 -0
- package/server/web-chat/router.js +204 -157
- package/server/web-chat/routes/chatRoute.js +885 -488
- package/server/web-chat/routes/engineRoute.js +270 -0
- package/server/web-chat/routes/exportRoute.js +1 -1
- package/server/web-chat/routes/lanDiscoveryRoute.js +17 -2
- package/server/web-chat/routes/lmStudioLoadRoute.js +172 -0
- package/server/web-chat/routes/mcpRoute.js +386 -0
- package/server/web-chat/routes/memoryRoute.js +253 -0
- package/server/web-chat/routes/providerRoute.js +49 -16
- package/server/web-chat/routes/staticRoute.js +306 -17
- package/server/web-chat/routes/statusRoute.js +194 -73
- package/server/web-chat/routes/suggestRoute.js +11 -3
- package/server/web-chat/routes/verifyRoute.js +26 -4
- package/server/web-chat/server.js +7 -6
- package/server/web-chat/ui/docsBuilder.js +3558 -0
- package/server/web-chat/ui/htmlBuilder.js +12 -0
- package/server/web-chat/ui/scripts/client.js +3401 -1302
- package/server/web-chat/ui/scripts/webchatI18n.js +155 -153
- package/server/web-chat/ui/settingsBuilder.js +7870 -0
- package/server/web-chat/ui/styles/lan-card.css +165 -0
- package/server/web-chat/ui/styles/main.css +4289 -447
- package/server/web-chat/ui/sw.js +64 -0
- package/server/web-chat/ui/templates/body.html +407 -281
- package/src/data/awardsData.js +146 -146
- package/src/data/musicVideoData.js +6 -4
- package/src/lib/vram/estimator.js +195 -19
package/server/agent/session.js
CHANGED
|
@@ -33,9 +33,16 @@ function normalizeStorageKey(value) {
|
|
|
33
33
|
|
|
34
34
|
export class AgentSession {
|
|
35
35
|
constructor(options = {}) {
|
|
36
|
-
|
|
36
|
+
const isTestEnv = process.env.NODE_ENV === 'test'
|
|
37
|
+
|| process.env.npm_lifecycle_event === 'test'
|
|
38
|
+
|| Boolean(process.env.npm_lifecycle_script?.includes('test'));
|
|
39
|
+
this.persistenceEnabled = options.persistence === true
|
|
40
|
+
|| Boolean(options.storageFile)
|
|
41
|
+
|| (options.persistence !== false && !isTestEnv);
|
|
37
42
|
this.id = options.sessionId || `sess_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
38
|
-
this.createdAt = new Date()
|
|
43
|
+
this.createdAt = options.createdAt && !isNaN(new Date(options.createdAt).getTime())
|
|
44
|
+
? new Date(options.createdAt)
|
|
45
|
+
: new Date();
|
|
39
46
|
this.thinkingMode = options.thinkingMode || 'hide';
|
|
40
47
|
const storageKey = normalizeStorageKey(options.storageKey);
|
|
41
48
|
this.storageFile = options.storageFile
|
|
@@ -292,10 +299,54 @@ export class AgentSession {
|
|
|
292
299
|
};
|
|
293
300
|
}
|
|
294
301
|
|
|
302
|
+
_syncDerivedStats() {
|
|
303
|
+
let derivedQueries = 0;
|
|
304
|
+
let derivedToolCalls = 0;
|
|
305
|
+
let derivedDurationMs = 0;
|
|
306
|
+
|
|
307
|
+
for (const msg of this.messages || []) {
|
|
308
|
+
if (msg.role === 'user') derivedQueries++;
|
|
309
|
+
if (typeof msg.durationMs === 'number' && msg.durationMs > 0) {
|
|
310
|
+
derivedDurationMs += msg.durationMs;
|
|
311
|
+
}
|
|
312
|
+
for (const part of msg.parts || []) {
|
|
313
|
+
if (part.type === 'tool') {
|
|
314
|
+
derivedToolCalls++;
|
|
315
|
+
if (typeof part.durationMs === 'number' && part.durationMs > 0) {
|
|
316
|
+
derivedDurationMs += part.durationMs;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!this.stats.totalQueries && derivedQueries > 0) {
|
|
323
|
+
this.stats.totalQueries = derivedQueries;
|
|
324
|
+
}
|
|
325
|
+
if (derivedToolCalls > (this.stats.totalToolCalls || 0)) {
|
|
326
|
+
this.stats.totalToolCalls = derivedToolCalls;
|
|
327
|
+
}
|
|
328
|
+
if (derivedDurationMs > (this.stats.totalDurationMs || 0)) {
|
|
329
|
+
this.stats.totalDurationMs = derivedDurationMs;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
getTotalToolCalls() {
|
|
334
|
+
const statsCount = Number(this.stats?.totalToolCalls) || 0;
|
|
335
|
+
let messageToolCount = 0;
|
|
336
|
+
for (const msg of this.messages || []) {
|
|
337
|
+
for (const part of msg.parts || []) {
|
|
338
|
+
if (part.type === 'tool') messageToolCount++;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return Math.max(statsCount, messageToolCount);
|
|
342
|
+
}
|
|
343
|
+
|
|
295
344
|
restoreFromSnapshot(sessionData) {
|
|
296
345
|
if (!sessionData) return false;
|
|
297
346
|
this.id = sessionData.id || `sess_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
298
|
-
this.createdAt = sessionData.createdAt
|
|
347
|
+
this.createdAt = sessionData.createdAt && !isNaN(new Date(sessionData.createdAt).getTime())
|
|
348
|
+
? new Date(sessionData.createdAt)
|
|
349
|
+
: new Date();
|
|
299
350
|
this.thinkingMode = sessionData.thinkingMode || 'hide';
|
|
300
351
|
this.messages = Array.isArray(sessionData.messages) ? sessionData.messages : [];
|
|
301
352
|
this.stats = {
|
|
@@ -309,6 +360,7 @@ export class AgentSession {
|
|
|
309
360
|
totalCostUsd: 0,
|
|
310
361
|
...(sessionData.stats || {})
|
|
311
362
|
};
|
|
363
|
+
this._syncDerivedStats();
|
|
312
364
|
return true;
|
|
313
365
|
}
|
|
314
366
|
|
|
@@ -337,7 +389,9 @@ export class AgentSession {
|
|
|
337
389
|
const sessionData = JSON.parse(data);
|
|
338
390
|
|
|
339
391
|
this.id = sessionData.id;
|
|
340
|
-
this.createdAt = new Date(sessionData.createdAt)
|
|
392
|
+
this.createdAt = sessionData.createdAt && !isNaN(new Date(sessionData.createdAt).getTime())
|
|
393
|
+
? new Date(sessionData.createdAt)
|
|
394
|
+
: new Date();
|
|
341
395
|
this.thinkingMode = sessionData.thinkingMode || 'hide';
|
|
342
396
|
this.messages = sessionData.messages || [];
|
|
343
397
|
this.stats = {
|
|
@@ -351,6 +405,7 @@ export class AgentSession {
|
|
|
351
405
|
totalCostUsd: 0,
|
|
352
406
|
...(sessionData.stats || {})
|
|
353
407
|
};
|
|
408
|
+
this._syncDerivedStats();
|
|
354
409
|
return true;
|
|
355
410
|
} catch {
|
|
356
411
|
return false;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { extractAgentFollowUpQuestions, generateDynamicFollowUps, normalizeSuggestionKey, dedupeSuggestions } from '../../web-chat/core/followUpEngine.js';
|
|
3
|
+
import { extractAgentFollowUpQuestions, generateDynamicFollowUps, normalizeSuggestionKey, dedupeSuggestions, isOffTopicChat, buildOffTopicFollowUps } from '../../web-chat/core/followUpEngine.js';
|
|
4
4
|
import { executeDirectCatalogKnowledge } from '../../web-chat/core/directCatalogEngine.js';
|
|
5
5
|
|
|
6
6
|
test('extractAgentFollowUpQuestions extracts [SUGGESTED_FOLLOW_UPS] block and strips it from cleanText', () => {
|
|
@@ -113,7 +113,6 @@ test('generateDynamicFollowUps: rotation keeps proposing fresh questions across
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
});
|
|
116
|
-
|
|
117
116
|
test('generateDynamicFollowUps: exhausted pool still returns suggestions (recycled)', () => {
|
|
118
117
|
const seen = new Set();
|
|
119
118
|
for (let turn = 0; turn < 6; turn++) {
|
|
@@ -125,3 +124,130 @@ test('generateDynamicFollowUps: exhausted pool still returns suggestions (recycl
|
|
|
125
124
|
assert.ok(round.length >= 2, `exhausted turn ${turn} returned too few suggestions`);
|
|
126
125
|
}
|
|
127
126
|
});
|
|
127
|
+
|
|
128
|
+
test('generateDynamicFollowUps: off-topic chats stay on the user topic, never Ju Jingyi', () => {
|
|
129
|
+
assert.equal(isOffTopicChat('วิธีทำต้มยำกุ้งน้ำข้น', []), true);
|
|
130
|
+
assert.equal(isOffTopicChat('จวีจิ้งอีเป็นใคร', []), false);
|
|
131
|
+
assert.equal(isOffTopicChat('อากาศวันนี้เป็นอย่างไร', [{ name: 'web_search', result: { status: 'success' } }]), true);
|
|
132
|
+
|
|
133
|
+
const th = generateDynamicFollowUps({ query: 'วิธีทำต้มยำกุ้งน้ำข้น', answer: 'ต้มยำกุ้งเป็นอาหารไทย', tools: [] });
|
|
134
|
+
assert.ok(th.length >= 2);
|
|
135
|
+
for (const s of th) {
|
|
136
|
+
assert.ok(s.query.includes('ต้มยำกุ้ง'), `off-topic chip must stay on topic: ${s.query}`);
|
|
137
|
+
assert.ok(!/จวีจิ้งอี|ju\s*jingyi|鞠婧祎/i.test(s.query), `off-topic chip must not mention Ju Jingyi: ${s.query}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const en = generateDynamicFollowUps({ query: 'What is quantum computing?', answer: 'Quantum computing uses qubits', tools: [] });
|
|
141
|
+
assert.ok(en.length >= 2);
|
|
142
|
+
assert.ok(en[0].query.includes('quantum computing'));
|
|
143
|
+
|
|
144
|
+
const onTopic = generateDynamicFollowUps({ query: 'จวีจิ้งอีเป็นใคร', answer: '', tools: [] });
|
|
145
|
+
assert.ok(onTopic.some(s => /จวีจิ้งอี/.test(s.query)), 'on-topic queries keep Ju Jingyi suggestions');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('generateDynamicFollowUps: multilingual chips for zh and en', () => {
|
|
149
|
+
const zh = generateDynamicFollowUps({
|
|
150
|
+
query: '鞠婧祎的代表作品有哪些?',
|
|
151
|
+
answer: '主要作品包括《花间令》、《芸汐传》',
|
|
152
|
+
tools: [{ name: 'search_works' }],
|
|
153
|
+
lang: 'zh'
|
|
154
|
+
});
|
|
155
|
+
assert.ok(zh.length >= 2);
|
|
156
|
+
assert.ok(zh.some(s => /[\u4E00-\u9FFF]/.test(s.query)), 'Should contain Chinese characters');
|
|
157
|
+
|
|
158
|
+
const en = generateDynamicFollowUps({
|
|
159
|
+
query: 'Tell me about Ju Jingyi dramas',
|
|
160
|
+
answer: 'Her famous works include In Blossom and Legend of Yunxi',
|
|
161
|
+
tools: [{ name: 'search_works' }],
|
|
162
|
+
lang: 'en'
|
|
163
|
+
});
|
|
164
|
+
assert.ok(en.length >= 2);
|
|
165
|
+
assert.ok(en.every(s => !/[\u0E00-\u0E7F]/.test(s.query)), 'English mode should not output Thai text');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('generateDynamicFollowUps: multi-topic score ranking synthesizes blended suggestions', () => {
|
|
169
|
+
const hybrid = generateDynamicFollowUps({
|
|
170
|
+
query: 'เพลงประกอบซีรีส์ ฮวาเจียนหลิง มีเพลงอะไรบ้าง',
|
|
171
|
+
answer: 'มีเพลง Dewdrops (露水) และเพลงอื่นๆ ใน In Blossom OST',
|
|
172
|
+
tools: [{ name: 'search_works' }, { name: 'search_discography' }],
|
|
173
|
+
lang: 'th'
|
|
174
|
+
});
|
|
175
|
+
assert.ok(hybrid.length >= 3, 'Should produce at least 3 suggestions');
|
|
176
|
+
const queriesText = hybrid.map(s => s.query).join(' ');
|
|
177
|
+
assert.ok(queriesText.includes('ซีรีส์') || queriesText.includes('ฮวาเจียนหลิง') || queriesText.includes('หยางไฉ่เวย'), 'Should contain drama chips');
|
|
178
|
+
assert.ok(queriesText.includes('เพลง') || queriesText.includes('OST') || queriesText.includes('FIND THE WAY'), 'Should contain music chips');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('generateDynamicFollowUps: handles expanded catalog works (The Eternal Fragrance, Sword & Fairy 4)', () => {
|
|
182
|
+
const fragrance = generateDynamicFollowUps({
|
|
183
|
+
query: 'ซีรีส์ พันกลิ่นบุปผา The Eternal Fragrance',
|
|
184
|
+
answer: 'รับบทเป็น เจียง黎非 (Jiang Lifei) ซีรีส์ Youku S+',
|
|
185
|
+
tools: [{ name: 'search_works' }]
|
|
186
|
+
});
|
|
187
|
+
assert.ok(fragrance.some(s => s.query.includes('พันกลิ่นบุปผา') || s.query.includes('Eternal Fragrance')));
|
|
188
|
+
|
|
189
|
+
const sword = generateDynamicFollowUps({
|
|
190
|
+
query: 'ซีรีส์ เซียนกระบี่พิชิตมาร 4 Sword and Fairy',
|
|
191
|
+
answer: 'รับบทเป็น หานหลิงซา ร้องเพลง 如纱 (Ru Sha)',
|
|
192
|
+
tools: [{ name: 'search_works' }]
|
|
193
|
+
});
|
|
194
|
+
assert.ok(sword.some(s => s.query.includes('เซียนกระบี่') || s.query.includes('หานหลิงซา') || s.query.includes('Ru Sha')));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('buildOffTopicFollowUps: generates specialized calculus & limits suggestions without broken LaTeX truncation', () => {
|
|
198
|
+
const mathQuery = 'จงหาค่าของลิมิต $\\lim_{x \\to 2} \\frac{x^2 - 4}{x^2 + x - 6}$';
|
|
199
|
+
const followUps = buildOffTopicFollowUps(mathQuery, 'th');
|
|
200
|
+
|
|
201
|
+
assert.ok(followUps.length >= 3, 'Should produce at least 3 follow-ups');
|
|
202
|
+
|
|
203
|
+
// Verify specialized limit/calculus content
|
|
204
|
+
const allText = followUps.map(f => `${f.label} ${f.query}`).join(' ');
|
|
205
|
+
assert.ok(/โลปีตาล|L'Hôpital|แยกตัวประกอบ|Factoring|0\/0|ความต่อเนื่อง|Continuity/i.test(allText),
|
|
206
|
+
'Should contain specialized calculus concepts instead of generic templates');
|
|
207
|
+
|
|
208
|
+
// Assert NO broken sliced LaTeX math fragments (e.g. $\lim_{x \to 2} \frac{x^2 -)
|
|
209
|
+
for (const item of followUps) {
|
|
210
|
+
assert.ok(!item.query.includes('x^2 -ให้'), `Must not contain sliced formula syntax: ${item.query}`);
|
|
211
|
+
assert.ok(!item.label.includes('x^2 -ให้'), `Label must not contain sliced formula syntax: ${item.label}`);
|
|
212
|
+
assert.ok(!/อธิบายเรื่องจงหาค่าของ/i.test(item.label), `Must strip Thai question verbs from labels: ${item.label}`);
|
|
213
|
+
|
|
214
|
+
// Check LaTeX delimiter balancing if dollar signs are present
|
|
215
|
+
const dollarCount = (item.query.match(/\$/g) || []).length;
|
|
216
|
+
assert.equal(dollarCount % 2, 0, `Query has unbalanced $ delimiters: ${item.query}`);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('buildOffTopicFollowUps: generates specialized programming suggestions for Kotlin and Python', () => {
|
|
221
|
+
const kotlinQuery = 'ช่วยสอนฉันเขียนโค้ด kotlin หน่อยง่ายๆนะ';
|
|
222
|
+
const kotlinFollowUps = buildOffTopicFollowUps(kotlinQuery, 'th');
|
|
223
|
+
|
|
224
|
+
assert.ok(kotlinFollowUps.length >= 3);
|
|
225
|
+
const kotlinText = kotlinFollowUps.map(f => `${f.label} ${f.query}`).join(' ');
|
|
226
|
+
assert.ok(/Null Safety|Coroutines|Scope functions|Data Class/i.test(kotlinText),
|
|
227
|
+
'Should propose specialized Kotlin language topics');
|
|
228
|
+
|
|
229
|
+
const pythonQuery = 'สอนเขียนโค้ด Python เบื้องต้นหน่อย';
|
|
230
|
+
const pythonFollowUps = buildOffTopicFollowUps(pythonQuery, 'th');
|
|
231
|
+
assert.ok(pythonFollowUps.length >= 3);
|
|
232
|
+
const pythonText = pythonFollowUps.map(f => `${f.label} ${f.query}`).join(' ');
|
|
233
|
+
assert.ok(/List Comprehension|OOP|Class|Virtual Environment|PIP/i.test(pythonText),
|
|
234
|
+
'Should propose specialized Python topics');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('buildOffTopicFollowUps: gracefully sanitizes and balances long math queries', () => {
|
|
238
|
+
const longMathQuery = 'คำนวณค่าของ $f(x) = \\int_{0}^{\\pi} \\sin(x) \\cos(x) dx$ และตรวจสอบความถูกต้อง';
|
|
239
|
+
const followUps = buildOffTopicFollowUps(longMathQuery, 'th');
|
|
240
|
+
|
|
241
|
+
assert.ok(followUps.length >= 3);
|
|
242
|
+
const text = followUps.map(f => f.query).join(' ');
|
|
243
|
+
assert.ok(/อินทิกรัล|Integral|Substitution|Calculus/i.test(text),
|
|
244
|
+
'Integral query should match integral or calculus specialization');
|
|
245
|
+
|
|
246
|
+
for (const f of followUps) {
|
|
247
|
+
const dollars = (f.query.match(/\$/g) || []).length;
|
|
248
|
+
assert.equal(dollars % 2, 0, `Unbalanced $ in follow-up: ${f.query}`);
|
|
249
|
+
assert.ok(!f.query.endsWith('\\'), `Query ends with dangling backslash: ${f.query}`);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { audioPlayer, resolveCatalogSong } from '../audioPlayer.js';
|
|
4
|
+
import { toolHandlers, toolsSchema } from '../tools.js';
|
|
5
|
+
import { dynamicToolRouter } from '../toolRouter.js';
|
|
6
|
+
|
|
7
|
+
test('resolveCatalogSong correctly identifies songs across languages and aliases', () => {
|
|
8
|
+
const s1 = resolveCatalogSong('Heartbeat Alert');
|
|
9
|
+
assert.ok(s1);
|
|
10
|
+
assert.equal(s1.youtubeId, '5MLPdCmnlmE');
|
|
11
|
+
|
|
12
|
+
const s2 = resolveCatalogSong('恋爱告急');
|
|
13
|
+
assert.ok(s2);
|
|
14
|
+
assert.equal(s2.youtubeId, '5MLPdCmnlmE');
|
|
15
|
+
|
|
16
|
+
const s3 = resolveCatalogSong('FIND THE WAY');
|
|
17
|
+
assert.ok(s3);
|
|
18
|
+
assert.equal(s3.youtubeId, '9AeErG03SZw');
|
|
19
|
+
|
|
20
|
+
const s4 = resolveCatalogSong('In Blossom');
|
|
21
|
+
assert.ok(s4);
|
|
22
|
+
assert.ok(s4.youtubeId);
|
|
23
|
+
|
|
24
|
+
const rand = resolveCatalogSong('random');
|
|
25
|
+
assert.ok(rand);
|
|
26
|
+
assert.ok(rand.youtubeId);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('resolveCatalogSong matches long composite queries bidirectionally', () => {
|
|
30
|
+
const s1 = resolveCatalogSong('รักกลางสายฝน (雨爱) / Yu Ai (Rain Love)');
|
|
31
|
+
assert.ok(s1);
|
|
32
|
+
assert.equal(s1.youtubeId, 'OMLpNyp8T7Q');
|
|
33
|
+
|
|
34
|
+
const s2 = resolveCatalogSong('雨爱');
|
|
35
|
+
assert.ok(s2);
|
|
36
|
+
assert.equal(s2.youtubeId, 'OMLpNyp8T7Q');
|
|
37
|
+
|
|
38
|
+
assert.equal(resolveCatalogSong('zzzz-not-a-song-xyz'), null);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('audioPlayer controls volume, seek, repeat, and shuffle states', () => {
|
|
42
|
+
assert.equal(typeof audioPlayer.setVolume, 'function');
|
|
43
|
+
const vMsg = audioPlayer.setVolume(75);
|
|
44
|
+
assert.equal(audioPlayer.volume, 75);
|
|
45
|
+
assert.match(vMsg, /75%/);
|
|
46
|
+
|
|
47
|
+
audioPlayer.setVolume(150);
|
|
48
|
+
assert.equal(audioPlayer.volume, 100);
|
|
49
|
+
|
|
50
|
+
audioPlayer.setVolume(-20);
|
|
51
|
+
assert.equal(audioPlayer.volume, 0);
|
|
52
|
+
|
|
53
|
+
assert.equal(typeof audioPlayer.seek, 'function');
|
|
54
|
+
const sMsg = audioPlayer.seek(125);
|
|
55
|
+
assert.equal(audioPlayer._currentOffsetSeconds, 125);
|
|
56
|
+
assert.match(sMsg, /2:05/);
|
|
57
|
+
|
|
58
|
+
assert.equal(typeof audioPlayer.setRepeatMode, 'function');
|
|
59
|
+
audioPlayer.setRepeatMode('one');
|
|
60
|
+
assert.equal(audioPlayer.repeatMode, 'one');
|
|
61
|
+
audioPlayer.setRepeatMode('all');
|
|
62
|
+
assert.equal(audioPlayer.repeatMode, 'all');
|
|
63
|
+
audioPlayer.setRepeatMode('off');
|
|
64
|
+
assert.equal(audioPlayer.repeatMode, 'off');
|
|
65
|
+
|
|
66
|
+
assert.equal(typeof audioPlayer.setShuffleMode, 'function');
|
|
67
|
+
audioPlayer.setShuffleMode(true);
|
|
68
|
+
assert.equal(audioPlayer.isShuffle, true);
|
|
69
|
+
audioPlayer.setShuffleMode(false);
|
|
70
|
+
assert.equal(audioPlayer.isShuffle, false);
|
|
71
|
+
|
|
72
|
+
const state = audioPlayer.getFullState();
|
|
73
|
+
assert.equal(typeof state.volume, 'number');
|
|
74
|
+
assert.equal(typeof state.repeatMode, 'string');
|
|
75
|
+
assert.equal(typeof state.isShuffle, 'boolean');
|
|
76
|
+
assert.equal(typeof state.currentTime, 'number');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('music tool handlers emit standard action payloads with song metadata', async () => {
|
|
80
|
+
const { audioPlayer: player } = await import('../audioPlayer.js');
|
|
81
|
+
const originalCreateStream = player._createStream;
|
|
82
|
+
|
|
83
|
+
player._createStream = async function () {
|
|
84
|
+
player.isPlaying = true;
|
|
85
|
+
player.isPaused = false;
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
player.stop();
|
|
89
|
+
|
|
90
|
+
const playRes = await toolHandlers.play_music({ query: 'Heartbeat Alert' });
|
|
91
|
+
assert.equal(playRes.status, 'success');
|
|
92
|
+
assert.equal(playRes.action, 'play');
|
|
93
|
+
assert.ok(playRes.song);
|
|
94
|
+
assert.equal(playRes.song.youtubeId, '5MLPdCmnlmE');
|
|
95
|
+
assert.equal(playRes.localPlayback, true);
|
|
96
|
+
assert.ok(playRes.youtubeUrl.includes('5MLPdCmnlmE'));
|
|
97
|
+
|
|
98
|
+
const pauseRes = await toolHandlers.pause_music({});
|
|
99
|
+
assert.equal(pauseRes.status, 'success');
|
|
100
|
+
assert.equal(pauseRes.action, 'pause');
|
|
101
|
+
|
|
102
|
+
const resumeRes = await toolHandlers.resume_music({});
|
|
103
|
+
assert.equal(resumeRes.status, 'success');
|
|
104
|
+
assert.equal(resumeRes.action, 'resume');
|
|
105
|
+
|
|
106
|
+
const stopRes = await toolHandlers.stop_music({});
|
|
107
|
+
assert.equal(stopRes.status, 'success');
|
|
108
|
+
assert.equal(stopRes.action, 'stop');
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
player.queue = ['Heartbeat Alert', 'FIND THE WAY'];
|
|
112
|
+
player.currentIndex = 0;
|
|
113
|
+
player.currentTrack = 'Heartbeat Alert';
|
|
114
|
+
player.isPlaying = true;
|
|
115
|
+
player.isPaused = false;
|
|
116
|
+
|
|
117
|
+
const nextRes = await toolHandlers.next_track({});
|
|
118
|
+
assert.equal(nextRes.status, 'success');
|
|
119
|
+
assert.equal(nextRes.action, 'next');
|
|
120
|
+
|
|
121
|
+
const prevRes = await toolHandlers.prev_track({});
|
|
122
|
+
assert.equal(prevRes.status, 'success');
|
|
123
|
+
assert.equal(prevRes.action, 'prev');
|
|
124
|
+
|
|
125
|
+
player.stop();
|
|
126
|
+
} finally {
|
|
127
|
+
player._createStream = originalCreateStream;
|
|
128
|
+
player.stop();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const volRes = await toolHandlers.set_volume({ volume: 60 });
|
|
132
|
+
assert.equal(volRes.status, 'success');
|
|
133
|
+
assert.equal(volRes.action, 'set_volume');
|
|
134
|
+
assert.equal(volRes.volume, 60);
|
|
135
|
+
|
|
136
|
+
const seekSecRes = await toolHandlers.seek_music({ seconds: 90 });
|
|
137
|
+
assert.equal(seekSecRes.status, 'success');
|
|
138
|
+
assert.equal(seekSecRes.action, 'seek');
|
|
139
|
+
assert.equal(seekSecRes.seconds, 90);
|
|
140
|
+
|
|
141
|
+
const seekStrRes = await toolHandlers.seek_music({ position: '01:30' });
|
|
142
|
+
assert.equal(seekStrRes.status, 'success');
|
|
143
|
+
assert.equal(seekStrRes.action, 'seek');
|
|
144
|
+
assert.equal(seekStrRes.seconds, 90);
|
|
145
|
+
|
|
146
|
+
const repRes = await toolHandlers.set_repeat_mode({ mode: 'one' });
|
|
147
|
+
assert.equal(repRes.status, 'success');
|
|
148
|
+
assert.equal(repRes.action, 'set_repeat');
|
|
149
|
+
assert.equal(repRes.mode, 'one');
|
|
150
|
+
|
|
151
|
+
const shufRes = await toolHandlers.set_shuffle_mode({ enabled: true });
|
|
152
|
+
assert.equal(shufRes.status, 'success');
|
|
153
|
+
assert.equal(shufRes.action, 'set_shuffle');
|
|
154
|
+
assert.equal(shufRes.enabled, true);
|
|
155
|
+
|
|
156
|
+
const vidRes = await toolHandlers.toggle_video_mode({ showVideo: true });
|
|
157
|
+
assert.equal(vidRes.status, 'success');
|
|
158
|
+
assert.equal(vidRes.action, 'toggle_video');
|
|
159
|
+
assert.equal(vidRes.isVideoOpen, true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('music tool handlers report honest errors on no-ops instead of phantom success', async () => {
|
|
163
|
+
const { audioPlayer: player } = await import('../audioPlayer.js');
|
|
164
|
+
player.stop();
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
const pauseIdle = await toolHandlers.pause_music({});
|
|
168
|
+
assert.equal(pauseIdle.status, 'error');
|
|
169
|
+
|
|
170
|
+
const resumeIdle = await toolHandlers.resume_music({});
|
|
171
|
+
assert.equal(resumeIdle.status, 'error');
|
|
172
|
+
|
|
173
|
+
const muteIdle = await toolHandlers.mute_music({});
|
|
174
|
+
assert.equal(muteIdle.status, 'error');
|
|
175
|
+
|
|
176
|
+
const unmuteIdle = await toolHandlers.unmute_music({});
|
|
177
|
+
assert.equal(unmuteIdle.status, 'error');
|
|
178
|
+
|
|
179
|
+
const nextIdle = await toolHandlers.next_track({});
|
|
180
|
+
assert.equal(nextIdle.status, 'error');
|
|
181
|
+
|
|
182
|
+
const prevIdle = await toolHandlers.prev_track({});
|
|
183
|
+
assert.equal(prevIdle.status, 'error');
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
const playEmpty = await toolHandlers.play_music({ query: ' ' });
|
|
187
|
+
assert.equal(playEmpty.status, 'error');
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
const originalCreateStream = player._createStream;
|
|
191
|
+
player._createStream = async function () {
|
|
192
|
+
|
|
193
|
+
player._cleanup();
|
|
194
|
+
player.isPlaying = false;
|
|
195
|
+
player.isPaused = false;
|
|
196
|
+
player._removeCurrentFromQueue();
|
|
197
|
+
throw new Error('No playable audio source found');
|
|
198
|
+
};
|
|
199
|
+
try {
|
|
200
|
+
const playFail = await toolHandlers.play_music({ query: 'Heartbeat Alert' });
|
|
201
|
+
assert.equal(playFail.status, 'success');
|
|
202
|
+
assert.equal(playFail.localPlayback, false);
|
|
203
|
+
assert.ok(playFail.playbackError);
|
|
204
|
+
assert.ok(playFail.youtubeUrl);
|
|
205
|
+
assert.equal(player.isPlaying, false);
|
|
206
|
+
assert.equal(player.currentTrack, null);
|
|
207
|
+
} finally {
|
|
208
|
+
player._createStream = originalCreateStream;
|
|
209
|
+
player.stop();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('new music tools are registered in toolsSchema', () => {
|
|
214
|
+
const schemaNames = new Set(toolsSchema.map(t => t.function?.name));
|
|
215
|
+
assert.ok(schemaNames.has('set_volume'));
|
|
216
|
+
assert.ok(schemaNames.has('seek_music'));
|
|
217
|
+
assert.ok(schemaNames.has('set_repeat_mode'));
|
|
218
|
+
assert.ok(schemaNames.has('set_shuffle_mode'));
|
|
219
|
+
assert.ok(schemaNames.has('toggle_video_mode'));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('toolRouter routes conversational music commands to music tools', () => {
|
|
223
|
+
const queries = [
|
|
224
|
+
'ช่วยเปิดเพลง Heartbeat Alert หน่อย',
|
|
225
|
+
'หยุดเพลงก่อน',
|
|
226
|
+
'เล่นเพลงต่อ',
|
|
227
|
+
'ปรับเสียงเป็น 80%',
|
|
228
|
+
'ข้ามไปเพลงถัดไป',
|
|
229
|
+
'ย้อนกลับไปเพลงก่อนหน้า',
|
|
230
|
+
'วนซ้ำเพลงนี้',
|
|
231
|
+
'เปิดโหมดสุ่มเพลง',
|
|
232
|
+
'ขอเปิด MV ดูหน่อย',
|
|
233
|
+
'กรอไปที่นาทีที่ 1:30',
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
for (const q of queries) {
|
|
237
|
+
const routed = dynamicToolRouter.routeToolsForQuery(q);
|
|
238
|
+
const names = new Set(routed.map(t => t.function?.name));
|
|
239
|
+
const hasMusicTool = names.has('play_music') || names.has('pause_music') ||
|
|
240
|
+
names.has('set_volume') || names.has('next_track') || names.has('prev_track') ||
|
|
241
|
+
names.has('seek_music') || names.has('set_repeat_mode') || names.has('set_shuffle_mode') ||
|
|
242
|
+
names.has('toggle_video_mode');
|
|
243
|
+
assert.ok(hasMusicTool, `Query "${q}" should route to at least one music playback tool`);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
@@ -65,7 +65,7 @@ test('REST API does not grant VIP rate limits to forged kiku_ keys', async () =>
|
|
|
65
65
|
test('REST API isolates bounded rate-limit probes from normal client traffic', async () => {
|
|
66
66
|
await withApiServer(async baseUrl => {
|
|
67
67
|
const testIp = `198.51.100.${Math.floor(Math.random() * 80) + 120}`;
|
|
68
|
-
const headers = { 'X-Forwarded-For': testIp };
|
|
68
|
+
const headers = { 'X-Forwarded-For': testIp, 'x-kiku-force-production': 'false' };
|
|
69
69
|
const probeUrl = `${baseUrl}/api/v1/health?testRateLimit=2`;
|
|
70
70
|
|
|
71
71
|
assert.equal((await fetch(probeUrl, { headers })).status, 200);
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
MODEL_FOLLOW_CTX_CEILING,
|
|
6
|
+
ensureModelContextFollow,
|
|
7
|
+
parseAutoContextFlag,
|
|
8
|
+
pickFittingLoadContext,
|
|
9
|
+
resolveModelFollowContext,
|
|
10
|
+
} from '../autoContext.js';
|
|
11
|
+
import { getSettingsHtml } from '../../web-chat/ui/settingsBuilder.js';
|
|
12
|
+
|
|
13
|
+
test('parseAutoContextFlag accepts boolean-ish inputs with a safe default', () => {
|
|
14
|
+
assert.equal(parseAutoContextFlag(true), true);
|
|
15
|
+
assert.equal(parseAutoContextFlag('false'), false);
|
|
16
|
+
assert.equal(parseAutoContextFlag('0'), false);
|
|
17
|
+
assert.equal(parseAutoContextFlag('auto'), true);
|
|
18
|
+
assert.equal(parseAutoContextFlag(undefined), false);
|
|
19
|
+
assert.equal(parseAutoContextFlag(undefined, true), true);
|
|
20
|
+
assert.equal(parseAutoContextFlag('bogus', true), true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('resolveModelFollowContext prefers the architectural model max', () => {
|
|
24
|
+
const maxed = resolveModelFollowContext({ modelMaxContext: 131072, loadedContext: 32768 });
|
|
25
|
+
assert.equal(maxed.target, 131072);
|
|
26
|
+
assert.equal(maxed.source, 'model-max');
|
|
27
|
+
|
|
28
|
+
const small = resolveModelFollowContext({ modelMaxContext: 8192, loadedContext: 32768 });
|
|
29
|
+
assert.equal(small.target, 8192);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('resolveModelFollowContext falls back to loaded context, then the safe default', () => {
|
|
33
|
+
const fromLoaded = resolveModelFollowContext({ loadedContext: 16384 });
|
|
34
|
+
assert.equal(fromLoaded.target, 16384);
|
|
35
|
+
assert.equal(fromLoaded.source, 'loaded-context');
|
|
36
|
+
|
|
37
|
+
const fallback = resolveModelFollowContext({});
|
|
38
|
+
assert.equal(fallback.target, 32768);
|
|
39
|
+
assert.equal(fallback.source, 'fallback');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('resolveModelFollowContext respects ceiling and floor bounds', () => {
|
|
43
|
+
const capped = resolveModelFollowContext({ modelMaxContext: 1000000 });
|
|
44
|
+
assert.equal(capped.target, MODEL_FOLLOW_CTX_CEILING);
|
|
45
|
+
assert.equal(capped.cappedByCeiling, true);
|
|
46
|
+
|
|
47
|
+
const floored = resolveModelFollowContext({ modelMaxContext: 512 });
|
|
48
|
+
assert.ok(floored.target >= 1024);
|
|
49
|
+
assert.equal(floored.raisedToFloor, true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('pickFittingLoadContext walks the ladder down to the first VRAM-safe rung', async () => {
|
|
53
|
+
const estimateFn = async ({ contextLength }) => ({
|
|
54
|
+
classification: { zone: contextLength <= 32768 ? 'GREEN' : 'RED' },
|
|
55
|
+
});
|
|
56
|
+
const fit = await pickFittingLoadContext({ model: 'google/gemma-4-e4b', modelMax: 131072, estimateFn });
|
|
57
|
+
assert.equal(fit.ctx, 32768);
|
|
58
|
+
assert.equal(fit.zone, 'GREEN');
|
|
59
|
+
assert.equal(fit.vramGuarded, true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('pickFittingLoadContext tolerates unknown hardware only at or below the safe default', async () => {
|
|
63
|
+
const estimateFn = async () => ({ classification: { zone: 'UNKNOWN' } });
|
|
64
|
+
const fit = await pickFittingLoadContext({ model: 'google/gemma-4-e4b', modelMax: 131072, estimateFn });
|
|
65
|
+
assert.equal(fit.ctx, 32768);
|
|
66
|
+
assert.equal(fit.vramGuarded, false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('pickFittingLoadContext settles on the lowest rung when everything is RED', async () => {
|
|
70
|
+
const estimateFn = async () => ({ classification: { zone: 'RED' } });
|
|
71
|
+
const fit = await pickFittingLoadContext({ model: 'google/gemma-4-e4b', modelMax: 131072, estimateFn });
|
|
72
|
+
assert.equal(fit.ctx, 4096);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('ensureModelContextFollow skips reload when the loaded context is already optimal', async () => {
|
|
76
|
+
let loads = 0;
|
|
77
|
+
let unloads = 0;
|
|
78
|
+
const out = await ensureModelContextFollow({
|
|
79
|
+
model: 'google/gemma-4-e4b',
|
|
80
|
+
probeFn: async () => ({
|
|
81
|
+
reachable: true,
|
|
82
|
+
loadedModel: 'google/gemma-4-e4b',
|
|
83
|
+
loadedContextLength: 32768,
|
|
84
|
+
modelMaxContextLength: 32768,
|
|
85
|
+
}),
|
|
86
|
+
loadFn: async () => { loads++; return { ok: true, modelLoaded: true }; },
|
|
87
|
+
unloadFn: async () => { unloads++; return { ok: true }; },
|
|
88
|
+
estimateFn: async ({ contextLength }) => ({
|
|
89
|
+
classification: { zone: contextLength <= 32768 ? 'GREEN' : 'RED' },
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
assert.equal(out.ok, true);
|
|
93
|
+
assert.equal(out.reloadedForFollow, false);
|
|
94
|
+
assert.equal(out.followReason, 'already-optimal');
|
|
95
|
+
assert.equal(loads, 0);
|
|
96
|
+
assert.equal(unloads, 0);
|
|
97
|
+
assert.equal(out.followContext, 32768);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('ensureModelContextFollow unloads and reloads once when the model max exceeds the loaded context', async () => {
|
|
101
|
+
let loads = [];
|
|
102
|
+
let unloads = 0;
|
|
103
|
+
let loadedCtx = 8192;
|
|
104
|
+
const out = await ensureModelContextFollow({
|
|
105
|
+
model: 'google/gemma-4-e4b',
|
|
106
|
+
probeFn: async () => ({
|
|
107
|
+
reachable: true,
|
|
108
|
+
loadedModel: 'google/gemma-4-e4b',
|
|
109
|
+
loadedContextLength: loadedCtx,
|
|
110
|
+
modelMaxContextLength: 131072,
|
|
111
|
+
}),
|
|
112
|
+
loadFn: async ({ contextLength }) => {
|
|
113
|
+
loads.push(contextLength);
|
|
114
|
+
if (contextLength) loadedCtx = contextLength;
|
|
115
|
+
return { ok: true, modelLoaded: true };
|
|
116
|
+
},
|
|
117
|
+
unloadFn: async () => { unloads++; return { ok: true }; },
|
|
118
|
+
estimateFn: async ({ contextLength }) => ({
|
|
119
|
+
classification: { zone: contextLength <= 32768 ? 'GREEN' : 'RED' },
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
assert.equal(out.ok, true);
|
|
123
|
+
assert.equal(out.reloadedForFollow, true);
|
|
124
|
+
assert.equal(unloads, 1);
|
|
125
|
+
assert.deepEqual(loads, [32768]);
|
|
126
|
+
assert.equal(out.loadedContextLength, 32768);
|
|
127
|
+
assert.equal(out.modelMaxContextLength, 131072);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('ensureModelContextFollow loads a missing model, then bumps it toward the max', async () => {
|
|
131
|
+
const loads = [];
|
|
132
|
+
let loadedCtx = null;
|
|
133
|
+
const out = await ensureModelContextFollow({
|
|
134
|
+
model: 'google/gemma-4-e4b',
|
|
135
|
+
requestedContext: 16384,
|
|
136
|
+
probeFn: async () => (loadedCtx === null
|
|
137
|
+
? { reachable: true, loadedModel: null, loadedModels: [] }
|
|
138
|
+
: {
|
|
139
|
+
reachable: true,
|
|
140
|
+
loadedModel: 'google/gemma-4-e4b',
|
|
141
|
+
loadedContextLength: loadedCtx,
|
|
142
|
+
modelMaxContextLength: 65536,
|
|
143
|
+
}),
|
|
144
|
+
loadFn: async ({ contextLength }) => {
|
|
145
|
+
loads.push(contextLength || null);
|
|
146
|
+
loadedCtx = contextLength || 16384;
|
|
147
|
+
return { ok: true, modelLoaded: true };
|
|
148
|
+
},
|
|
149
|
+
unloadFn: async () => ({ ok: true }),
|
|
150
|
+
estimateFn: async () => ({ classification: { zone: 'GREEN' } }),
|
|
151
|
+
});
|
|
152
|
+
assert.equal(out.ok, true);
|
|
153
|
+
assert.equal(out.performedLoad, true);
|
|
154
|
+
assert.equal(out.reloadedForFollow, true);
|
|
155
|
+
assert.deepEqual(loads, [16384, 65536]);
|
|
156
|
+
assert.equal(out.followContext, 65536);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('ensureModelContextFollow surfaces load failures without follow fields', async () => {
|
|
160
|
+
const out = await ensureModelContextFollow({
|
|
161
|
+
model: 'google/gemma-4-e4b',
|
|
162
|
+
probeFn: async () => ({ reachable: true, loadedModel: null, loadedModels: [] }),
|
|
163
|
+
loadFn: async () => ({ ok: false, reason: 'boom' }),
|
|
164
|
+
});
|
|
165
|
+
assert.equal(out.ok, false);
|
|
166
|
+
assert.match(out.reason, /boom/);
|
|
167
|
+
assert.equal(out.reloadedForFollow, false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('settings UI exposes the auto-follow toggle and the follow applier', () => {
|
|
171
|
+
const html = getSettingsHtml();
|
|
172
|
+
assert.match(html, /id="cfg-lms-context-auto"[^>]*checked/);
|
|
173
|
+
assert.match(html, /autoContext/);
|
|
174
|
+
assert.match(html, /function applyModelContextFollow\(data, model\)/);
|
|
175
|
+
assert.match(html, /lblLmsContextAuto/);
|
|
176
|
+
});
|