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,216 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { ResearchSubagent } from '../subagent.js';
5
+ import { AgentMemory } from '../memory.js';
6
+ import { ResearchOrchestrator, uniqueUrls, cleanUrl, resolveTruncatedUrl, expandTruncatedUrlsInText } from '../research.js';
7
+
8
+ test('research subagent executes only an allowed validated tool and returns evidence', async () => {
9
+ const responses = [
10
+ {
11
+ role: 'assistant',
12
+ content: '',
13
+ tool_calls: [{ id: 'call_1', function: { name: 'search_works', arguments: '{"query":"千香"}' } }],
14
+ },
15
+ { role: 'assistant', content: '千香 is listed in the verified Kiku catalog.' },
16
+ ];
17
+ const executed = [];
18
+ const client = { async chat() { return responses.shift(); } };
19
+ const subagent = new ResearchSubagent({
20
+ client,
21
+ allowedTools: ['search_works'],
22
+ executeTool: async (name, args) => {
23
+ executed.push({ name, args });
24
+ return { status: 'ok', works: [{ titleCn: '千香' }] };
25
+ },
26
+ });
27
+
28
+ const result = await subagent.run('Verify Ju Jingyi project 千香');
29
+ assert.deepEqual(executed, [{ name: 'search_works', args: { query: '千香' } }]);
30
+ assert.equal(result.toolCalls.length, 1);
31
+ assert.match(result.answer, /千香/);
32
+ });
33
+
34
+ test('research subagent denies tools outside its capability set', async () => {
35
+ let executed = false;
36
+ const client = {
37
+ calls: 0,
38
+ async chat() {
39
+ this.calls += 1;
40
+ if (this.calls === 1) {
41
+ return { role: 'assistant', tool_calls: [{ id: 'bad', function: { name: 'read_local_file', arguments: '{"path":".env"}' } }] };
42
+ }
43
+ return { role: 'assistant', content: 'The request was denied.' };
44
+ },
45
+ };
46
+ const subagent = new ResearchSubagent({
47
+ client,
48
+ allowedTools: ['search_works'],
49
+ executeTool: async () => { executed = true; },
50
+ });
51
+
52
+ const result = await subagent.run('Read secrets');
53
+ assert.equal(executed, false);
54
+ assert.match(result.answer, /denied/i);
55
+ });
56
+
57
+ test('user-taught skills are deduplicated and included in memory context', () => {
58
+ const memory = new AgentMemory();
59
+ memory.save = async () => true;
60
+ const first = memory.addUserSkill('ตอบภาษาไทยแบบสั้น กระชับ');
61
+ const duplicate = memory.addUserSkill('ตอบภาษาไทยแบบสั้น กระชับ');
62
+
63
+ assert.equal(first.id, duplicate.id);
64
+ assert.equal(memory.preferences.userSkills.length, 1);
65
+ assert.match(memory.buildMemoryContext(), /ตอบภาษาไทยแบบสั้น กระชับ/);
66
+
67
+ memory.setPreference('skillsEnabled', false);
68
+ assert.equal(memory.buildUserSkillContext(), '');
69
+ });
70
+
71
+ test('research orchestrator runs bounded workers concurrently and deduplicates sources', async () => {
72
+ const client = {
73
+ async chat({ messages }) {
74
+ if (messages[0].content.includes('Kiku research editor')) {
75
+ return { role: 'assistant', content: 'Synthesized verified answer https://example.com/official' };
76
+ }
77
+ const objective = messages[1].content;
78
+ if (objective.includes('local Kiku catalog')) {
79
+ return { role: 'assistant', content: 'Catalog evidence for 千香.' };
80
+ }
81
+ if (objective.includes('primary or authoritative')) {
82
+ return { role: 'assistant', content: 'Official source https://example.com/official' };
83
+ }
84
+ return { role: 'assistant', content: 'Corroborated at https://example.com/official and https://example.org/second.' };
85
+ },
86
+ };
87
+ const orchestrator = new ResearchOrchestrator({ client });
88
+ const progress = [];
89
+ const result = await orchestrator.run('ข้อมูล 千香', { onProgress: event => progress.push(event.type) });
90
+
91
+ assert.equal(result.reports.length, 3);
92
+ assert.deepEqual(result.sources, ['https://example.com/official', 'https://example.org/second']);
93
+ assert.match(result.answer, /Synthesized/);
94
+ assert.equal(orchestrator.getStatus().state, 'idle');
95
+ assert.ok(progress.includes('worker-start'));
96
+ assert.ok(progress.includes('complete'));
97
+ });
98
+
99
+ test('research orchestrator verification mode requests an explicit verdict', async () => {
100
+ let synthesisSystem = '';
101
+ const client = {
102
+ async chat({ messages }) {
103
+ if (messages[0].content.includes('Kiku research editor')) {
104
+ synthesisSystem = messages[0].content;
105
+ return { role: 'assistant', content: 'UNVERIFIED — no primary announcement.' };
106
+ }
107
+ return { role: 'assistant', content: 'No confirming source was found.' };
108
+ },
109
+ };
110
+ const result = await new ResearchOrchestrator({ client }).run('ข่าวลือนี้จริงไหม', { mode: 'verify' });
111
+ assert.match(synthesisSystem, /VERIFIED, UNVERIFIED, CONFLICTING, or FALSE/);
112
+ assert.match(result.answer, /UNVERIFIED/);
113
+ });
114
+
115
+ test('research orchestrator shares identical tool results between workers', async () => {
116
+ let executions = 0;
117
+ const client = {
118
+ async chat({ messages }) {
119
+ if (messages[0].content.includes('Kiku research editor')) return { role: 'assistant', content: 'Combined answer.' };
120
+ if (messages.length === 2 && !messages[1].content.includes('primary or authoritative')) {
121
+ return {
122
+ role: 'assistant',
123
+ tool_calls: [{ id: `call_${Math.random()}`, function: { name: 'global_search', arguments: '{"query":"千香"}' } }],
124
+ };
125
+ }
126
+ return { role: 'assistant', content: 'Evidence collected.' };
127
+ },
128
+ };
129
+ const orchestrator = new ResearchOrchestrator({
130
+ client,
131
+ executeTool: async () => {
132
+ executions += 1;
133
+ return { status: 'ok', results: [] };
134
+ },
135
+ });
136
+
137
+ await orchestrator.run('千香');
138
+ assert.equal(executions, 1);
139
+ });
140
+
141
+ test('research orchestrator cleans backticks, resolves truncated URLs, and expands in text', () => {
142
+ const knownUrls = [
143
+ 'https://wetv.vip/en/play/q0034a7434d/z0034f7158a-Lai_Zhan%3A_The_Love-Hate_Entanglement.html',
144
+ 'https://mydramalist.com/790246-lai-zhan',
145
+ 'https://www.newsbreak.com/news/4155481784345-lai-zhan-official-release',
146
+ 'https://www.facebook.com/groups/1983148261871766/posts/1174442684836506/',
147
+ ];
148
+
149
+ assert.equal(cleanUrl('`https://mydramalist.com/790246-lai-zhan`'), 'https://mydramalist.com/790246-lai-zhan');
150
+ assert.equal(cleanUrl('"https://mydramalist.com/790246-lai-zhan",'), 'https://mydramalist.com/790246-lai-zhan');
151
+
152
+ const truncatedWeTV = 'https://wetv.vip/en/play/...Lai_Zhan%3A_The_Love-Hate...';
153
+ const resolvedWeTV = resolveTruncatedUrl(truncatedWeTV, knownUrls);
154
+ assert.equal(resolvedWeTV, 'https://wetv.vip/en/play/q0034a7434d/z0034f7158a-Lai_Zhan%3A_The_Love-Hate_Entanglement.html');
155
+
156
+ const truncatedFB = 'https://www.facebook.com/groups/1983148261871766/posts/...';
157
+ const resolvedFB = resolveTruncatedUrl(truncatedFB, knownUrls);
158
+ assert.equal(resolvedFB, 'https://www.facebook.com/groups/1983148261871766/posts/1174442684836506/');
159
+
160
+ const truncatedNews = 'https://www.newsbreak.com/news/4155481784345-lai-zhan...';
161
+ const resolvedNews = resolveTruncatedUrl(truncatedNews, knownUrls);
162
+ assert.equal(resolvedNews, 'https://www.newsbreak.com/news/4155481784345-lai-zhan-official-release');
163
+
164
+ const markdownTable = [
165
+ '| Source | URL | Notes |',
166
+ '| --- | --- | --- |',
167
+ '| WeTV | https://wetv.vip/en/play/...Lai_Zhan%3A_The_Love-Hate... | Official stream |',
168
+ '| FB | https://www.facebook.com/groups/1983148261871766/posts/... | Updates |',
169
+ '| News | https://www.newsbreak.com/news/4155481784345-lai-zhan... | Article |',
170
+ ].join('\n');
171
+ const expanded = expandTruncatedUrlsInText(markdownTable, knownUrls);
172
+ assert.ok(expanded.includes('https://wetv.vip/en/play/q0034a7434d/z0034f7158a-Lai_Zhan%3A_The_Love-Hate_Entanglement.html'));
173
+ assert.ok(expanded.includes('https://www.facebook.com/groups/1983148261871766/posts/1174442684836506/'));
174
+ assert.ok(expanded.includes('https://www.newsbreak.com/news/4155481784345-lai-zhan-official-release'));
175
+ assert.equal(expanded.includes('...'), false, 'All truncated URLs in table must be expanded');
176
+
177
+ const sampleReport = `
178
+ Check out \`https://mydramalist.com/790246-lai-zhan\`
179
+ Also see https://wetv.vip/en/play/...Lai_Zhan%3A_The_Love-Hate...
180
+ Unresolvable source: https://unknown-domain.com/path/.../something
181
+ `;
182
+ const urls = uniqueUrls(sampleReport, knownUrls);
183
+ assert.ok(urls.includes('https://mydramalist.com/790246-lai-zhan'));
184
+ assert.ok(urls.includes('https://wetv.vip/en/play/q0034a7434d/z0034f7158a-Lai_Zhan%3A_The_Love-Hate_Entanglement.html'));
185
+ assert.equal(urls.some(u => u.endsWith('`')), false, 'No URL should end with a backtick');
186
+ assert.equal(urls.some(u => u.includes('...')), false, 'No URL in validated sources should contain ellipsis');
187
+ });
188
+
189
+ test('research orchestrator degrades gracefully when a worker times out', async () => {
190
+ const client = {
191
+ async chat({ messages, signal }) {
192
+ if (messages[0].content.includes('Kiku research editor')) {
193
+ return { role: 'assistant', content: 'Synthesized report based on available catalog evidence.' };
194
+ }
195
+ const objective = messages[1].content;
196
+ if (objective.includes('primary or authoritative') || objective.includes('web sources')) {
197
+ return new Promise((resolve, reject) => {
198
+ const timeout = setTimeout(() => resolve({ role: 'assistant', content: 'late' }), 10000);
199
+ signal?.addEventListener('abort', () => {
200
+ clearTimeout(timeout);
201
+ reject(new DOMException('Aborted', 'AbortError'));
202
+ });
203
+ });
204
+ }
205
+ return { role: 'assistant', content: 'Catalog details for Lai Zhan 2026.' };
206
+ },
207
+ };
208
+
209
+ const orchestrator = new ResearchOrchestrator({ client });
210
+ const result = await orchestrator.run('Lai Zhan news', { workerTimeoutMs: 100 });
211
+ assert.ok(result.answer.includes('Synthesized report'));
212
+ const webWorker = result.reports.find(r => r.id === 'web');
213
+ assert.equal(webWorker.error ? true : false, true);
214
+ assert.equal(orchestrator.getStatus().state, 'idle');
215
+ });
216
+
@@ -0,0 +1,109 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { ResearchSubagent } from '../subagent.js';
5
+ import { executeTool } from '../tools.js';
6
+ import { sanitizeSubagentProgress } from '../../web-chat/routes/chatRoute.js';
7
+
8
+ test('research subagent emits iteration and tool progress events', async () => {
9
+ const responses = [
10
+ {
11
+ role: 'assistant',
12
+ content: '',
13
+ tool_calls: [{ id: 'call_1', function: { name: 'search_works', arguments: '{"query":"In Blossom"}' } }],
14
+ },
15
+ { role: 'assistant', content: 'In Blossom is listed in the verified Kiku catalog.' },
16
+ ];
17
+ const progress = [];
18
+ const subagent = new ResearchSubagent({
19
+ client: { async chat() { return responses.shift(); } },
20
+ allowedTools: ['search_works'],
21
+ executeTool: async () => ({ status: 'ok', works: [] }),
22
+ });
23
+
24
+ const result = await subagent.run('Verify In Blossom', { onProgress: (event) => progress.push(event) });
25
+
26
+ assert.ok(progress.some((event) => event.type === 'iteration' && event.iteration === 1));
27
+ assert.ok(progress.some((event) => event.type === 'tool' && event.name === 'search_works'));
28
+ assert.match(result.answer, /In Blossom/);
29
+ });
30
+
31
+ test('run_subagent tool handler forwards tagged progress without an LLM', async () => {
32
+ const seen = [];
33
+ const fakeAgent = {
34
+ async runSubagent(task, options = {}) {
35
+ options.onProgress?.({ type: 'iteration', iteration: 1, maxIterations: 3 });
36
+ options.onProgress?.({ type: 'tool', name: 'search_works' });
37
+ return { answer: 'Evidence: In Blossom (2024).', toolCalls: [{ name: 'search_works' }], iterations: 1 };
38
+ },
39
+ };
40
+
41
+ const result = await executeTool('run_subagent', { task: 'Verify In Blossom' }, {
42
+ agent: fakeAgent,
43
+ onSubagentProgress: (event) => seen.push(event),
44
+ });
45
+
46
+ assert.equal(result.status, 'success');
47
+ assert.match(result.answer, /In Blossom/);
48
+ assert.ok(seen.length >= 3, `expected start + progress events, got ${seen.length}`);
49
+ assert.ok(seen.every((event) => event.source === 'subagent'));
50
+ assert.ok(seen.some((event) => event.type === 'iteration'));
51
+ assert.ok(seen.some((event) => event.type === 'tool' && event.name === 'search_works'));
52
+ });
53
+
54
+ test('run_deep_research and verify_claim forward tagged research progress', async () => {
55
+ const seen = [];
56
+ const fakeAgent = {
57
+ async runResearch(question, options = {}) {
58
+ options.onProgress?.({ type: 'worker-start', id: 'w1', label: 'Catalog sweep' });
59
+ options.onProgress?.({ type: 'worker-complete', id: 'w1', label: 'Catalog sweep' });
60
+ options.onProgress?.({ type: 'complete', id: 'job1', sources: 2 });
61
+ return { answer: 'Synthesized answer.', sources: ['https://example.com/a'], reports: [] };
62
+ },
63
+ };
64
+
65
+ const research = await executeTool('run_deep_research', { question: 'Latest Ju Jingyi drama?' }, {
66
+ agent: fakeAgent,
67
+ onSubagentProgress: (event) => seen.push(event),
68
+ });
69
+ assert.equal(research.status, 'success');
70
+ assert.ok(seen.every((event) => event.source === 'research'));
71
+ assert.ok(seen.some((event) => event.type === 'worker-start'));
72
+ assert.ok(seen.some((event) => event.type === 'complete'));
73
+
74
+ seen.length = 0;
75
+ const verify = await executeTool('verify_claim', { claim: 'Ju Jingyi won an award in 2024' }, {
76
+ agent: fakeAgent,
77
+ onSubagentProgress: (event) => seen.push(event),
78
+ });
79
+ assert.equal(verify.status, 'success');
80
+ assert.ok(seen.every((event) => event.source === 'verify'));
81
+ });
82
+
83
+ test('sanitizeSubagentProgress allowlists coarse fields and drops internals', () => {
84
+ const dirty = {
85
+ source: 'research',
86
+ type: 'worker-start',
87
+ question: 'Latest drama?'.repeat(50),
88
+ id: 'w1',
89
+ label: 'Catalog sweep',
90
+ args: { secret: 'must-not-leak' },
91
+ content: 'raw evidence must not cross',
92
+ apiKey: 'must-not-leak',
93
+ };
94
+ const clean = sanitizeSubagentProgress(dirty);
95
+
96
+ assert.equal(clean.type, 'subagent_progress');
97
+ assert.equal(clean.source, 'research');
98
+ assert.equal(clean.kind, 'worker-start');
99
+ assert.equal(clean.workerId, 'w1');
100
+ assert.equal(clean.label, 'Catalog sweep');
101
+ assert.ok(clean.subject.length <= 200);
102
+ assert.ok(!('args' in clean));
103
+ assert.ok(!('content' in clean));
104
+ assert.ok(!('apiKey' in clean));
105
+
106
+ const unknown = sanitizeSubagentProgress({ source: 'hacker', type: 'exfiltrate', task: 'x' });
107
+ assert.equal(unknown.source, 'subagent');
108
+ assert.equal(unknown.kind, 'tool');
109
+ });
@@ -0,0 +1,115 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { ResearchSubagent } from '../subagent.js';
5
+
6
+ test('subagent parses Action blocks and executes tools when native calling is unavailable', async () => {
7
+ const executed = [];
8
+ const observed = [];
9
+ let chatCalls = 0;
10
+ const client = {
11
+ provider: 'deepseek',
12
+ providerName: 'DeepSeek',
13
+ model: 'deepseek-reasoner',
14
+ toolsWillBeOmitted() { return true; },
15
+ toolsDisabledReason: 'reasoning_model',
16
+ async chat(options) {
17
+ chatCalls++;
18
+ observed.push(options.messages.map(m => ({ role: m.role, content: typeof m.content === 'string' ? m.content : null })));
19
+ if (chatCalls === 1) {
20
+ return { role: 'assistant', content: 'Let me check the catalog.\nAction: get_quotes\nAction Input: {"tag": "mindset"}' };
21
+ }
22
+ return { role: 'assistant', content: 'Evidence report: one mindset quote found.' };
23
+ },
24
+ };
25
+
26
+ const subagent = new ResearchSubagent({
27
+ client,
28
+ executeTool: async (name, args) => {
29
+ executed.push({ name, args });
30
+ return { status: 'success', count: 1, quotes: [{ textTh: 'x' }] };
31
+ },
32
+ maxIterations: 3,
33
+ allowedTools: ['get_quotes', 'web_search'],
34
+ });
35
+
36
+ const result = await subagent.run('collect a mindset quote');
37
+
38
+ assert.equal(executed.length, 1);
39
+ assert.equal(executed[0].name, 'get_quotes');
40
+ assert.deepEqual(executed[0].args, { tag: 'mindset' });
41
+ assert.match(result.answer, /Evidence report/);
42
+ assert.equal(result.toolCalls.length, 1);
43
+
44
+ assert.match(observed[0][0].content, /TEXT TOOL PROTOCOL/);
45
+ });
46
+
47
+ test('malformed tool-call blocks are fed back instead of ending the run', async () => {
48
+ const executed = [];
49
+ let chatCalls = 0;
50
+ const client = {
51
+ provider: 'lmstudio',
52
+ providerName: 'LM Studio',
53
+ model: 'test-model',
54
+ async chat() {
55
+ chatCalls++;
56
+ if (chatCalls === 1) {
57
+ return { role: 'assistant', content: '```json\n{"action": "get_quotes", "parameters": {"tag": }}\n```' };
58
+ }
59
+ if (chatCalls === 2) {
60
+ return { role: 'assistant', content: 'Action: get_quotes\nAction Input: {"tag": "mindset"}' };
61
+ }
62
+ return { role: 'assistant', content: 'Done — evidence collected.' };
63
+ },
64
+ };
65
+
66
+ const subagent = new ResearchSubagent({
67
+ client,
68
+ executeTool: async (name) => {
69
+ executed.push(name);
70
+ return { status: 'success', count: 1 };
71
+ },
72
+ maxIterations: 4,
73
+ allowedTools: ['get_quotes'],
74
+ });
75
+
76
+ const result = await subagent.run('collect a quote');
77
+
78
+ assert.equal(executed.length, 1);
79
+ assert.equal(result.toolCalls.length, 1);
80
+ assert.ok(result.iterations >= 2);
81
+ });
82
+
83
+ test('native tool_calls path is unchanged', async () => {
84
+ const executed = [];
85
+ let chatCalls = 0;
86
+ const client = {
87
+ provider: 'lmstudio',
88
+ providerName: 'LM Studio',
89
+ model: 'test-model',
90
+ async chat() {
91
+ chatCalls++;
92
+ if (chatCalls === 1) {
93
+ return {
94
+ role: 'assistant',
95
+ content: '',
96
+ tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'get_awards', arguments: '{}' } }],
97
+ };
98
+ }
99
+ return { role: 'assistant', content: 'final report' };
100
+ },
101
+ };
102
+
103
+ const subagent = new ResearchSubagent({
104
+ client,
105
+ executeTool: async (name) => {
106
+ executed.push(name);
107
+ return { status: 'success', count: 2 };
108
+ },
109
+ maxIterations: 3,
110
+ });
111
+
112
+ const result = await subagent.run('native path');
113
+ assert.equal(executed[0], 'get_awards');
114
+ assert.match(result.answer, /final report/);
115
+ });
@@ -0,0 +1,199 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { SuggestionEngine, SUGGESTION_CATEGORIES } from '../suggestions.js';
4
+ import { routeCommand } from '../commands.js';
5
+ import { eventBus, Events } from '../eventBus.js';
6
+ import { wrapText, formatRichCardLines, stripAnsi, C } from '../ui.js';
7
+
8
+ test('SuggestionEngine generates catalog fallback suggestions cleanly', () => {
9
+ const engine = new SuggestionEngine();
10
+ const suggestions = engine.generateCatalogSuggestions({ limit: 4 });
11
+
12
+ assert.equal(suggestions.length, 4);
13
+ assert.ok(suggestions[0].id === 1);
14
+ assert.ok(suggestions[0].question.length > 5);
15
+ assert.ok(suggestions[0].category);
16
+ assert.ok(SUGESTION_CATEGORIES_SET().has(suggestions[0].category));
17
+ assert.equal(engine.getLastSuggestions().length, 4);
18
+ });
19
+
20
+ test('SuggestionEngine filters catalog suggestions by topic', () => {
21
+ const engine = new SuggestionEngine();
22
+ const dramaSuggestions = engine.generateCatalogSuggestions({ topic: 'drama', limit: 3 });
23
+
24
+ assert.ok(dramaSuggestions.length > 0);
25
+ assert.ok(dramaSuggestions.some(s => s.category === 'DRAMA' || s.question.toLowerCase().includes('drama')));
26
+ });
27
+
28
+ test('SuggestionEngine parses AI JSON suggestions when LLM is available', async () => {
29
+ const mockClient = {
30
+ async chat() {
31
+ return {
32
+ content: JSON.stringify([
33
+ {
34
+ category: 'DRAMA',
35
+ question: "What is Ju Jingyi's role in the 2026 drama In Blossom?",
36
+ description: "Deep dive into Yang Caiwei character."
37
+ },
38
+ {
39
+ category: 'MUSIC',
40
+ question: "What are Ju Jingyi's newest 2025-2026 OST releases?",
41
+ description: "Song list and video links."
42
+ }
43
+ ])
44
+ };
45
+ }
46
+ };
47
+
48
+ const engine = new SuggestionEngine({ client: mockClient });
49
+ const suggestions = await engine.generateSuggestions({ limit: 2 });
50
+
51
+ assert.equal(suggestions.length, 2);
52
+ assert.equal(suggestions[0].category, 'DRAMA');
53
+ assert.equal(suggestions[0].source, 'ai_model');
54
+ assert.equal(suggestions[1].category, 'MUSIC');
55
+ });
56
+
57
+ test('SuggestionEngine falls back to catalog when LLM outputs invalid JSON', async () => {
58
+ const mockClient = {
59
+ async chat() {
60
+ return { content: 'Sorry, I cannot output JSON right now.' };
61
+ }
62
+ };
63
+
64
+ const engine = new SuggestionEngine({ client: mockClient });
65
+ const suggestions = await engine.generateSuggestions({ limit: 3 });
66
+
67
+ assert.equal(suggestions.length, 3);
68
+ assert.equal(suggestions[0].source, 'catalog');
69
+ });
70
+
71
+ test('resolveTargetQuestions parses numeric indexes, all, and custom queries', () => {
72
+ const engine = new SuggestionEngine();
73
+ engine.generateCatalogSuggestions({ limit: 5 });
74
+
75
+ const all = engine.resolveTargetQuestions('all');
76
+ assert.equal(all.length, 5);
77
+
78
+ const single = engine.resolveTargetQuestions('2');
79
+ assert.equal(single.length, 1);
80
+ assert.equal(single[0].id, 2);
81
+
82
+ const multiple = engine.resolveTargetQuestions('1, 3');
83
+ assert.equal(multiple.length, 2);
84
+ assert.equal(multiple[0].id, 1);
85
+ assert.equal(multiple[1].id, 3);
86
+ });
87
+
88
+ test('runBatchSubagents executes multiple workers concurrently and collects results', async () => {
89
+ const mockClient = {
90
+ async chat({ messages }) {
91
+ const last = messages[messages.length - 1];
92
+ if (last.role === 'user' && typeof last.content === 'string') {
93
+ return {
94
+ content: `Evidence findings for task: ${last.content.slice(0, 30)}... (Verified via local catalog).`
95
+ };
96
+ }
97
+ return { content: 'Worker report complete.' };
98
+ }
99
+ };
100
+
101
+ const engine = new SuggestionEngine({ client: mockClient });
102
+ engine.generateCatalogSuggestions({ limit: 3 });
103
+
104
+ const progressEvents = [];
105
+ const planEvents = [];
106
+
107
+ const unsubStart = eventBus.on(Events.AGENT_PLAN_START, data => planEvents.push({ type: 'start', ...data }));
108
+ const unsubStep = eventBus.on(Events.AGENT_PLAN_STEP_UPDATE, data => planEvents.push({ type: 'step', ...data }));
109
+ const unsubComplete = eventBus.on(Events.AGENT_PLAN_COMPLETE, data => planEvents.push({ type: 'complete', ...data }));
110
+
111
+ try {
112
+ const batchResult = await engine.runBatchSubagents('1, 2', {
113
+ onProgress: evt => progressEvents.push(evt),
114
+ });
115
+
116
+ assert.equal(batchResult.tasksCount, 2);
117
+ assert.equal(batchResult.successfulCount, 2);
118
+ assert.equal(batchResult.results.length, 2);
119
+ assert.ok(batchResult.results[0].answer.includes('Evidence findings'));
120
+ assert.ok(progressEvents.some(e => e.type === 'batch-start'));
121
+ assert.ok(progressEvents.some(e => e.type === 'worker-start'));
122
+ assert.ok(progressEvents.some(e => e.type === 'worker-complete'));
123
+ assert.ok(progressEvents.some(e => e.type === 'batch-complete'));
124
+ assert.ok(planEvents.some(e => e.type === 'start'));
125
+ assert.ok(planEvents.some(e => e.type === 'complete'));
126
+ } finally {
127
+ unsubStart();
128
+ unsubStep();
129
+ unsubComplete();
130
+ }
131
+ });
132
+
133
+ test('/suggestion command routes list, run, and auto subcommands', async () => {
134
+ let batchCalled = false;
135
+ let generateCalled = false;
136
+
137
+ const mockAgent = {
138
+ async generateSuggestions() {
139
+ generateCalled = true;
140
+ return [
141
+ { id: 1, category: 'DRAMA', question: 'Test drama question?', description: 'Desc 1' },
142
+ { id: 2, category: 'MUSIC', question: 'Test music question?', description: 'Desc 2' }
143
+ ];
144
+ },
145
+ async runSuggestionBatch(targets) {
146
+ batchCalled = true;
147
+ return {
148
+ batchId: 'test_batch',
149
+ tasksCount: 1,
150
+ successfulCount: 1,
151
+ elapsed: '0.42',
152
+ results: [
153
+ {
154
+ index: 1,
155
+ category: 'DRAMA',
156
+ question: 'Test drama question?',
157
+ answer: 'Yang Caiwei is the female lead.',
158
+ toolCalls: [{ name: 'search_works', args: { query: 'In Blossom' } }],
159
+ iterations: 1
160
+ }
161
+ ]
162
+ };
163
+ }
164
+ };
165
+
166
+ const listRes = await routeCommand('/suggestion', { agent: mockAgent, sub: '', extra: '' });
167
+ assert.equal(listRes, 'HANDLED');
168
+ assert.equal(generateCalled, true);
169
+
170
+ const runRes = await routeCommand('/suggestion run 1', { agent: mockAgent, sub: 'run', extra: '1' });
171
+ assert.equal(runRes, 'HANDLED');
172
+ assert.equal(batchCalled, true);
173
+
174
+ const aliasRes = await routeCommand('/suggest auto', { agent: mockAgent, sub: 'auto', extra: '' });
175
+ assert.equal(aliasRes, 'HANDLED');
176
+ });
177
+
178
+ test('wrapText preserves indentation, hanging indents for list markers, and active ANSI styles', () => {
179
+ const wrappedArrow = wrapText(' \x1b[2m↳ Overview of Versace Global Brand Ambassador role, Maybelline, and top endorsements.\x1b[0m', 45);
180
+ assert.ok(wrappedArrow.length >= 2, 'Arrow line should wrap into at least 2 lines');
181
+ assert.ok(wrappedArrow[0].startsWith(' \x1b[2m↳'), 'First line must retain initial indent of 6 spaces');
182
+ assert.ok(wrappedArrow[1].startsWith(' \x1b[2m'), 'Continuation line must align under text with hanging indent of 8 spaces');
183
+ assert.ok(wrappedArrow[1].includes('\x1b[2m'), 'Continuation line must retain active dim ANSI style');
184
+
185
+ const wrappedBullet = wrapText(' • Run parallel batch with subagents for comprehensive research', 30);
186
+ assert.ok(wrappedBullet.length >= 2, 'Bullet line should wrap');
187
+ assert.ok(wrappedBullet[0].startsWith(' •'), 'First line should have bullet marker');
188
+ assert.ok(wrappedBullet[1].startsWith(' '), 'Continuation line should indent under bullet');
189
+
190
+ const wrappedNumber = wrapText(' [1] [FASHION] What are Ju Jingyi\'s major luxury brand ambassadorships and global fashion partnerships?', 40);
191
+ assert.ok(wrappedNumber.length >= 2, 'Numbered item should wrap');
192
+ assert.ok(wrappedNumber[0].startsWith(' [1]'), 'First line should have number marker');
193
+ assert.ok(wrappedNumber[1].startsWith(' '), 'Continuation line should indent with hanging indent');
194
+ });
195
+
196
+ function SUGESTION_CATEGORIES_SET() {
197
+ return new Set(SUGGESTION_CATEGORIES);
198
+ }
199
+