kiku-agent 1.3.1 → 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 +5 -12
- 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/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 +288 -0
- package/server/agent/eval/modules/evaluation/scoring.js +363 -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 +4799 -0
- package/server/agent/eval/modules/utils/smokes.js +119 -0
- 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/agentBubbleSystem.test.js +65 -0
- package/server/agent/tests/agentFollowUps.test.js +253 -0
- package/server/agent/tests/agentMusicControl.test.js +245 -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/autoContext.test.js +176 -0
- package/server/agent/tests/autoResearch.test.js +64 -0
- package/server/agent/tests/bilibiliEmbed.test.js +296 -0
- package/server/agent/tests/catalogSync.test.js +130 -0
- package/server/agent/tests/chatHardening.test.js +121 -0
- package/server/agent/tests/chineseWebSearchEscalation.test.js +62 -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 +627 -0
- package/server/agent/tests/cliAutomation.test.js +158 -0
- package/server/agent/tests/cloudProviders.test.js +322 -0
- package/server/agent/tests/commandInput.test.js +24 -0
- package/server/agent/tests/commands.test.js +506 -0
- package/server/agent/tests/contextIntelligence.test.js +209 -0
- package/server/agent/tests/conversationSafety.test.js +53 -0
- package/server/agent/tests/cpuMetrics.test.js +101 -0
- package/server/agent/tests/decisionAndSearchEnhancements.test.js +167 -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 +131 -0
- package/server/agent/tests/engineService.test.js +436 -0
- package/server/agent/tests/engineUnload.test.js +94 -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 +284 -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 +320 -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/fetchApi.test.js +99 -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 +194 -0
- package/server/agent/tests/judgeService.test.js +251 -0
- package/server/agent/tests/lanDiscovery.test.js +324 -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/mcpClient.test.js +201 -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 +141 -0
- package/server/agent/tests/multilingualScenarios.test.js +69 -0
- package/server/agent/tests/musicControlUiBridge.test.js +113 -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 +101 -0
- package/server/agent/tests/offlineInput.test.js +217 -0
- package/server/agent/tests/openDomainPrompt.test.js +197 -0
- package/server/agent/tests/openDomainSearch.test.js +121 -0
- package/server/agent/tests/packagingGuard.test.js +57 -0
- package/server/agent/tests/presentation.test.js +61 -0
- package/server/agent/tests/processManager.test.js +144 -0
- package/server/agent/tests/providerPersistence.test.js +44 -0
- package/server/agent/tests/queryBenchmarks.test.js +83 -0
- package/server/agent/tests/queryDetails.test.js +126 -0
- package/server/agent/tests/quickActionsAndModals.test.js +180 -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/searchQueryPlanner.test.js +108 -0
- package/server/agent/tests/securityAuditRemediation.test.js +221 -0
- package/server/agent/tests/securityAuditRemediations2.test.js +154 -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 +267 -0
- package/server/agent/tests/sidebarControls.test.js +135 -0
- package/server/agent/tests/statefulMemory.test.js +277 -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 +197 -0
- package/server/agent/tests/textToolParsing.test.js +187 -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 +160 -0
- package/server/agent/tests/tools.test.js +259 -0
- package/server/agent/tests/unifiedEndpoints.test.js +159 -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 +595 -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 +551 -0
- package/server/agent/tests/webChatMcp.test.js +94 -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 +284 -0
- package/server/agent/tests/webSources.test.js +281 -0
- 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/eval/judgeRubrics.js +110 -0
- package/server/eval/judgeService.js +290 -0
- package/server/eval/runBenchmark.js +182 -0
- package/server/mcp/data/discography.js +2 -2
- package/server/mcp/examples/antigravity.example.json +17 -0
- package/server/mcp/examples/librechat.example.yaml +16 -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 +162 -0
- 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 +63 -0
- 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;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
await import('../../web-chat/ui/scripts/agentBubbleSystem.js');
|
|
5
|
+
|
|
6
|
+
const { AgentBubbleSystem } = globalThis;
|
|
7
|
+
|
|
8
|
+
test('agent bubble reducer follows the streaming lifecycle without replacing parts', () => {
|
|
9
|
+
let model = AgentBubbleSystem.createModel({
|
|
10
|
+
id: 'message_1',
|
|
11
|
+
status: 'queued',
|
|
12
|
+
agent: { id: 'research', name: 'Research Agent', avatar: 'R' }
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
model = AgentBubbleSystem.reduceEvent(model, { type: 'reasoning', chunk: 'Check ' });
|
|
16
|
+
model = AgentBubbleSystem.reduceEvent(model, { type: 'reasoning', chunk: 'sources.' });
|
|
17
|
+
assert.equal(model.status, 'thinking');
|
|
18
|
+
assert.equal(model.parts.find(part => part.type === 'reasoning').content, 'Check sources.');
|
|
19
|
+
|
|
20
|
+
model = AgentBubbleSystem.reduceEvent(model, {
|
|
21
|
+
type: 'tool_start',
|
|
22
|
+
callId: 'call_1',
|
|
23
|
+
name: 'search_works',
|
|
24
|
+
input: { query: 'In Blossom' }
|
|
25
|
+
});
|
|
26
|
+
model = AgentBubbleSystem.reduceEvent(model, {
|
|
27
|
+
type: 'tool_result',
|
|
28
|
+
callId: 'call_1',
|
|
29
|
+
name: 'search_works',
|
|
30
|
+
result: { count: 1 },
|
|
31
|
+
durationMs: 24
|
|
32
|
+
});
|
|
33
|
+
assert.equal(model.parts.find(part => part.id === 'call_1').status, 'done');
|
|
34
|
+
|
|
35
|
+
model = AgentBubbleSystem.reduceEvent(model, { type: 'token', chunk: 'Found ' });
|
|
36
|
+
model = AgentBubbleSystem.reduceEvent(model, { type: 'token', chunk: 'one result.' });
|
|
37
|
+
assert.equal(model.status, 'streaming');
|
|
38
|
+
assert.equal(model.parts.find(part => part.type === 'text').content, 'Found one result.');
|
|
39
|
+
|
|
40
|
+
model = AgentBubbleSystem.reduceEvent(model, { type: 'done' });
|
|
41
|
+
assert.equal(model.status, 'done');
|
|
42
|
+
assert.equal(model.parts.length, 3);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('agent bubble reducer accepts agent handoffs and exposes explicit waiting and error states', () => {
|
|
46
|
+
const base = AgentBubbleSystem.createModel({
|
|
47
|
+
agent: { id: 'coordinator', name: 'Coordinator', avatar: 'C' }
|
|
48
|
+
});
|
|
49
|
+
const waiting = AgentBubbleSystem.reduceEvent(base, {
|
|
50
|
+
type: 'clarifying_questions',
|
|
51
|
+
agentId: 'fact-checker',
|
|
52
|
+
agentName: 'Fact-check Agent',
|
|
53
|
+
agentAvatar: 'F'
|
|
54
|
+
});
|
|
55
|
+
assert.equal(waiting.status, 'waiting');
|
|
56
|
+
assert.equal(waiting.agent.id, 'fact-checker');
|
|
57
|
+
|
|
58
|
+
const failed = AgentBubbleSystem.reduceEvent(waiting, {
|
|
59
|
+
type: 'error',
|
|
60
|
+
error: 'Catalog unavailable.'
|
|
61
|
+
});
|
|
62
|
+
assert.equal(failed.status, 'error');
|
|
63
|
+
assert.equal(failed.error, 'Catalog unavailable.');
|
|
64
|
+
assert.equal(failed.parts.at(-1).type, 'error');
|
|
65
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { extractAgentFollowUpQuestions, generateDynamicFollowUps, normalizeSuggestionKey, dedupeSuggestions, isOffTopicChat, buildOffTopicFollowUps } from '../../web-chat/core/followUpEngine.js';
|
|
4
|
+
import { executeDirectCatalogKnowledge } from '../../web-chat/core/directCatalogEngine.js';
|
|
5
|
+
|
|
6
|
+
test('extractAgentFollowUpQuestions extracts [SUGGESTED_FOLLOW_UPS] block and strips it from cleanText', () => {
|
|
7
|
+
const text = `นี่คือเนื้อหาการแสดงของจวีจิ้งอีในฮวาเจียนลิ่งครับ
|
|
8
|
+
|
|
9
|
+
[SUGGESTED_FOLLOW_UPS]
|
|
10
|
+
- วิเคราะห์พัฒนาการของหยางไฉ่เวย
|
|
11
|
+
- ฉากแต่งงานที่น่าประทับใจ
|
|
12
|
+
- เพลงประกอบซีรีส์ฮวาเจียนลิ่ง
|
|
13
|
+
[/SUGGESTED_FOLLOW_UPS]`;
|
|
14
|
+
|
|
15
|
+
const result = extractAgentFollowUpQuestions(text);
|
|
16
|
+
assert.equal(result.cleanText, 'นี่คือเนื้อหาการแสดงของจวีจิ้งอีในฮวาเจียนลิ่งครับ');
|
|
17
|
+
assert.equal(result.suggestions.length, 3);
|
|
18
|
+
assert.equal(result.suggestions[0].query, 'วิเคราะห์พัฒนาการของหยางไฉ่เวย');
|
|
19
|
+
assert.ok(result.suggestions[0].label.startsWith('💡 วิเคราะห์พัฒนาการ'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('extractAgentFollowUpQuestions extracts inline 💡 section at the end of answer', () => {
|
|
23
|
+
const text = `จวีจิ้งอีได้ปล่อยซิงเกิลใหม่ FIND THE WAY ในปี 2026
|
|
24
|
+
|
|
25
|
+
💡 แนะนำถามต่อ:
|
|
26
|
+
- ฟังเพลง FIND THE WAY และดู MV
|
|
27
|
+
- สเตจการแสดงสดของเพลงนี้
|
|
28
|
+
- ทิศทางดนตรีใหม่ในปี 2026`;
|
|
29
|
+
|
|
30
|
+
const result = extractAgentFollowUpQuestions(text);
|
|
31
|
+
assert.equal(result.cleanText, 'จวีจิ้งอีได้ปล่อยซิงเกิลใหม่ FIND THE WAY ในปี 2026');
|
|
32
|
+
assert.equal(result.suggestions.length, 3);
|
|
33
|
+
assert.equal(result.suggestions[0].query, 'ฟังเพลง FIND THE WAY และดู MV');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('generateDynamicFollowUps creates contextual questions based on entities', () => {
|
|
37
|
+
const dramaSuggestions = generateDynamicFollowUps({
|
|
38
|
+
query: 'ซีรีส์ฮวาเจียนลิ่ง',
|
|
39
|
+
answer: 'จวีจิ้งอีรับบทเป็นหยางไฉ่เวยและซ่างกวนจื่อ',
|
|
40
|
+
tools: [{ name: 'search_works' }]
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.ok(dramaSuggestions.length >= 2);
|
|
44
|
+
assert.ok(dramaSuggestions.some(s => s.query.includes('ฮวาเจียนลิ่ง') || s.query.includes('หยางไฉ่เวย')));
|
|
45
|
+
|
|
46
|
+
const musicSuggestions = generateDynamicFollowUps({
|
|
47
|
+
query: 'ผลงานเพลงของจวีจิ้งอี',
|
|
48
|
+
answer: 'เพลง FIND THE WAY และเพลง Everyday',
|
|
49
|
+
tools: [{ name: 'search_discography' }]
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
assert.ok(musicSuggestions.length >= 2);
|
|
53
|
+
assert.ok(musicSuggestions.some(s => s.query.includes('เพลง') || s.query.includes('FIND THE WAY')));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('executeDirectCatalogKnowledge returns suggestions dynamically generated by agent engine', async () => {
|
|
57
|
+
const response = await executeDirectCatalogKnowledge('ผลงานซีรีส์ In Blossom');
|
|
58
|
+
assert.ok(response.answer);
|
|
59
|
+
assert.ok(Array.isArray(response.suggestions));
|
|
60
|
+
assert.ok(response.suggestions.length > 0);
|
|
61
|
+
assert.ok(response.suggestions[0].query);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('followUpEngine: normalizeSuggestionKey and dedupeSuggestions collapse duplicates', () => {
|
|
65
|
+
assert.equal(normalizeSuggestionKey('💡 เพลงประกอบ ฮวาเจียนลิ่ง!'), normalizeSuggestionKey('เพลงประกอบฮวาเจียนลิ่ง'));
|
|
66
|
+
|
|
67
|
+
const seen = new Set([normalizeSuggestionKey('วิเคราะห์พัฒนาการของหยางไฉ่เวย')]);
|
|
68
|
+
const out = dedupeSuggestions([
|
|
69
|
+
{ label: 'a', query: 'วิเคราะห์พัฒนาการของหยางไฉ่เวย' },
|
|
70
|
+
{ label: 'b', query: 'วิเคราะห์พัฒนาการของหยางไฉ่เวยในเรื่อง' },
|
|
71
|
+
{ label: 'c', query: 'เพลงประกอบซีรีส์ฮวาเจียนลิ่ง' },
|
|
72
|
+
], seen);
|
|
73
|
+
|
|
74
|
+
assert.equal(out.length, 1);
|
|
75
|
+
assert.equal(out[0].query, 'เพลงประกอบซีรีส์ฮวาเจียนลิ่ง');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('followUpEngine: extractAgentFollowUpQuestions dedupes within one block', () => {
|
|
79
|
+
const text = `[SUGGESTED_FOLLOW_UPS]
|
|
80
|
+
- วิเคราะห์พัฒนาการของหยางไฉ่เวย
|
|
81
|
+
- วิเคราะห์พัฒนาการของหยางไฉ่เวย
|
|
82
|
+
- เพลงประกอบซีรีส์ฮวาเจียนลิ่ง
|
|
83
|
+
[/SUGGESTED_FOLLOW_UPS]`;
|
|
84
|
+
|
|
85
|
+
const result = extractAgentFollowUpQuestions(text);
|
|
86
|
+
assert.equal(result.suggestions.length, 2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('generateDynamicFollowUps: recentKeys suppresses already-shown questions', () => {
|
|
90
|
+
const first = generateDynamicFollowUps({ query: 'ซีรีส์ฮวาเจียนลิ่ง', answer: '', tools: [] });
|
|
91
|
+
assert.ok(first.length >= 2);
|
|
92
|
+
|
|
93
|
+
const seen = new Set(first.map(s => normalizeSuggestionKey(s.query)));
|
|
94
|
+
const second = generateDynamicFollowUps({ query: 'ซีรีส์ฮวาเจียนลิ่ง', answer: '', tools: [], recentKeys: seen });
|
|
95
|
+
|
|
96
|
+
assert.ok(second.length >= 2);
|
|
97
|
+
for (const s of second) {
|
|
98
|
+
assert.ok(!seen.has(normalizeSuggestionKey(s.query)), `repeat leaked: ${s.query}`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('generateDynamicFollowUps: rotation keeps proposing fresh questions across turns', () => {
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
const asked = new Set();
|
|
105
|
+
for (let turn = 0; turn < 4; turn++) {
|
|
106
|
+
const round = generateDynamicFollowUps({ query: 'จวีจิ้งอีเป็นใคร', answer: '', tools: [], recentKeys: seen });
|
|
107
|
+
assert.ok(round.length >= 2, `turn ${turn} returned too few suggestions`);
|
|
108
|
+
for (const s of round) {
|
|
109
|
+
const key = normalizeSuggestionKey(s.query);
|
|
110
|
+
assert.ok(!asked.has(key), `question repeated on turn ${turn}: ${s.query}`);
|
|
111
|
+
asked.add(key);
|
|
112
|
+
seen.add(key);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
test('generateDynamicFollowUps: exhausted pool still returns suggestions (recycled)', () => {
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (let turn = 0; turn < 6; turn++) {
|
|
119
|
+
const round = generateDynamicFollowUps({ query: 'จวีจิ้งอีเป็นใคร', answer: '', tools: [], recentKeys: seen });
|
|
120
|
+
for (const s of round) seen.add(normalizeSuggestionKey(s.query));
|
|
121
|
+
}
|
|
122
|
+
for (let turn = 0; turn < 3; turn++) {
|
|
123
|
+
const round = generateDynamicFollowUps({ query: 'จวีจิ้งอีเป็นใคร', answer: '', tools: [], recentKeys: seen });
|
|
124
|
+
assert.ok(round.length >= 2, `exhausted turn ${turn} returned too few suggestions`);
|
|
125
|
+
}
|
|
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
|
+
});
|