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
@@ -0,0 +1,160 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { DynamicToolRouter } from '../toolRouter.js';
4
+ import { resolveQueryEntities, buildCatalogIndex, detectPromotionIntent } from '../catalogEntityResolver.js';
5
+ import { createCatalogMatcher, executeTool } from '../tools.js';
6
+ import { KikuAgent } from '../agentCore.js';
7
+ import { McpClientManager } from '../mcpClient.js';
8
+
9
+ const MULTI_INTENT_QUERY = 'เปิดเพลงล่าสุดของจวีจิ้งอี และดูคลิปละครใหม่หน่อย ล่าสุดมีข่าวไหม';
10
+
11
+ test('router keeps playback tools visible when multiple intents share the 15-tool cap', () => {
12
+ const router = new DynamicToolRouter();
13
+ const routed = router.routeToolsForQuery(MULTI_INTENT_QUERY, { includeMcp: true });
14
+ const names = new Set(routed.map(t => t.function?.name));
15
+
16
+ assert.ok(routed.length <= 15, `Expected cap of 15, got ${routed.length}`);
17
+ assert.ok(names.has('play_music'), 'play_music must survive the cap on a play request');
18
+ assert.ok(names.has('search_discography'), 'search_discography must survive the cap');
19
+ assert.ok(names.has('search_works'), 'second intent essentials must follow the first intent before generic tools');
20
+ assert.ok(names.has('get_character_roles'), 'WORKS essentials must be included before the cap cuts them');
21
+ });
22
+
23
+ test('router no longer routes PROFILE intent on substring collisions', () => {
24
+ const router = new DynamicToolRouter();
25
+
26
+ const winter = router.detectIntents('Tell me about her winter concert');
27
+ const winterTypes = winter.map(p => p.type);
28
+ assert.ok(winterTypes.includes('MEDIA'), 'concert should route MEDIA');
29
+ assert.ok(!winterTypes.includes('PROFILE_AWARDS_BRANDS'), '"win" inside "winter" must not route PROFILE');
30
+
31
+ const superlative = router.detectIntents('เพลงที่ยอดวิวสูงสุดของเธอคือเพลงไหน');
32
+ const superlativeTypes = superlative.map(p => p.type);
33
+ assert.ok(superlativeTypes.includes('MEDIA'));
34
+ assert.ok(!superlativeTypes.includes('PROFILE_AWARDS_BRANDS'), 'สูงสุด must not route PROFILE');
35
+
36
+ const happen = router.detectIntents('มีอะไรเกิดขึ้นกับงานนี้');
37
+ assert.ok(!happen.map(p => p.type).includes('PROFILE_AWARDS_BRANDS'), 'เกิดขึ้น must not route PROFILE');
38
+ });
39
+
40
+ test('profile queries no longer leak SYSTEM_SKILL tools via "file" inside "profile"', () => {
41
+ const router = new DynamicToolRouter();
42
+ const intents = router.detectIntents('Show me her profile and fandom details').map(p => p.type);
43
+ assert.ok(intents.includes('PROFILE_AWARDS_BRANDS'));
44
+ assert.ok(!intents.includes('SYSTEM_SKILL'), '"file" inside "profile" must not route SYSTEM_SKILL');
45
+ });
46
+
47
+ test('off-topic queries enforce internet tools and suppress all catalog tools', () => {
48
+ const router = new DynamicToolRouter();
49
+ const catalogTools = new Set([
50
+ 'search_works', 'get_work_detail', 'search_discography', 'get_profile_and_fandom',
51
+ 'get_awards', 'get_brand_endorsements', 'get_career_timeline', 'get_character_roles',
52
+ 'get_gallery_photos', 'get_quotes', 'get_social_feed', 'global_search', 'get_media_links',
53
+ ]);
54
+ for (const q of [
55
+ 'วิธีทำต้มยำกุ้งน้ำข้น',
56
+ 'แนะนำเพลงสากลเพราะๆ หน่อย',
57
+ 'หนัง Dune ภาคใหม่สนุกไหม',
58
+ 'ประวัติศาสตร์อยุธยาตอนต้น',
59
+ 'เขียนฟังก์ชันเรียงข้อมูลให้หน่อย',
60
+ ]) {
61
+ const names = router.routeToolsForQuery(q).map(t => t.function?.name);
62
+ assert.ok(names.includes('web_search'), `${q} must include web_search`);
63
+ for (const c of catalogTools) {
64
+ assert.ok(!names.includes(c), `${q} must not route catalog tool ${c}`);
65
+ }
66
+ }
67
+ });
68
+
69
+ test('off-topic enforcement keeps in-domain music routing intact', () => {
70
+ const router = new DynamicToolRouter();
71
+ const names = router.routeToolsForQuery('เพลง 0.2s ใครร้อง').map(t => t.function?.name);
72
+ assert.ok(names.includes('search_discography'), 'in-domain song query keeps search_discography');
73
+ });
74
+
75
+ test('explicit @mention pins survive off-topic enforcement', () => {
76
+ const router = new DynamicToolRouter();
77
+ const names = router.routeToolsForQuery('วิธีทำต้มยำกุ้ง @search_works').map(t => t.function?.name);
78
+ assert.ok(names.includes('search_works'), 'explicit user pin must survive enforcement');
79
+ });
80
+
81
+ test('ฟังก์ชัน no longer collides with the ฟัง music intent', () => {
82
+ const router = new DynamicToolRouter();
83
+ const types = router.detectIntents('เขียนฟังก์ชันเรียงข้อมูลให้หน่อย').map(p => p.type);
84
+ assert.ok(!types.includes('MEDIA'), 'ฟังก์ชัน must not route MEDIA');
85
+ assert.ok(router.detectIntents('ขอฟังเพลงก่อนนอนหน่อย').map(p => p.type).includes('MEDIA'), 'ฟังเพลง must still route MEDIA');
86
+ });
87
+
88
+ test('promotion intent no longer fires on generic words like "look" or "brand"', () => {
89
+ assert.equal(detectPromotionIntent('What does she look like?'), false);
90
+ assert.equal(detectPromotionIntent('a brand new song'), false);
91
+ assert.equal(detectPromotionIntent('her fashion ambassador campaign'), true);
92
+ assert.equal(detectPromotionIntent('รับโปรโมทแบรนด์'), true);
93
+
94
+ const lookRes = resolveQueryEntities('What does she look like?');
95
+ assert.equal(lookRes.isPromotion, false);
96
+ assert.equal(lookRes.hasBrand, false);
97
+ });
98
+
99
+ test('brand entities have no Thai name fallback polluted by category strings', () => {
100
+ const index = buildCatalogIndex();
101
+ const prsr = index.find(e => e.id === 'prsr');
102
+ assert.ok(prsr, 'prsr entity must exist');
103
+ assert.equal(prsr.nameTh, '');
104
+ });
105
+
106
+ test('get_awards returns not_found instead of the full list when the query matches nothing', async () => {
107
+ const empty = await executeTool('get_awards', { query: 'quantum oscars 2099' });
108
+ assert.equal(empty.status, 'not_found');
109
+ assert.equal(empty.count, 0);
110
+ assert.deepEqual(empty.awards, []);
111
+
112
+ const all = await executeTool('get_awards', {});
113
+ assert.equal(all.status, 'success');
114
+ assert.ok(all.count > 0);
115
+ });
116
+
117
+ test('get_gallery_photos returns not_found instead of the full list when the query matches nothing', async () => {
118
+ const empty = await executeTool('get_gallery_photos', { query: 'quantum oscars 2099' });
119
+ assert.equal(empty.status, 'not_found');
120
+ assert.equal(empty.count, 0);
121
+ assert.deepEqual(empty.photos, []);
122
+ });
123
+
124
+ test('stopword-only catalog queries are flagged as broad matches with an explicit note', () => {
125
+ const matcher = createCatalogMatcher('MV จวีจิ้งอี');
126
+ assert.equal(matcher.broad, true, 'stopword-only query must set the broad flag');
127
+ assert.equal(createCatalogMatcher('In Blossom').broad, undefined, 'real-term queries must not set the broad flag');
128
+ assert.equal(createCatalogMatcher('').broad, undefined, 'empty query is an intentional full listing, not broad');
129
+ });
130
+
131
+ test('search_works appends a broad-query note so the model does not present latest entries as matches', async () => {
132
+ const broad = await executeTool('search_works', { query: 'MV จวีจิ้งอี' });
133
+ assert.equal(broad.status, 'success');
134
+ assert.ok(broad.message, 'broad query must attach an explanatory message');
135
+ assert.match(broad.message, /Broad query/);
136
+
137
+ const exact = await executeTool('search_works', { query: 'In Blossom' });
138
+ assert.equal(exact.status, 'success');
139
+ assert.equal(exact.message, undefined);
140
+ });
141
+
142
+ test('text tool protocol parses against the active tool set, not the full schema', () => {
143
+ const agent = new KikuAgent({
144
+ mcpManager: new McpClientManager({ autoLoad: false }),
145
+ client: {
146
+ provider: 'lmstudio',
147
+ model: 'test-model',
148
+ async chat() { return { role: 'assistant', content: 'test response' }; },
149
+ async checkConnection() { return { connected: true, models: ['test-model'] }; }
150
+ }
151
+ });
152
+
153
+ const content = 'Action: get_awards\nAction Input: {"query": "Oscar"}';
154
+ const unrestricted = agent._extractTextToolCalls(content);
155
+ assert.ok(unrestricted.calls.length === 1, 'without routing, the full schema applies');
156
+
157
+ agent._activeToolSchema = agent.getAllToolsSchema().filter(t => t.function.name === 'search_works');
158
+ const restricted = agent._extractTextToolCalls(content);
159
+ assert.equal(restricted.calls.length, 0, 'tools hidden by the router must not be callable via text protocol');
160
+ });
@@ -0,0 +1,259 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { executeTool, formatCatalogTitle, prepareToolArguments, toolHandlers } from '../tools.js';
5
+
6
+ test('catalog titles do not repeat an alternate title already present', () => {
7
+ assert.equal(formatCatalogTitle('รักกลางสายฝน (Yu Ai / 雨爱)', '雨爱'), 'รักกลางสายฝน (Yu Ai / 雨爱)');
8
+ assert.equal(formatCatalogTitle('In Blossom', '花间令'), 'In Blossom (花间令)');
9
+ });
10
+
11
+ test('discography search reports an empty result as not found', async () => {
12
+ const result = await toolHandlers.search_discography({
13
+ query: 'no-such-kiku-track-xyz',
14
+ type: 'ost',
15
+ });
16
+
17
+ assert.equal(result.status, 'not_found');
18
+ assert.equal(result.count, 0);
19
+ });
20
+
21
+ test('supported drama OST example returns catalog entries', async () => {
22
+ const result = await toolHandlers.search_discography({
23
+ query: 'The Blooms at Ruyi Pavilion',
24
+ type: 'ost',
25
+ });
26
+
27
+ assert.equal(result.status, 'success');
28
+ assert.ok(result.count >= 1);
29
+ });
30
+
31
+ test('global search ignores generic OST wording and still finds the drama', async () => {
32
+ const result = await toolHandlers.global_search({ query: 'In Blossom OST songs' });
33
+
34
+ assert.equal(result.status, 'success');
35
+ assert.ok(result.results.works.some(work => work.title.includes('In Blossom')));
36
+ });
37
+
38
+ test('tool runtime rejects malformed JSON instead of inventing a query argument', () => {
39
+ const prepared = prepareToolArguments('global_search', '{query: broken json}');
40
+ assert.equal(prepared.safe, false);
41
+ assert.match(prepared.error, /Invalid JSON arguments/);
42
+ });
43
+
44
+ test('tool runtime validates required arguments and removes unknown keys', () => {
45
+ assert.equal(prepareToolArguments('global_search', {}).safe, false);
46
+ const prepared = prepareToolArguments('global_search', { query: 'In Blossom', unexpected: true });
47
+ assert.equal(prepared.safe, true);
48
+ assert.deepEqual(prepared.args, { query: 'In Blossom' });
49
+ assert.match(prepared.warnings.join(' '), /unexpected/);
50
+ });
51
+
52
+ test('tool runtime normalizes empty successful collections to not_found', async () => {
53
+ const result = await executeTool('search_works', { year: '1900' });
54
+ assert.equal(result.status, 'not_found');
55
+ assert.equal(result.count, 0);
56
+ assert.match(result.validationWarnings.join(' '), /converted to number/);
57
+ });
58
+
59
+ test('search_works maps category synonyms onto real catalog values (no false not_found)', async () => {
60
+
61
+ const series = await executeTool('search_works', { category: 'series', featuredOnly: true }, { noCache: true });
62
+ assert.equal(series.status, 'success');
63
+ assert.ok(series.count > 0);
64
+ assert.ok(series.works.every(w => ['historical-drama', 'modern-drama'].includes(w.category)));
65
+
66
+ const movie = await executeTool('search_works', { category: 'movie' }, { noCache: true });
67
+ assert.equal(movie.status, 'success');
68
+ assert.ok(movie.count >= 1);
69
+ assert.ok(movie.works.every(w => w.category === 'movie'));
70
+
71
+
72
+ const junk = await executeTool('search_works', { category: 'documentary' }, { noCache: true });
73
+ assert.equal(junk.status, 'not_found');
74
+ });
75
+
76
+ test('manage_skill tool executes learn, list, disable, and remove actions', async () => {
77
+ const memory = {
78
+ preferences: { skillsEnabled: true, userSkills: [] },
79
+ addUserSkill(inst) {
80
+ const s = { id: 'sk_test', instruction: inst };
81
+ this.preferences.userSkills.push(s);
82
+ return s;
83
+ },
84
+ removeUserSkill(id) {
85
+ const idx = this.preferences.userSkills.findIndex(s => s.id === id);
86
+ if (idx >= 0) {
87
+ this.preferences.userSkills.splice(idx, 1);
88
+ return true;
89
+ }
90
+ return false;
91
+ },
92
+ clearUserSkills() { this.preferences.userSkills = []; },
93
+ setPreference(k, v) { this.preferences[k] = v; },
94
+ };
95
+
96
+ const learnRes = await executeTool('manage_skill', { action: 'learn', instruction: 'ตอบสั้นกระชับ' }, { memory });
97
+ assert.equal(learnRes.status, 'success');
98
+ assert.equal(learnRes.skill.instruction, 'ตอบสั้นกระชับ');
99
+
100
+ const listRes = await executeTool('manage_skill', { action: 'list' }, { memory });
101
+ assert.equal(listRes.status, 'success');
102
+ assert.equal(listRes.skills.length, 1);
103
+
104
+ const disableRes = await executeTool('manage_skill', { action: 'disable' }, { memory });
105
+ assert.equal(disableRes.status, 'success');
106
+ assert.equal(memory.preferences.skillsEnabled, false);
107
+
108
+ const removeRes = await executeTool('manage_skill', { action: 'remove', skillId: 'sk_test' }, { memory });
109
+ assert.equal(removeRes.status, 'success');
110
+ assert.equal(memory.preferences.userSkills.length, 0);
111
+ });
112
+
113
+ test('run_subagent tool routes through agent context when available', async () => {
114
+ const mockAgent = {
115
+ async runSubagent(task) {
116
+ return { answer: `Report for ${task}`, toolCalls: [{ name: 'search_works' }], iterations: 1 };
117
+ }
118
+ };
119
+
120
+ const res = await executeTool('run_subagent', { task: 'Verify 2026 drama' }, { agent: mockAgent });
121
+ assert.equal(res.status, 'success');
122
+ assert.equal(res.task, 'Verify 2026 drama');
123
+ assert.match(res.answer, /Report for/);
124
+ assert.equal(res.toolCalls.length, 1);
125
+ });
126
+
127
+ test('run_deep_research and verify_claim route through agent research orchestrator', async () => {
128
+ let verifyModeCalled = false;
129
+ const mockAgent = {
130
+ async runResearch(query, options = {}) {
131
+ if (options.mode === 'verify') verifyModeCalled = true;
132
+ return { answer: `Synthesized for ${query}`, sources: ['https://example.com/source1'], reports: [] };
133
+ }
134
+ };
135
+
136
+ const researchRes = await executeTool('run_deep_research', { question: 'All 2026 plans' }, { agent: mockAgent });
137
+ assert.equal(researchRes.status, 'success');
138
+ assert.equal(researchRes.sources.length, 1);
139
+
140
+ const verifyRes = await executeTool('verify_claim', { claim: 'Is Qian Xiang releasing in 2026' }, { agent: mockAgent });
141
+ assert.equal(verifyRes.status, 'success');
142
+ assert.equal(verifyModeCalled, true);
143
+ });
144
+
145
+ test('globalToolCache stores and returns cached tool execution results', async () => {
146
+ const { globalToolCache, ToolCache } = await import('../tools.js');
147
+ const cache = new ToolCache(10, 5000);
148
+ cache.set('test_key', { data: 123 });
149
+ assert.deepEqual(cache.get('test_key'), { data: 123 });
150
+ assert.equal(cache.get('non_existent'), null);
151
+
152
+ const firstCall = await executeTool('get_awards', {});
153
+ assert.equal(firstCall.status, 'success');
154
+
155
+ const secondCall = await executeTool('get_awards', {});
156
+ assert.equal(secondCall.status, 'success');
157
+ assert.deepEqual(firstCall.awards, secondCall.awards);
158
+ });
159
+
160
+ test('get_brand_endorsements finds Versace and global brand ambassador roles', async () => {
161
+ const versaceRes = await executeTool('get_brand_endorsements', { query: 'Versace', isGlobal: true });
162
+ assert.equal(versaceRes.status, 'success');
163
+ assert.ok(versaceRes.count >= 1);
164
+ assert.ok(versaceRes.brands.some(b => b.brand.toLowerCase().includes('versace')));
165
+
166
+ const prsrRes = await executeTool('get_brand_endorsements', { query: 'PRSR' });
167
+ assert.equal(prsrRes.status, 'success');
168
+ assert.ok(prsrRes.count >= 1);
169
+ assert.ok(prsrRes.brands.some(b => b.brand.includes('PRSR')));
170
+ });
171
+
172
+
173
+ test('createCatalogMatcher uses scoring fuzzy matching for multi-word and misspelled queries', async () => {
174
+ const { createCatalogMatcher } = await import('../tools.js');
175
+ const matcher = createCatalogMatcher('เพลง Ost Ju Jingyi 2024 Yunxi');
176
+ assert.equal(matcher('ตำนานหยุนซี Legend of Yunxi', 'Yunxi'), true);
177
+
178
+ const res = await executeTool('search_works', { query: 'เพลง Ost Ju Jingyi 2024 Yunxi' });
179
+ assert.equal(res.status, 'success');
180
+ assert.ok(res.count > 0);
181
+ assert.ok(res.works.some(w => (w.titleTh || '').includes('หยุนซี') || (w.titleEn || '').toLowerCase().includes('yunxi')));
182
+ });
183
+
184
+ test('empty or not_found tool results are not stored in globalToolCache', async () => {
185
+ const { globalToolCache } = await import('../tools.js');
186
+ const query = 'totally_fake_drama_xyz_never_exists_123';
187
+ const notFound = await executeTool('search_works', { query });
188
+ assert.equal(notFound.status, 'not_found');
189
+
190
+ const cacheKey = `search_works:${JSON.stringify({ query })}`;
191
+ assert.equal(globalToolCache.get(cacheKey), null);
192
+ });
193
+
194
+ test('get_system_stats returns dynamically computed activeYears and metadata', async () => {
195
+ const statsRes = await executeTool('get_system_stats', {});
196
+ assert.equal(statsRes.status, 'success');
197
+ assert.equal(statsRes.sourceType, 'local_database');
198
+ assert.ok(statsRes.dataFreshness);
199
+ assert.match(statsRes.stats.activeYears, /2013 - Present/);
200
+ assert.ok(Array.isArray(statsRes.stats.featured2026Works));
201
+ assert.ok(statsRes.stats.featured2026Works.length > 0);
202
+ });
203
+
204
+ test('tool results distinguish direct links from search URLs with isDirectLink', async () => {
205
+ const worksRes = await executeTool('search_works', { query: 'In Blossom' });
206
+ assert.equal(worksRes.status, 'success');
207
+ const work = worksRes.works[0];
208
+ assert.equal(work.youtubeTrailerIsDirectLink, false);
209
+ if (work.themeSong) {
210
+ assert.equal(typeof work.themeSong.isDirectLink, 'boolean');
211
+ }
212
+
213
+ const discoRes = await executeTool('search_discography', { query: 'In Blossom' });
214
+ assert.equal(discoRes.status, 'success');
215
+ assert.equal(typeof discoRes.songs[0].isDirectLink, 'boolean');
216
+ });
217
+
218
+ test('generic artist-name queries return catalog data instead of false not_found', async () => {
219
+ for (const tool of ['search_works', 'search_discography', 'get_character_roles', 'get_music_videos', 'get_brand_endorsements', 'global_search']) {
220
+ const res = await executeTool(tool, { query: 'Ju Jingyi' }, { noCache: true });
221
+ assert.equal(res.status, 'success', `${tool} should not not_found a generic artist query`);
222
+ assert.ok((res.count ?? 0) > 0, `${tool} should return items for a generic artist query`);
223
+ }
224
+ const gallery = await executeTool('get_gallery_photos', { query: 'Ju Jingyi' }, { noCache: true });
225
+ assert.equal(gallery.status, 'success');
226
+ assert.ok(gallery.count > 0);
227
+ });
228
+
229
+ test('latest-intent queries fall back to the newest-first catalog list', async () => {
230
+ const works = await executeTool('search_works', { query: 'เรื่องล่าสุด' }, { noCache: true });
231
+ assert.equal(works.status, 'success');
232
+ assert.ok(works.count > 0);
233
+
234
+ const songs = await executeTool('search_discography', { query: 'เพลงล่าสุด' }, { noCache: true });
235
+ assert.equal(songs.status, 'success');
236
+ assert.ok(songs.count > 0);
237
+ });
238
+
239
+ test('get_social_feed aggregates douyin and weibo cards', async () => {
240
+ const res = await executeTool('get_social_feed', { limit: 4 }, { noCache: true });
241
+ assert.equal(res.status, 'success');
242
+ assert.ok(res.count > 0);
243
+ assert.ok(res.posts.every(p => p.platform === 'douyin' || p.platform === 'weibo'));
244
+ assert.ok(res.posts[0].content && res.posts[0].content.length > 10);
245
+
246
+ const weibo = await executeTool('get_social_feed', { platform: 'weibo', limit: 2 }, { noCache: true });
247
+ assert.equal(weibo.status, 'success');
248
+ assert.ok(weibo.posts.every(p => p.platform === 'weibo'));
249
+ });
250
+
251
+ test('get_work_detail resolves dash/underscore/space id variants and fuzzy titles', async () => {
252
+ for (const variant of ['in-blossom', 'in_blossom', 'in blossom']) {
253
+ const res = await executeTool('get_work_detail', { workIdOrTitle: variant }, { noCache: true });
254
+ assert.equal(res.status, 'success', `id variant "${variant}" should resolve`);
255
+ assert.match(res.work.id, /in-blossom/);
256
+ }
257
+ const missing = await executeTool('get_work_detail', { workIdOrTitle: 'no-such-work-xyz' }, { noCache: true });
258
+ assert.equal(missing.status, 'not_found');
259
+ });
@@ -0,0 +1,159 @@
1
+ import test, { after } from 'node:test';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import fs from 'node:fs';
5
+ import assert from 'node:assert/strict';
6
+ const isolatedSettingsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kiku-provider-test-'));
7
+ process.env.KIKU_DATA_DIR = isolatedSettingsDir;
8
+ after(() => fs.rmSync(isolatedSettingsDir, { recursive: true, force: true }));
9
+ const {
10
+ DEFAULT_LOCAL_ENDPOINTS,
11
+ getProviderBaseUrl,
12
+ savePersistedProviderBaseUrl,
13
+ setActiveProvider,
14
+ getUnifiedProviderCatalog,
15
+ config
16
+ } = await import("../config.js");
17
+ const { LLMClient } = await import("../llmClient.js");
18
+ const { routeCommand } = await import("../commands.js");
19
+ const { handleProviderGetRoute, handleProviderUpdateRoute } = await import("../../web-chat/routes/providerRoute.js");
20
+
21
+ test('DEFAULT_LOCAL_ENDPOINTS exposes canonical local endpoints', () => {
22
+ assert.equal(DEFAULT_LOCAL_ENDPOINTS.lmstudio, 'http://127.0.0.1:1234/v1');
23
+ assert.equal(DEFAULT_LOCAL_ENDPOINTS.vllm, 'http://127.0.0.1:8000/v1');
24
+ });
25
+
26
+ test('getProviderBaseUrl resolves local engines and custom endpoints correctly', () => {
27
+ const lmUrl = getProviderBaseUrl('lmstudio');
28
+ assert.ok(lmUrl.includes(':1234') || lmUrl.startsWith('http://127.0.0.1:1234'));
29
+
30
+ const vllmUrl = getProviderBaseUrl('vllm');
31
+ assert.ok(vllmUrl.includes(':8000') || vllmUrl.startsWith('http://127.0.0.1:8000'));
32
+
33
+ const customTestUrl = 'http://127.0.0.1:8888/v1';
34
+ savePersistedProviderBaseUrl('vllm', customTestUrl);
35
+ assert.equal(getProviderBaseUrl('vllm'), customTestUrl);
36
+
37
+
38
+ savePersistedProviderBaseUrl('vllm', DEFAULT_LOCAL_ENDPOINTS.vllm);
39
+ assert.equal(getProviderBaseUrl('vllm'), DEFAULT_LOCAL_ENDPOINTS.vllm);
40
+ });
41
+
42
+ test('setActiveProvider updates active provider and in-memory configuration', () => {
43
+ const infoVllm = setActiveProvider('vllm');
44
+ assert.equal(infoVllm.provider, 'vllm');
45
+ assert.equal(config.activeProvider, 'vllm');
46
+ assert.equal(config.lmStudio.baseUrl, getProviderBaseUrl('vllm'));
47
+
48
+ const catalog = getUnifiedProviderCatalog();
49
+ assert.equal(catalog.activeProvider, 'vllm');
50
+ assert.equal(catalog.localEndpoints.vllm.isActive, true);
51
+ assert.equal(catalog.localEndpoints.lmstudio.isActive, false);
52
+
53
+ const infoLm = setActiveProvider('lmstudio');
54
+ assert.equal(infoLm.provider, 'lmstudio');
55
+ assert.equal(config.activeProvider, 'lmstudio');
56
+ assert.equal(config.lmStudio.baseUrl, getProviderBaseUrl('lmstudio'));
57
+
58
+ const catalogLm = getUnifiedProviderCatalog();
59
+ assert.equal(catalogLm.activeProvider, 'lmstudio');
60
+ assert.equal(catalogLm.localEndpoints.lmstudio.isActive, true);
61
+ assert.equal(catalogLm.localEndpoints.vllm.isActive, false);
62
+ });
63
+
64
+ test('LLMClient respects unified baseUrl for vLLM and LM Studio', () => {
65
+ const vllmClient = new LLMClient({ provider: 'vllm' });
66
+ assert.equal(vllmClient.provider, 'vllm');
67
+ assert.equal(vllmClient.baseUrl, getProviderBaseUrl('vllm'));
68
+ assert.equal(vllmClient._buildHeaders()['Authorization'], 'Bearer token-kiku');
69
+
70
+ vllmClient.setBaseUrl('http://127.0.0.1:8008/v1');
71
+ assert.equal(vllmClient.baseUrl, 'http://127.0.0.1:8008/v1');
72
+ assert.equal(getProviderBaseUrl('vllm'), 'http://127.0.0.1:8008/v1');
73
+
74
+ vllmClient.setBaseUrl(DEFAULT_LOCAL_ENDPOINTS.vllm);
75
+ assert.equal(vllmClient.baseUrl, DEFAULT_LOCAL_ENDPOINTS.vllm);
76
+
77
+ const lmClient = new LLMClient({ provider: 'lmstudio' });
78
+ assert.equal(lmClient.provider, 'lmstudio');
79
+ assert.equal(lmClient.baseUrl, getProviderBaseUrl('lmstudio'));
80
+ assert.equal(lmClient._buildHeaders()['Authorization'], 'Bearer lm-studio');
81
+ });
82
+
83
+ test('CLI routeCommand handles /provider url, /url, and /endpoint', async () => {
84
+ const fakeContext = {
85
+ client: new LLMClient({ provider: 'vllm' }),
86
+ agent: null,
87
+ parts: ['/provider', 'url'],
88
+ sub: 'url',
89
+ extra: '',
90
+ };
91
+
92
+ const res1 = await routeCommand('/provider url', fakeContext);
93
+ assert.equal(res1, 'HANDLED');
94
+
95
+ const res2 = await routeCommand('/url', fakeContext);
96
+ assert.equal(res2, 'HANDLED');
97
+
98
+ const res3 = await routeCommand('/endpoint', fakeContext);
99
+ assert.equal(res3, 'HANDLED');
100
+
101
+ const res4 = await routeCommand('/provider url vllm http://127.0.0.1:8000/v1', {
102
+ ...fakeContext,
103
+ parts: ['/provider', 'url', 'vllm', 'http://127.0.0.1:8000/v1'],
104
+ sub: 'url'
105
+ });
106
+ assert.equal(res4, 'HANDLED');
107
+ });
108
+
109
+ import { Readable } from 'node:stream';
110
+
111
+ test('Web Chat /api/provider GET and POST routes work as unified manager', async () => {
112
+ let resStatus = null;
113
+ let resPayload = null;
114
+
115
+ const mockRes = {
116
+ writeHead(status, headers) {
117
+ resStatus = status;
118
+ },
119
+ end(content) {
120
+ if (content) resPayload = JSON.parse(content);
121
+ },
122
+ setHeader() {}
123
+ };
124
+
125
+ await handleProviderGetRoute({}, mockRes);
126
+ assert.equal(resStatus, 200);
127
+ assert.ok(resPayload);
128
+ assert.equal(resPayload.status, 'ok');
129
+ assert.ok(resPayload.localEndpoints.lmstudio);
130
+ assert.ok(resPayload.localEndpoints.vllm);
131
+
132
+
133
+ const updateReq = Readable.from([
134
+ Buffer.from(JSON.stringify({
135
+ provider: 'vllm',
136
+ baseUrl: 'http://127.0.0.1:8000/v1'
137
+ }))
138
+ ]);
139
+
140
+ let postStatus = null;
141
+ let postPayload = null;
142
+ const mockPostRes = {
143
+ writeHead(status, headers) {
144
+ postStatus = status;
145
+ },
146
+ end(content) {
147
+ if (content) postPayload = JSON.parse(content);
148
+ },
149
+ setHeader() {}
150
+ };
151
+
152
+ updateReq.socket = { remoteAddress: '127.0.0.1' };
153
+ await handleProviderUpdateRoute(updateReq, mockPostRes);
154
+ assert.equal(postStatus, 200);
155
+ assert.equal(postPayload.activeProvider, 'vllm');
156
+ assert.equal(postPayload.activeBaseUrl, 'http://127.0.0.1:8000/v1');
157
+
158
+ setActiveProvider('lmstudio');
159
+ });