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.
Files changed (232) hide show
  1. package/README.md +69 -12
  2. package/package.json +5 -12
  3. package/server/agent/agentCore.js +319 -46
  4. package/server/agent/appPaths.js +31 -0
  5. package/server/agent/audioPlayer.js +339 -12
  6. package/server/agent/autoContext.js +193 -0
  7. package/server/agent/catalogEntityResolver.js +170 -28
  8. package/server/agent/clarifyingQuestions.js +137 -35
  9. package/server/agent/cli.js +177 -48
  10. package/server/agent/cloudAdapters.js +116 -0
  11. package/server/agent/commandRegistry.js +409 -80
  12. package/server/agent/commands.js +1056 -125
  13. package/server/agent/config.js +1088 -690
  14. package/server/agent/contextCompactor.js +213 -21
  15. package/server/agent/cpuMetrics.js +199 -0
  16. package/server/agent/engineService.js +591 -0
  17. package/server/agent/eval/AGENTS.md +228 -0
  18. package/server/agent/eval/analyze.py +684 -0
  19. package/server/agent/eval/gates.json +15 -0
  20. package/server/agent/eval/modules/analysis/compare.js +198 -0
  21. package/server/agent/eval/modules/analysis/gates.js +72 -0
  22. package/server/agent/eval/modules/analysis/judge.js +70 -0
  23. package/server/agent/eval/modules/core/cli.js +453 -0
  24. package/server/agent/eval/modules/core/config.js +60 -0
  25. package/server/agent/eval/modules/core/store.js +236 -0
  26. package/server/agent/eval/modules/evaluation/cases.js +689 -0
  27. package/server/agent/eval/modules/evaluation/runner.js +288 -0
  28. package/server/agent/eval/modules/evaluation/scoring.js +363 -0
  29. package/server/agent/eval/modules/evaluation/worker.js +122 -0
  30. package/server/agent/eval/modules/reporting/benchmark.js +265 -0
  31. package/server/agent/eval/modules/reporting/benchmarkPng.js +487 -0
  32. package/server/agent/eval/modules/reporting/dashboard.js +1406 -0
  33. package/server/agent/eval/modules/server/evalApi.js +1099 -0
  34. package/server/agent/eval/modules/ui/evalUi.js +4799 -0
  35. package/server/agent/eval/modules/utils/smokes.js +119 -0
  36. package/server/agent/eventBus.js +1 -0
  37. package/server/agent/ink/main.jsx +196 -191
  38. package/server/agent/jobManager.js +11 -1
  39. package/server/agent/lanDiscovery.js +721 -23
  40. package/server/agent/llmClient.js +317 -71
  41. package/server/agent/lmStudioWakeup.js +517 -65
  42. package/server/agent/locationService.js +797 -0
  43. package/server/agent/mcpQuizData.js +509 -0
  44. package/server/agent/memory/contextAllocator.js +19 -7
  45. package/server/agent/memory.js +3 -1
  46. package/server/agent/modelCompatibility.js +352 -0
  47. package/server/agent/modelDefaults.js +1 -0
  48. package/server/agent/modelManager.js +46 -2
  49. package/server/agent/newsWatch.js +9 -3
  50. package/server/agent/processManager.js +229 -0
  51. package/server/agent/prompts.js +174 -8
  52. package/server/agent/routingInterceptor.js +9 -1
  53. package/server/agent/sanitizer.js +41 -6
  54. package/server/agent/searchQueryPlanner.js +202 -0
  55. package/server/agent/session.js +59 -4
  56. package/server/agent/tests/agentBubbleSystem.test.js +65 -0
  57. package/server/agent/tests/agentFollowUps.test.js +253 -0
  58. package/server/agent/tests/agentMusicControl.test.js +245 -0
  59. package/server/agent/tests/apiRouter.test.js +108 -0
  60. package/server/agent/tests/architectureFixes.test.js +81 -0
  61. package/server/agent/tests/architectureRefactoring.test.js +194 -0
  62. package/server/agent/tests/autoContext.test.js +176 -0
  63. package/server/agent/tests/autoResearch.test.js +64 -0
  64. package/server/agent/tests/bilibiliEmbed.test.js +296 -0
  65. package/server/agent/tests/catalogSync.test.js +130 -0
  66. package/server/agent/tests/chatHardening.test.js +121 -0
  67. package/server/agent/tests/chineseWebSearchEscalation.test.js +62 -0
  68. package/server/agent/tests/citations.test.js +180 -0
  69. package/server/agent/tests/clarificationAnalytics.test.js +219 -0
  70. package/server/agent/tests/clarifyingQuestions.test.js +627 -0
  71. package/server/agent/tests/cliAutomation.test.js +158 -0
  72. package/server/agent/tests/cloudProviders.test.js +322 -0
  73. package/server/agent/tests/commandInput.test.js +24 -0
  74. package/server/agent/tests/commands.test.js +506 -0
  75. package/server/agent/tests/contextIntelligence.test.js +209 -0
  76. package/server/agent/tests/conversationSafety.test.js +53 -0
  77. package/server/agent/tests/cpuMetrics.test.js +101 -0
  78. package/server/agent/tests/decisionAndSearchEnhancements.test.js +167 -0
  79. package/server/agent/tests/dramaAnswerQuality.test.js +73 -0
  80. package/server/agent/tests/dynamicCatalogIngestion.test.js +285 -0
  81. package/server/agent/tests/dynamicRoutingAndSearch.test.js +92 -0
  82. package/server/agent/tests/edgeCasesScenarios.test.js +131 -0
  83. package/server/agent/tests/engineService.test.js +436 -0
  84. package/server/agent/tests/engineUnload.test.js +94 -0
  85. package/server/agent/tests/entertainmentMetrics.test.js +126 -0
  86. package/server/agent/tests/envBootstrap.test.js +87 -0
  87. package/server/agent/tests/envCommand.test.js +284 -0
  88. package/server/agent/tests/errorHandling.test.js +80 -0
  89. package/server/agent/tests/errors.test.js +13 -0
  90. package/server/agent/tests/evalAnalyze.test.js +54 -0
  91. package/server/agent/tests/evalCommand.test.js +54 -0
  92. package/server/agent/tests/evalCorpus.test.js +126 -0
  93. package/server/agent/tests/evalHarness.test.js +62 -0
  94. package/server/agent/tests/evalJudgeContext.test.js +38 -0
  95. package/server/agent/tests/evalMetrics.test.js +320 -0
  96. package/server/agent/tests/evalStore.test.js +190 -0
  97. package/server/agent/tests/evalStudio.test.js +179 -0
  98. package/server/agent/tests/feedbackLearning.test.js +186 -0
  99. package/server/agent/tests/fetchApi.test.js +99 -0
  100. package/server/agent/tests/firstRun.test.js +44 -0
  101. package/server/agent/tests/gemma4eOptimization.test.js +95 -0
  102. package/server/agent/tests/hardwareTiers.test.js +206 -0
  103. package/server/agent/tests/hybridWebSearch.test.js +194 -0
  104. package/server/agent/tests/judgeService.test.js +251 -0
  105. package/server/agent/tests/lanDiscovery.test.js +324 -0
  106. package/server/agent/tests/lanInterfaces.test.js +510 -0
  107. package/server/agent/tests/lmStudioWakeup.test.js +278 -0
  108. package/server/agent/tests/locationTool.test.js +278 -0
  109. package/server/agent/tests/mcpClient.test.js +201 -0
  110. package/server/agent/tests/memoryRoute.test.js +173 -0
  111. package/server/agent/tests/memoryUiNormalization.test.js +75 -0
  112. package/server/agent/tests/modelCompatibility.test.js +148 -0
  113. package/server/agent/tests/modelManager.test.js +141 -0
  114. package/server/agent/tests/multilingualScenarios.test.js +69 -0
  115. package/server/agent/tests/musicControlUiBridge.test.js +113 -0
  116. package/server/agent/tests/newCommands.test.js +347 -0
  117. package/server/agent/tests/newCommandsTui.test.js +141 -0
  118. package/server/agent/tests/newTools.test.js +113 -0
  119. package/server/agent/tests/newsIntelligence.test.js +178 -0
  120. package/server/agent/tests/nextGenArchitecture.test.js +101 -0
  121. package/server/agent/tests/offlineInput.test.js +217 -0
  122. package/server/agent/tests/openDomainPrompt.test.js +197 -0
  123. package/server/agent/tests/openDomainSearch.test.js +121 -0
  124. package/server/agent/tests/packagingGuard.test.js +57 -0
  125. package/server/agent/tests/presentation.test.js +61 -0
  126. package/server/agent/tests/processManager.test.js +144 -0
  127. package/server/agent/tests/providerPersistence.test.js +44 -0
  128. package/server/agent/tests/queryBenchmarks.test.js +83 -0
  129. package/server/agent/tests/queryDetails.test.js +126 -0
  130. package/server/agent/tests/quickActionsAndModals.test.js +180 -0
  131. package/server/agent/tests/realWorldValidation.test.js +391 -0
  132. package/server/agent/tests/resilienceAndConcurrency.test.js +117 -0
  133. package/server/agent/tests/runtimeWatchdog.test.js +32 -0
  134. package/server/agent/tests/sanitizer.test.js +37 -0
  135. package/server/agent/tests/scenariosData.js +378 -0
  136. package/server/agent/tests/schemaRouting.test.js +54 -0
  137. package/server/agent/tests/searchQueryPlanner.test.js +108 -0
  138. package/server/agent/tests/securityAuditRemediation.test.js +221 -0
  139. package/server/agent/tests/securityAuditRemediations2.test.js +154 -0
  140. package/server/agent/tests/securityHardening.test.js +146 -0
  141. package/server/agent/tests/securitySanitizer.test.js +38 -0
  142. package/server/agent/tests/sessionAndCopy.test.js +267 -0
  143. package/server/agent/tests/sidebarControls.test.js +135 -0
  144. package/server/agent/tests/statefulMemory.test.js +277 -0
  145. package/server/agent/tests/subagent.test.js +216 -0
  146. package/server/agent/tests/subagentProgress.test.js +109 -0
  147. package/server/agent/tests/subagentTextFallback.test.js +115 -0
  148. package/server/agent/tests/suggestions.test.js +199 -0
  149. package/server/agent/tests/suggestionsTui.test.js +251 -0
  150. package/server/agent/tests/telemetryAndProfiles.test.js +179 -0
  151. package/server/agent/tests/terminalImage.test.js +80 -0
  152. package/server/agent/tests/terminalMarkdown.test.js +166 -0
  153. package/server/agent/tests/terminalText.test.js +133 -0
  154. package/server/agent/tests/textProtocolLoop.test.js +197 -0
  155. package/server/agent/tests/textToolParsing.test.js +187 -0
  156. package/server/agent/tests/theme.test.js +26 -0
  157. package/server/agent/tests/toolRegistryParity.test.js +53 -0
  158. package/server/agent/tests/toolRoutingRegressions.test.js +160 -0
  159. package/server/agent/tests/tools.test.js +259 -0
  160. package/server/agent/tests/unifiedEndpoints.test.js +159 -0
  161. package/server/agent/tests/v110Features.test.js +262 -0
  162. package/server/agent/tests/versionBump.test.js +69 -0
  163. package/server/agent/tests/vramEstimator.test.js +595 -0
  164. package/server/agent/tests/webChatDocs.test.js +199 -0
  165. package/server/agent/tests/webChatInferenceControl.test.js +298 -0
  166. package/server/agent/tests/webChatInspection.test.js +551 -0
  167. package/server/agent/tests/webChatMcp.test.js +94 -0
  168. package/server/agent/tests/webChatMemory.test.js +112 -0
  169. package/server/agent/tests/webChatSecurity.test.js +169 -0
  170. package/server/agent/tests/webExtract.test.js +284 -0
  171. package/server/agent/tests/webSources.test.js +281 -0
  172. package/server/agent/textToolProtocol.js +173 -46
  173. package/server/agent/toolRouter.js +318 -44
  174. package/server/agent/tools.js +877 -96
  175. package/server/agent/ui.js +39 -34
  176. package/server/agent/vramService.js +35 -54
  177. package/server/agent/webSearch.js +441 -162
  178. package/server/agent/websearch/orchestrator.js +15 -5
  179. package/server/agent/websearch/resilience/cache.js +9 -2
  180. package/server/agent/websearch/security/ssrf.js +41 -10
  181. package/server/agent/youtubeAuth.js +5 -20
  182. package/server/apiRouter.js +5558 -4422
  183. package/server/eval/judgeRubrics.js +110 -0
  184. package/server/eval/judgeService.js +290 -0
  185. package/server/eval/runBenchmark.js +182 -0
  186. package/server/mcp/data/discography.js +2 -2
  187. package/server/mcp/examples/antigravity.example.json +17 -0
  188. package/server/mcp/examples/librechat.example.yaml +16 -0
  189. package/server/mcp/handler.js +8 -4
  190. package/server/mcp/security.js +220 -21
  191. package/server/mcp/server.js +2 -1
  192. package/server/mcp/tests/connection.test.js +162 -0
  193. package/server/mcp/tests/mcpKeyAuth.test.js +156 -0
  194. package/server/mcp/tools.js +7 -1
  195. package/server/supabase_migration_prefix.sql +102 -0
  196. package/server/supabase_schema.sql +63 -0
  197. package/server/web-chat/core/connection.js +179 -218
  198. package/server/web-chat/core/conversationContext.js +9 -2
  199. package/server/web-chat/core/directCatalogEngine.js +52 -28
  200. package/server/web-chat/core/dramaKnowledge.js +33 -22
  201. package/server/web-chat/core/followUpEngine.js +735 -81
  202. package/server/web-chat/core/futureEventGuard.js +5 -1
  203. package/server/web-chat/core/musicControlAction.js +70 -0
  204. package/server/web-chat/core/security.js +32 -21
  205. package/server/web-chat/core/toolInspector.js +6 -6
  206. package/server/web-chat/core/urlGuard.js +45 -0
  207. package/server/web-chat/router.js +204 -157
  208. package/server/web-chat/routes/chatRoute.js +885 -488
  209. package/server/web-chat/routes/engineRoute.js +270 -0
  210. package/server/web-chat/routes/exportRoute.js +1 -1
  211. package/server/web-chat/routes/lanDiscoveryRoute.js +17 -2
  212. package/server/web-chat/routes/lmStudioLoadRoute.js +172 -0
  213. package/server/web-chat/routes/mcpRoute.js +386 -0
  214. package/server/web-chat/routes/memoryRoute.js +253 -0
  215. package/server/web-chat/routes/providerRoute.js +49 -16
  216. package/server/web-chat/routes/staticRoute.js +306 -17
  217. package/server/web-chat/routes/statusRoute.js +194 -73
  218. package/server/web-chat/routes/suggestRoute.js +11 -3
  219. package/server/web-chat/routes/verifyRoute.js +26 -4
  220. package/server/web-chat/server.js +7 -6
  221. package/server/web-chat/ui/docsBuilder.js +3558 -0
  222. package/server/web-chat/ui/htmlBuilder.js +12 -0
  223. package/server/web-chat/ui/scripts/client.js +3401 -1302
  224. package/server/web-chat/ui/scripts/webchatI18n.js +155 -153
  225. package/server/web-chat/ui/settingsBuilder.js +7870 -0
  226. package/server/web-chat/ui/styles/lan-card.css +165 -0
  227. package/server/web-chat/ui/styles/main.css +4289 -447
  228. package/server/web-chat/ui/sw.js +64 -0
  229. package/server/web-chat/ui/templates/body.html +407 -281
  230. package/src/data/awardsData.js +146 -146
  231. package/src/data/musicVideoData.js +6 -4
  232. package/src/lib/vram/estimator.js +195 -19
@@ -33,9 +33,16 @@ function normalizeStorageKey(value) {
33
33
 
34
34
  export class AgentSession {
35
35
  constructor(options = {}) {
36
- this.persistenceEnabled = options.persistence !== false;
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 ? new Date(sessionData.createdAt) : new Date();
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
+ });