kiku-agent 1.3.0 → 1.3.2

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 (155) hide show
  1. package/README.md +7 -7
  2. package/package.json +4 -7
  3. package/server/agent/agentCore.js +2 -2
  4. package/server/agent/catalogEntityResolver.js +8 -0
  5. package/server/agent/cli.js +4 -0
  6. package/server/agent/eval/AGENTS.md +228 -0
  7. package/server/agent/eval/analyze.py +684 -0
  8. package/server/agent/eval/gates.json +15 -0
  9. package/server/agent/eval/modules/analysis/compare.js +198 -0
  10. package/server/agent/eval/modules/analysis/gates.js +72 -0
  11. package/server/agent/eval/modules/analysis/judge.js +70 -0
  12. package/server/agent/eval/modules/core/cli.js +453 -0
  13. package/server/agent/eval/modules/core/config.js +60 -0
  14. package/server/agent/eval/modules/core/store.js +236 -0
  15. package/server/agent/eval/modules/evaluation/cases.js +689 -0
  16. package/server/agent/eval/modules/evaluation/runner.js +298 -0
  17. package/server/agent/eval/modules/evaluation/scoring.js +351 -0
  18. package/server/agent/eval/modules/evaluation/worker.js +122 -0
  19. package/server/agent/eval/modules/reporting/benchmark.js +265 -0
  20. package/server/agent/eval/modules/reporting/benchmarkPng.js +487 -0
  21. package/server/agent/eval/modules/reporting/dashboard.js +1406 -0
  22. package/server/agent/eval/modules/server/evalApi.js +1099 -0
  23. package/server/agent/eval/modules/ui/evalUi.js +4796 -0
  24. package/server/agent/eval/modules/utils/smokes.js +119 -0
  25. package/server/agent/ink/App.jsx +348 -0
  26. package/server/agent/ink/Panel.jsx +54 -0
  27. package/server/agent/ink/PromptInput.jsx +321 -0
  28. package/server/agent/ink/TaskPlanPanel.jsx +88 -0
  29. package/server/agent/ink/ThinkingPanel.jsx +68 -0
  30. package/server/agent/ink/ToolResult.jsx +133 -0
  31. package/server/agent/ink/main.jsx +195 -0
  32. package/server/agent/ink/useEventBus.js +218 -0
  33. package/server/agent/memory/contextAllocator.js +214 -0
  34. package/server/agent/memory/hierarchicalStore.js +523 -0
  35. package/server/agent/memory/reflectionWorker.js +110 -0
  36. package/server/agent/prompts.js +1 -1
  37. package/server/agent/telemetry/hardwareTiers.js +332 -0
  38. package/server/agent/telemetry/runtimeWatchdog.js +66 -0
  39. package/server/agent/telemetry/techPowerUpClient.js +682 -0
  40. package/server/agent/telemetry/techpowerup-mcp.json +22 -0
  41. package/server/agent/tests/agentBubbleSystem.test.js +65 -0
  42. package/server/agent/tests/agentFollowUps.test.js +127 -0
  43. package/server/agent/tests/apiRouter.test.js +108 -0
  44. package/server/agent/tests/architectureFixes.test.js +81 -0
  45. package/server/agent/tests/architectureRefactoring.test.js +194 -0
  46. package/server/agent/tests/autoResearch.test.js +64 -0
  47. package/server/agent/tests/bilibiliEmbed.test.js +251 -0
  48. package/server/agent/tests/catalogSync.test.js +130 -0
  49. package/server/agent/tests/citations.test.js +180 -0
  50. package/server/agent/tests/clarificationAnalytics.test.js +219 -0
  51. package/server/agent/tests/clarifyingQuestions.test.js +594 -0
  52. package/server/agent/tests/cliAutomation.test.js +121 -0
  53. package/server/agent/tests/cloudProviders.test.js +234 -0
  54. package/server/agent/tests/commandInput.test.js +24 -0
  55. package/server/agent/tests/commands.test.js +417 -0
  56. package/server/agent/tests/contextIntelligence.test.js +162 -0
  57. package/server/agent/tests/conversationSafety.test.js +53 -0
  58. package/server/agent/tests/decisionAndSearchEnhancements.test.js +135 -0
  59. package/server/agent/tests/dramaAnswerQuality.test.js +73 -0
  60. package/server/agent/tests/dynamicCatalogIngestion.test.js +285 -0
  61. package/server/agent/tests/dynamicRoutingAndSearch.test.js +92 -0
  62. package/server/agent/tests/edgeCasesScenarios.test.js +49 -0
  63. package/server/agent/tests/entertainmentMetrics.test.js +126 -0
  64. package/server/agent/tests/envBootstrap.test.js +87 -0
  65. package/server/agent/tests/envCommand.test.js +215 -0
  66. package/server/agent/tests/errorHandling.test.js +80 -0
  67. package/server/agent/tests/errors.test.js +13 -0
  68. package/server/agent/tests/evalAnalyze.test.js +54 -0
  69. package/server/agent/tests/evalCommand.test.js +54 -0
  70. package/server/agent/tests/evalCorpus.test.js +126 -0
  71. package/server/agent/tests/evalHarness.test.js +62 -0
  72. package/server/agent/tests/evalJudgeContext.test.js +38 -0
  73. package/server/agent/tests/evalMetrics.test.js +312 -0
  74. package/server/agent/tests/evalStore.test.js +190 -0
  75. package/server/agent/tests/evalStudio.test.js +179 -0
  76. package/server/agent/tests/feedbackLearning.test.js +186 -0
  77. package/server/agent/tests/firstRun.test.js +44 -0
  78. package/server/agent/tests/gemma4eOptimization.test.js +95 -0
  79. package/server/agent/tests/hardwareTiers.test.js +206 -0
  80. package/server/agent/tests/hybridWebSearch.test.js +192 -0
  81. package/server/agent/tests/judgeService.test.js +251 -0
  82. package/server/agent/tests/lanDiscovery.test.js +319 -0
  83. package/server/agent/tests/mcpClient.test.js +201 -0
  84. package/server/agent/tests/modelManager.test.js +109 -0
  85. package/server/agent/tests/multilingualScenarios.test.js +69 -0
  86. package/server/agent/tests/newCommands.test.js +347 -0
  87. package/server/agent/tests/newCommandsTui.test.js +141 -0
  88. package/server/agent/tests/newTools.test.js +113 -0
  89. package/server/agent/tests/newsIntelligence.test.js +178 -0
  90. package/server/agent/tests/nextGenArchitecture.test.js +99 -0
  91. package/server/agent/tests/offlineInput.test.js +217 -0
  92. package/server/agent/tests/packagingGuard.test.js +57 -0
  93. package/server/agent/tests/presentation.test.js +61 -0
  94. package/server/agent/tests/providerPersistence.test.js +39 -0
  95. package/server/agent/tests/queryBenchmarks.test.js +83 -0
  96. package/server/agent/tests/queryDetails.test.js +126 -0
  97. package/server/agent/tests/realWorldValidation.test.js +391 -0
  98. package/server/agent/tests/resilienceAndConcurrency.test.js +117 -0
  99. package/server/agent/tests/runtimeWatchdog.test.js +32 -0
  100. package/server/agent/tests/sanitizer.test.js +37 -0
  101. package/server/agent/tests/scenariosData.js +378 -0
  102. package/server/agent/tests/schemaRouting.test.js +54 -0
  103. package/server/agent/tests/securityAuditRemediation.test.js +214 -0
  104. package/server/agent/tests/securityHardening.test.js +146 -0
  105. package/server/agent/tests/securitySanitizer.test.js +38 -0
  106. package/server/agent/tests/sessionAndCopy.test.js +232 -0
  107. package/server/agent/tests/sidebarControls.test.js +101 -0
  108. package/server/agent/tests/statefulMemory.test.js +270 -0
  109. package/server/agent/tests/subagent.test.js +216 -0
  110. package/server/agent/tests/subagentProgress.test.js +109 -0
  111. package/server/agent/tests/subagentTextFallback.test.js +115 -0
  112. package/server/agent/tests/suggestions.test.js +199 -0
  113. package/server/agent/tests/suggestionsTui.test.js +251 -0
  114. package/server/agent/tests/telemetryAndProfiles.test.js +179 -0
  115. package/server/agent/tests/terminalImage.test.js +80 -0
  116. package/server/agent/tests/terminalMarkdown.test.js +166 -0
  117. package/server/agent/tests/terminalText.test.js +133 -0
  118. package/server/agent/tests/textProtocolLoop.test.js +126 -0
  119. package/server/agent/tests/textToolParsing.test.js +139 -0
  120. package/server/agent/tests/theme.test.js +26 -0
  121. package/server/agent/tests/toolRegistryParity.test.js +53 -0
  122. package/server/agent/tests/toolRoutingRegressions.test.js +119 -0
  123. package/server/agent/tests/tools.test.js +259 -0
  124. package/server/agent/tests/unifiedEndpoints.test.js +152 -0
  125. package/server/agent/tests/v110Features.test.js +262 -0
  126. package/server/agent/tests/versionBump.test.js +69 -0
  127. package/server/agent/tests/vramEstimator.test.js +520 -0
  128. package/server/agent/tests/webChatInspection.test.js +300 -0
  129. package/server/agent/tests/webChatMemory.test.js +112 -0
  130. package/server/agent/tests/webChatSecurity.test.js +169 -0
  131. package/server/agent/tests/webExtract.test.js +261 -0
  132. package/server/agent/tests/webSources.test.js +247 -0
  133. package/server/agent/websearch/adapters/duckduckgo.js +97 -0
  134. package/server/agent/websearch/adapters/jina.js +77 -0
  135. package/server/agent/websearch/adapters/searxng.js +57 -0
  136. package/server/agent/websearch/core/canonical.js +89 -0
  137. package/server/agent/websearch/core/fusion.js +81 -0
  138. package/server/agent/websearch/core/types.js +29 -0
  139. package/server/agent/websearch/extract.js +605 -0
  140. package/server/agent/websearch/index.js +43 -0
  141. package/server/agent/websearch/orchestrator.js +266 -0
  142. package/server/agent/websearch/resilience/breaker.js +97 -0
  143. package/server/agent/websearch/resilience/cache.js +59 -0
  144. package/server/agent/websearch/resilience/limiter.js +93 -0
  145. package/server/agent/websearch/security/filter.js +36 -0
  146. package/server/agent/websearch/security/ssrf.js +289 -0
  147. package/server/agent/websearch/telemetry.js +71 -0
  148. package/server/apiRouter.js +7 -11
  149. package/server/eval/judgeRubrics.js +110 -0
  150. package/server/eval/judgeService.js +290 -0
  151. package/server/eval/runBenchmark.js +182 -0
  152. package/server/mcp/examples/librechat.example.yaml +16 -0
  153. package/server/mcp/tests/connection.test.js +156 -0
  154. package/server/routes/agent.js +55 -0
  155. package/server/supabase_schema.sql +63 -0
@@ -0,0 +1,347 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { routeCommand } from '../commands.js';
5
+ import { AgentMemory } from '../memory.js';
6
+ import { AgentJobManager } from '../jobManager.js';
7
+
8
+ const RECENT_DATE = new Date(Date.now() - 3 * 86_400_000).toISOString().slice(0, 10);
9
+
10
+ async function captureOutput(run) {
11
+ const originalLog = console.log;
12
+ const output = [];
13
+ console.log = (...args) => output.push(args.join(' '));
14
+ try {
15
+ const result = await run();
16
+ return { result, output: output.join('\n') };
17
+ } finally {
18
+ console.log = originalLog;
19
+ }
20
+ }
21
+
22
+ function catalogExecutor(name, args = {}) {
23
+ if (name === 'search_works' && (!args.query || /in blossom/i.test(args.query))) return Promise.resolve({ works: [{ id: 'in-blossom', titleTh: 'In Blossom', year: 2024, role: 'Yang Caiwei', platform: 'Youku', episodes: 32 }] });
24
+ if (name === 'search_discography' && (!args.query || /rain love/i.test(args.query))) return Promise.resolve({ songs: [{ id: 'rain-love', title: 'Rain Love', year: 2024, type: 'Single', duration: '3:45' }] });
25
+ return Promise.resolve({ works: [], songs: [], roles: [], brands: [], awards: [], photos: [], videos: [] });
26
+ }
27
+
28
+ test('/find validates flags and renders normalized catalog results', async () => {
29
+ const found = await captureOutput(() => routeCommand('/find "In Blossom" --type work --limit 2', { executeTool: catalogExecutor }));
30
+ assert.equal(found.result, 'HANDLED');
31
+ assert.match(found.output, /Catalog Search/);
32
+ assert.match(found.output, /In Blossom/);
33
+
34
+ const invalid = await captureOutput(() => routeCommand('/find test --type file', { executeTool: catalogExecutor }));
35
+ assert.equal(invalid.result, 'HANDLED');
36
+ assert.match(invalid.output, /COMMAND ERROR/);
37
+ });
38
+
39
+ test('/compare resolves both sides around the separator', async () => {
40
+ const output = await captureOutput(() => routeCommand('/compare "In Blossom" -- "Rain Love"', { executeTool: catalogExecutor }));
41
+ assert.equal(output.result, 'HANDLED');
42
+ assert.match(output.output, /Compare/);
43
+ assert.match(output.output, /In Blossom/);
44
+ assert.match(output.output, /Rain Love/);
45
+ });
46
+
47
+ test('/favorites adds, lists, removes, and exports resolved entries', async () => {
48
+ const memory = new AgentMemory();
49
+ memory.save = async () => true;
50
+ memory.exportFavorites = async format => ({ filePath: `C:\\safe\\favorites.${format === 'markdown' ? 'md' : 'json'}`, count: memory.getFavorites().length });
51
+ const agent = { memory };
52
+
53
+ const added = await captureOutput(() => routeCommand('/favorites add work "In Blossom"', { agent, executeTool: catalogExecutor }));
54
+ assert.equal(added.result, 'HANDLED');
55
+ assert.equal(memory.getFavorites().length, 1);
56
+ assert.match(added.output, /Favorite Added/);
57
+
58
+ const listed = await captureOutput(() => routeCommand('/favorites list --type work', { agent }));
59
+ assert.match(listed.output, /In Blossom/);
60
+
61
+ const id = memory.getFavorites()[0].id;
62
+ await captureOutput(() => routeCommand(`/favorites remove ${id}`, { agent }));
63
+ assert.equal(memory.getFavorites().length, 0);
64
+
65
+ const exported = await captureOutput(() => routeCommand('/favorites export markdown', { agent }));
66
+ assert.match(exported.output, /Favorites Exported/);
67
+ });
68
+
69
+ test('/news filters unsafe URLs and honors official-only hosts', async () => {
70
+ let query = '';
71
+ const executeTool = async () => ({
72
+ results: [
73
+ { title: `Official update ${RECENT_DATE}`, url: 'https://weibo.com/example', snippet: 'Studio update\u001b[31m' },
74
+ { title: 'Unsafe', url: 'javascript:alert(1)', snippet: 'Ignore' },
75
+ { title: 'Blog', url: 'https://example.com/news', snippet: 'Unofficial' },
76
+ ],
77
+ });
78
+ const output = await captureOutput(() => routeCommand('/news drama --days 7 --official-only', { executeTool: async (name, args) => {
79
+ query = args.query;
80
+ return executeTool(name, args);
81
+ } }));
82
+ assert.equal(output.result, 'HANDLED');
83
+ assert.match(query, /after:\d{4}-\d{2}-\d{2} before:\d{4}-\d{2}-\d{2}/);
84
+ assert.match(output.output, /requested 7-day window/);
85
+ assert.match(output.output, /weibo\.com/);
86
+ assert.doesNotMatch(output.output, /javascript:/);
87
+ assert.doesNotMatch(output.output, /example\.com/);
88
+ assert.doesNotMatch(output.output, /\u001b\[31m/);
89
+ });
90
+
91
+ test('/news labels results without dates as recency unverified', async () => {
92
+ const executeTool = async () => ({ results: [{ title: 'Drama update', url: 'https://example.com/update', snippet: 'No publication date' }] });
93
+ const output = await captureOutput(() => routeCommand('/news drama --days 7', { executeTool }));
94
+ assert.match(output.output, /date unavailable • recency unverified/);
95
+ });
96
+
97
+ test('/jobs lists, shows, cancels, and clears through the agent contract', async () => {
98
+ const manager = new AgentJobManager();
99
+ const job = manager.create('research', 'Current drama news');
100
+ const agent = {
101
+ getJobs: () => manager.list(),
102
+ getJob: id => manager.get(id),
103
+ cancelJob: id => manager.cancel(id),
104
+ clearJobs: () => manager.clear(),
105
+ };
106
+ const listed = await captureOutput(() => routeCommand('/jobs', { agent }));
107
+ assert.match(listed.output, new RegExp(job.id));
108
+ const shown = await captureOutput(() => routeCommand(`/jobs show ${job.id}`, { agent }));
109
+ assert.match(shown.output, /Current drama news/);
110
+ await captureOutput(() => routeCommand(`/jobs cancel ${job.id}`, { agent }));
111
+ assert.equal(manager.get(job.id).state, 'cancelled');
112
+ await captureOutput(() => routeCommand('/jobs clear', { agent }));
113
+ assert.equal(manager.list().length, 0);
114
+ });
115
+
116
+ test('/discover returns one catalog recommendation with deterministic selection', async () => {
117
+ const output = await captureOutput(() => routeCommand('/discover song --mood calm', { executeTool: catalogExecutor, random: () => 0 }));
118
+ assert.equal(output.result, 'HANDLED');
119
+ assert.match(output.output, /Discover/);
120
+ assert.match(output.output, /Rain Love/);
121
+ });
122
+
123
+ test('/discover renders an AI rationale when a chat client is available', async () => {
124
+ const agent = {
125
+ memory: new AgentMemory(),
126
+ client: {
127
+ chat: async () => ({ content: 'A dreamy choice to end the day.' }),
128
+ },
129
+ };
130
+ const output = await captureOutput(() => routeCommand('/discover song --mood calm', { agent, executeTool: catalogExecutor, random: () => 0 }));
131
+ assert.equal(output.result, 'HANDLED');
132
+ assert.match(output.output, /A dreamy choice to end the day/);
133
+ });
134
+
135
+ test('/discover falls back gracefully when the chat client fails', async () => {
136
+ const agent = {
137
+ memory: new AgentMemory(),
138
+ client: {
139
+ chat: async () => { throw new Error('model unavailable'); },
140
+ },
141
+ };
142
+ const output = await captureOutput(() => routeCommand('/discover drama', { agent, executeTool: catalogExecutor, random: () => 0 }));
143
+ assert.equal(output.result, 'HANDLED');
144
+ assert.match(output.output, /Discover/);
145
+ assert.match(output.output, /In Blossom/);
146
+ assert.doesNotMatch(output.output, /COMMAND ERROR/);
147
+ });
148
+
149
+ function fakeAgent(narration) {
150
+ const memory = new AgentMemory();
151
+ memory.save = async () => true;
152
+ return {
153
+ memory,
154
+ client: { chat: async () => ({ content: narration }) },
155
+ };
156
+ }
157
+
158
+ test('/compare renders an AI natural-language summary when a client is available', async () => {
159
+ const output = await captureOutput(() => routeCommand('/compare "In Blossom" -- "Rain Love"', { agent: fakeAgent('In Blossom is a drama while Rain Love is a single.'), executeTool: catalogExecutor }));
160
+ assert.equal(output.result, 'HANDLED');
161
+ assert.match(output.output, /In Blossom is a drama while Rain Love is a single/);
162
+ });
163
+
164
+ test('/compare falls back gracefully when the chat client fails', async () => {
165
+ const agent = { client: { chat: async () => { throw new Error('boom'); } } };
166
+ const output = await captureOutput(() => routeCommand('/compare "In Blossom" -- "Rain Love"', { agent, executeTool: catalogExecutor }));
167
+ assert.equal(output.result, 'HANDLED');
168
+ assert.match(output.output, /Differences/);
169
+ assert.doesNotMatch(output.output, /COMMAND ERROR/);
170
+ });
171
+
172
+ test('/find renders an AI explanation when a client is available', async () => {
173
+ const output = await captureOutput(() => routeCommand('/find "In Blossom" --type work', { agent: fakeAgent('In Blossom matches because the query matches its title.'), executeTool: catalogExecutor }));
174
+ assert.equal(output.result, 'HANDLED');
175
+ assert.match(output.output, /matches because/);
176
+ });
177
+
178
+ test('/find falls back gracefully when no client is available', async () => {
179
+ const output = await captureOutput(() => routeCommand('/find "In Blossom" --type work', { executeTool: catalogExecutor }));
180
+ assert.equal(output.result, 'HANDLED');
181
+ assert.match(output.output, /Catalog Search/);
182
+ assert.match(output.output, /In Blossom/);
183
+ assert.doesNotMatch(output.output, /COMMAND ERROR/);
184
+ });
185
+
186
+ test('/favorites suggest renders an AI recommendation from saved taste', async () => {
187
+ const agent = fakeAgent('Rain Love — a gentle companion piece\nIn Blossom encore');
188
+ await captureOutput(() => routeCommand('/favorites add work "In Blossom"', { agent, executeTool: catalogExecutor }));
189
+ const output = await captureOutput(() => routeCommand('/favorites suggest', { agent, executeTool: catalogExecutor }));
190
+ assert.equal(output.result, 'HANDLED');
191
+ assert.match(output.output, /Favorites Suggest/);
192
+ assert.match(output.output, /Rain Love/);
193
+ });
194
+
195
+ test('/favorites suggest explains when there are no favorites yet', async () => {
196
+ const output = await captureOutput(() => routeCommand('/favorites suggest', { agent: fakeAgent('ignored'), executeTool: catalogExecutor }));
197
+ assert.equal(output.result, 'HANDLED');
198
+ assert.match(output.output, /No favorites yet/);
199
+ assert.doesNotMatch(output.output, /COMMAND ERROR/);
200
+ });
201
+
202
+ test('/news renders an AI summary of results when a client is available', async () => {
203
+ const executeTool = async () => ({ results: [{ title: `Studio update ${RECENT_DATE}`, url: 'https://weibo.com/example', snippet: 'New single released' }] });
204
+ const output = await captureOutput(() => routeCommand('/news drama --days 7', { agent: fakeAgent('The studio announced a new single this week.'), executeTool }));
205
+ assert.equal(output.result, 'HANDLED');
206
+ assert.match(output.output, /announced a new single/);
207
+ });
208
+
209
+ test('/news falls back gracefully when no client is available', async () => {
210
+ const executeTool = async () => ({ results: [{ title: `Studio update ${RECENT_DATE}`, url: 'https://weibo.com/example', snippet: 'New single released' }] });
211
+ const output = await captureOutput(() => routeCommand('/news drama --days 7', { executeTool }));
212
+ assert.equal(output.result, 'HANDLED');
213
+ assert.match(output.output, /News Search/);
214
+ assert.doesNotMatch(output.output, /COMMAND ERROR/);
215
+ });
216
+
217
+ test('/history summary delegates to /session summary and renders an AI summary when messages and client exist', async () => {
218
+ const agent = fakeAgent('Discussed In Blossom release details and music.');
219
+ agent.session = {
220
+ messages: [
221
+ { role: 'user', parts: [{ type: 'text', content: 'Tell me about In Blossom' }] },
222
+ { role: 'assistant', parts: [{ type: 'text', content: 'In Blossom was released in 2024.' }] },
223
+ ],
224
+ };
225
+ const output = await captureOutput(() => routeCommand('/history summary', { agent }));
226
+ assert.equal(output.result, 'HANDLED');
227
+ assert.match(output.output, /Session Summary/);
228
+ assert.match(output.output, /Discussed In Blossom/);
229
+ });
230
+
231
+ test('/history summary handles empty history gracefully', async () => {
232
+ const agent = fakeAgent('ignored');
233
+ agent.session = { messages: [] };
234
+ const output = await captureOutput(() => routeCommand('/history summary', { agent }));
235
+ assert.equal(output.result, 'HANDLED');
236
+ assert.match(output.output, /No messages in the active session/);
237
+ });
238
+
239
+ test('/session summary synthesizes an AI session analysis when messages exist', async () => {
240
+ const agent = fakeAgent('Session focused on 2024 filmography research with 2 tool calls.');
241
+ agent.session = {
242
+ id: 'sess_test_123',
243
+ messages: [
244
+ { role: 'user', parts: [{ type: 'text', content: 'Who did Ju Jingyi play in In Blossom?' }] },
245
+ { role: 'assistant', parts: [{ type: 'text', content: 'She played Yang Caiwei.' }] },
246
+ ],
247
+ stats: { totalQueries: 1, totalToolCalls: 2 },
248
+ };
249
+ const output = await captureOutput(() => routeCommand('/session summary', { agent }));
250
+ assert.equal(output.result, 'HANDLED');
251
+ assert.match(output.output, /Session Summary/);
252
+ assert.match(output.output, /Session focused on 2024/);
253
+ });
254
+
255
+ test('/session summary handles empty session messages gracefully', async () => {
256
+ const agent = fakeAgent('ignored');
257
+ agent.session = { id: 'sess_empty', messages: [], stats: {} };
258
+ const output = await captureOutput(() => routeCommand('/session summary', { agent }));
259
+ assert.equal(output.result, 'HANDLED');
260
+ assert.match(output.output, /No messages in the active session/);
261
+ });
262
+
263
+ test('/mem summary synthesizes an AI persona profile when facts exist', async () => {
264
+ const agent = fakeAgent('User is an avid fan tracking Ju Jingyi dramas and OST releases.');
265
+ agent.memory.addFact('Loves In Blossom (2024)', { source: 'user' });
266
+ const output = await captureOutput(() => routeCommand('/mem summary', { agent }));
267
+ assert.equal(output.result, 'HANDLED');
268
+ assert.match(output.output, /Memory Profile Summary/);
269
+ assert.match(output.output, /avid fan tracking/);
270
+ });
271
+
272
+ test('/mem summary handles empty memory gracefully', async () => {
273
+ const agent = fakeAgent('ignored');
274
+ const output = await captureOutput(() => routeCommand('/mem summary', { agent }));
275
+ assert.equal(output.result, 'HANDLED');
276
+ assert.match(output.output, /No long-term memories/);
277
+ });
278
+
279
+ test('/stats renders AI data narration alongside summary counts', async () => {
280
+ const agent = fakeAgent('Ju Jingyi has a rich discography and television portfolio.');
281
+ const output = await captureOutput(() => routeCommand('/stats', { agent, executeTool: async () => ({ stats: { totalWorks: 15, totalUniqueSongs: 40, brandEndorsements: 12, totalAwards: 25, timelineMilestones: 30 } }) }));
282
+ assert.equal(output.result, 'HANDLED');
283
+ assert.match(output.output, /Database Overview/);
284
+ assert.match(output.output, /rich discography/);
285
+ });
286
+
287
+ test('job manager records completion, failure, and cancellation states', async () => {
288
+ const manager = new AgentJobManager();
289
+ await manager.run('research', 'done', async () => ({ ok: true }));
290
+ assert.equal(manager.list()[0].state, 'completed');
291
+
292
+ await assert.rejects(manager.run('research', 'failed', async () => { throw new Error('failed'); }), /failed/);
293
+ assert.equal(manager.list()[0].state, 'failed');
294
+
295
+ let release;
296
+ const pending = manager.run('research', 'cancel me', ({ signal }) => new Promise((resolve, reject) => {
297
+ release = resolve;
298
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
299
+ }));
300
+ const active = manager.list()[0];
301
+ assert.equal(manager.cancel(active.id), true);
302
+ await assert.rejects(pending, /Cancelled by user/);
303
+ assert.equal(manager.get(active.id).state, 'cancelled');
304
+ release?.();
305
+ });
306
+
307
+ test('/session load and /resume restore archived sessions into memory', async () => {
308
+ let restoredTarget = null;
309
+ const mockAgent = {
310
+ session: {
311
+ id: 'sess_active',
312
+ messages: [],
313
+ stats: { totalQueries: 0, totalToolCalls: 0, totalDurationMs: 0 },
314
+ thinkingMode: 'hide',
315
+ },
316
+ memory: {
317
+ getArchivedSessions: (limit) => [
318
+ { id: 'sess_arch_1', createdAt: new Date(), messageCount: 4, stats: { totalQueries: 2, totalToolCalls: 3, totalDurationMs: 1500 } },
319
+ { id: 'sess_arch_2', createdAt: new Date(), messageCount: 2, stats: { totalQueries: 1, totalToolCalls: 1, totalDurationMs: 800 } },
320
+ ],
321
+ },
322
+ restoreSession: async (target) => {
323
+ restoredTarget = target;
324
+ return {
325
+ id: 'sess_arch_1',
326
+ createdAt: new Date(),
327
+ messages: [{ role: 'user' }, { role: 'assistant' }, { role: 'user' }, { role: 'assistant' }],
328
+ stats: { totalQueries: 2, totalToolCalls: 3, totalDurationMs: 1500 },
329
+ };
330
+ },
331
+ };
332
+
333
+ const usageOutput = await captureOutput(() => routeCommand('/session load', { agent: mockAgent, sub: 'load', extra: '' }));
334
+ assert.equal(usageOutput.result, 'HANDLED');
335
+ assert.match(usageOutput.output, /Load Session Usage/);
336
+
337
+ const loadOutput = await captureOutput(() => routeCommand('/session load sess_arch_1', { agent: mockAgent, sub: 'load', extra: 'sess_arch_1' }));
338
+ assert.equal(loadOutput.result, 'HANDLED');
339
+ assert.equal(restoredTarget, 'sess_arch_1');
340
+ assert.match(loadOutput.output, /Session Restored Successfully/);
341
+
342
+ const resumeOutput = await captureOutput(() => routeCommand('/resume 1', { agent: mockAgent, sub: '1', extra: '' }));
343
+ assert.equal(resumeOutput.result, 'HANDLED');
344
+ assert.equal(restoredTarget, '1');
345
+ assert.match(resumeOutput.output, /Session Restored Successfully/);
346
+ });
347
+
@@ -0,0 +1,141 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { COMMAND_NAMES } from '../commandRegistry.js';
5
+ import { routeCommand } from '../commands.js';
6
+ import { AgentJobManager } from '../jobManager.js';
7
+ import { AgentMemory } from '../memory.js';
8
+ import { getStringDisplayWidth, getWelcomeHeroLines, stripAnsi } from '../ui.js';
9
+
10
+ const RECENT_DATE = new Date(Date.now() - 3 * 86_400_000).toISOString().slice(0, 10);
11
+
12
+ function executor(name, args = {}) {
13
+ const query = String(args.query || '').toLowerCase();
14
+ if (name === 'search_works') {
15
+ const works = [
16
+ { id: 'in-blossom', titleTh: 'บุปผารักอลวน', titleEn: 'In Blossom', year: args.year || 2024, role: 'Yang Caiwei', platform: 'Youku', episodes: 32 },
17
+ { id: 'beauty-of-resilience', titleTh: 'ตำนานเลือดฟีนิกซ์', titleEn: 'Beauty of Resilience', year: args.year || 2023, role: 'Wei Zhi', platform: 'iQIYI', episodes: 36 },
18
+ ].filter(item => !query || `${item.titleTh} ${item.titleEn}`.toLowerCase().includes(query));
19
+ return Promise.resolve({ works, count: works.length });
20
+ }
21
+ if (name === 'search_discography') return Promise.resolve({ songs: [{ id: 'rain-love', title: 'Rain Love (雨恋)', year: args.year || 2024, type: 'OST', duration: '3:45', youtubeUrl: 'https://youtube.com/watch?v=test' }], count: 1 });
22
+ if (name === 'get_character_roles') return Promise.resolve({ roles: [{ role: 'Yang Caiwei', roleCn: '杨采薇', workTitle: 'In Blossom', year: 2024 }], count: 1 });
23
+ if (name === 'get_brand_endorsements') return Promise.resolve({ brands: [{ brand: 'Versace', title: 'Global Brand Ambassador', category: 'Luxury', year: args.year || 2026 }], count: 1 });
24
+ if (name === 'get_awards') return Promise.resolve({ awards: [{ id: 'award-1', title: 'Weibo Actress of the Year', ceremony: 'Weibo Night', category: 'Acting', year: args.year || 2025 }], count: 1 });
25
+ if (name === 'get_gallery_photos') return Promise.resolve({ photos: [{ id: 'photo-1', title: 'Milan Fashion Week Portrait', category: 'fashion-magazine', year: args.year || 2025 }], count: 1 });
26
+ if (name === 'get_music_videos') return Promise.resolve({ videos: [{ id: 'stage-1', title: 'Live Stage Performance', category: 'Stage Performance', year: 2025, duration: '4:12', youtubeUrl: 'https://youtube.com/watch?v=stage' }], count: 1 });
27
+ if (name === 'web_search') return Promise.resolve({ results: [
28
+ { title: `Ju Jingyi official studio update ${RECENT_DATE}`, url: 'https://weibo.com/kiku-studio', snippet: 'A concise official update about the latest drama production.' },
29
+ { title: 'Ju Jingyi interview', url: 'https://example.com/interview', snippet: 'A recent interview covering music and acting.' },
30
+ ] });
31
+ return Promise.resolve({});
32
+ }
33
+
34
+ function createContext() {
35
+ const memory = new AgentMemory();
36
+ memory.save = async () => true;
37
+ memory.exportFavorites = async format => ({ filePath: `C:\\kiku-test\\favorites.${format === 'markdown' ? 'md' : 'json'}`, count: memory.getFavorites().length });
38
+ const jobs = new AgentJobManager();
39
+ const running = jobs.create('research', 'Inspect the newest Ju Jingyi drama announcement and collect trustworthy sources');
40
+ return {
41
+ context: {
42
+ executeTool: executor,
43
+ random: () => 0,
44
+ agent: {
45
+ memory,
46
+ getJobs: () => jobs.list(),
47
+ getJob: id => jobs.get(id),
48
+ cancelJob: id => jobs.cancel(id),
49
+ clearJobs: () => jobs.clear(),
50
+ },
51
+ },
52
+ memory,
53
+ jobs,
54
+ running,
55
+ };
56
+ }
57
+
58
+ async function captureAtWidth(width, run) {
59
+ const originalLog = console.log;
60
+ const originalDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'columns');
61
+ const output = [];
62
+ Object.defineProperty(process.stdout, 'columns', { value: width, configurable: true });
63
+ console.log = (...args) => output.push(args.join(' '));
64
+ try {
65
+ const result = await run();
66
+ return { result, output: output.join('\n') };
67
+ } finally {
68
+ console.log = originalLog;
69
+ if (originalDescriptor) Object.defineProperty(process.stdout, 'columns', originalDescriptor);
70
+ else delete process.stdout.columns;
71
+ }
72
+ }
73
+
74
+ function assertHealthyTui(output, width) {
75
+ assert.doesNotMatch(output, /undefined|\[object Object\]|NaN/);
76
+ for (const line of output.split('\n')) {
77
+ assert.ok(getStringDisplayWidth(stripAnsi(line)) <= width, `TUI line exceeded ${width} columns: ${stripAnsi(line)}`);
78
+ }
79
+ }
80
+
81
+ test('every canonical command exposes its help route without executing side effects', async () => {
82
+ const seen = [];
83
+ for (const command of COMMAND_NAMES) {
84
+ const result = await routeCommand(`${command} help`, { printHelp: name => seen.push(name) });
85
+ assert.equal(result, 'HANDLED', command);
86
+ }
87
+ assert.deepEqual(seen, COMMAND_NAMES.map(command => command === '/help' ? 'help' : command));
88
+ });
89
+
90
+ for (const width of [80, 120]) {
91
+ test(`welcome TUI is bounded and complete at ${width} columns`, async () => {
92
+ const rendered = await captureAtWidth(width, async () => getWelcomeHeroLines());
93
+ const lines = rendered.result;
94
+ assert.match(stripAnsi(lines.join('\n')), /Ju Jingyi/);
95
+ assert.match(stripAnsi(lines.join('\n')), /Ctrl\+C/);
96
+ for (const line of lines) assert.ok(getStringDisplayWidth(stripAnsi(line)) <= width, stripAnsi(line));
97
+ if (width === 80) assert.ok(lines.length <= 14);
98
+ });
99
+ }
100
+
101
+ for (const width of [80, 120]) {
102
+ test(`new command TUI matrix is complete and bounded at ${width} columns`, async () => {
103
+ const { context, memory, running } = createContext();
104
+ const commands = [
105
+ ['/find "In Blossom" --type work --limit 4', /Catalog Search/],
106
+ ['/find Versace --type brand --year 2026', /Versace/],
107
+ ['/find test --type invalid', /COMMAND ERROR/],
108
+ ['/compare "In Blossom" -- "Beauty of Resilience" --type work', /Compare/],
109
+ ['/compare 2024 -- 2025', /2024/],
110
+ ['/compare missing separator', /Usage/],
111
+ ['/favorites list', /Favorites/],
112
+ ['/favorites add song "Rain Love"', /Favorite Added/],
113
+ ['/favorites list --type song', /Rain Love/],
114
+ ['/favorites export markdown', /Favorites Exported/],
115
+ ['/news drama --days 7', /News/],
116
+ ['/news drama --days 14 --official-only', /weibo\.com/],
117
+ ['/news --days 0', /COMMAND ERROR/],
118
+ ['/jobs list', new RegExp(running.id)],
119
+ [`/jobs show ${running.id}`, /Inspect the newest/],
120
+ [`/jobs cancel ${running.id}`, /cancellation requested/],
121
+ ['/jobs clear', /Cleared/],
122
+ ['/discover drama', /บุปผารักอลวน/],
123
+ ['/discover song --mood calm', /Rain Love/],
124
+ ['/discover stage --year 2025', /Live Stage/],
125
+ ['/discover photo', /Milan Fashion Week/],
126
+ ['/discover award', /COMMAND ERROR/],
127
+ ];
128
+
129
+ for (const [command, expected] of commands) {
130
+ const rendered = await captureAtWidth(width, () => routeCommand(command, context));
131
+ assert.equal(rendered.result, 'HANDLED', command);
132
+ assert.match(rendered.output, expected, command);
133
+ assertHealthyTui(rendered.output, width);
134
+ }
135
+
136
+ const favorite = memory.getFavorites()[0];
137
+ const removed = await captureAtWidth(width, () => routeCommand(`/favorites remove ${favorite.id}`, context));
138
+ assert.match(removed.output, /Favorite removed/);
139
+ assertHealthyTui(removed.output, width);
140
+ });
141
+ }
@@ -0,0 +1,113 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { extractYouTubeVideoId, parseSubtitleToText } from '../youtubeTranscript.js';
5
+ import { executeTool, toolHandlers, toolsSchema } from '../tools.js';
6
+ import { KikuAgent } from '../agentCore.js';
7
+
8
+ function bareAgent() {
9
+ const agent = Object.create(KikuAgent.prototype);
10
+ agent.mcpManager = null;
11
+ agent.allowedToolNames = null;
12
+ return agent;
13
+ }
14
+
15
+ test('get_current_datetime returns a coherent current date', async () => {
16
+ const result = await executeTool('get_current_datetime', {});
17
+
18
+ assert.equal(result.status, 'success');
19
+ assert.equal(result.year, new Date().getFullYear());
20
+ assert.ok(Number.isFinite(result.timestampMs));
21
+ assert.match(result.timezoneOffset, /^[+-]\d{2}:\d{2}$/);
22
+ assert.match(result.iso, /^\d{4}-\d{2}-\d{2}T/);
23
+ assert.ok(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'].includes(result.weekday));
24
+ });
25
+
26
+ test('system prompt injects the live year instead of a hardcoded one', () => {
27
+ const prompt = bareAgent().buildSystemPrompt();
28
+ assert.ok(!prompt.includes('{{CURRENT_YEAR}}'));
29
+ assert.ok(prompt.includes(`THE CURRENT YEAR IS ${new Date().getFullYear()}`));
30
+ assert.ok(!prompt.includes('THE CURRENT YEAR IS 2026.'));
31
+ });
32
+
33
+ test('get_profile_and_fandom exposes previously hidden sections', async () => {
34
+ const talents = await toolHandlers.get_profile_and_fandom({ section: 'talents' });
35
+ assert.ok(Array.isArray(talents.talents));
36
+ assert.ok(talents.talents.length > 0);
37
+ assert.equal(talents.talents[0].title.length > 0, true);
38
+
39
+ const honors = await toolHandlers.get_profile_and_fandom({ section: 'honors' });
40
+ assert.ok(honors.honors.some(h => /Forbes/.test(h)));
41
+
42
+ const social = await toolHandlers.get_profile_and_fandom({ section: 'social' });
43
+ assert.ok(social.socialLinks.some(s => /^https?:\/\//.test(s.url)));
44
+
45
+ const all = await executeTool('get_profile_and_fandom', {});
46
+ assert.equal(all.status, 'success');
47
+ assert.ok(all.talents.length > 0);
48
+ assert.ok(all.stats.length > 0);
49
+ assert.ok(all.honors.length > 0);
50
+ assert.ok(all.socialLinks.length > 0);
51
+ });
52
+
53
+ test('get_gallery_photos includes the served image path', async () => {
54
+ const result = await executeTool('get_gallery_photos', { limit: 2 });
55
+ assert.equal(result.status, 'success');
56
+ assert.ok(result.photos.length > 0);
57
+ assert.match(result.photos[0].imageUrl, /^\/images\//);
58
+ });
59
+
60
+ test('extractYouTubeVideoId understands URLs and bare IDs', () => {
61
+ assert.equal(extractYouTubeVideoId('9AeErG03SZw'), '9AeErG03SZw');
62
+ assert.equal(extractYouTubeVideoId('https://www.youtube.com/watch?v=9AeErG03SZw'), '9AeErG03SZw');
63
+ assert.equal(extractYouTubeVideoId('https://youtu.be/OMLpNyp8T7Q?t=30'), 'OMLpNyp8T7Q');
64
+ assert.equal(extractYouTubeVideoId('https://www.youtube.com/shorts/abcd1234efg'), 'abcd1234efg');
65
+ assert.equal(extractYouTubeVideoId('not a youtube link'), null);
66
+ });
67
+
68
+ test('parseSubtitleToText flattens VTT with inline tags and dedupes rolling captions', () => {
69
+ const vtt = [
70
+ 'WEBVTT',
71
+ '',
72
+ '00:00:01.000 --> 00:00:03.000',
73
+ '<c.colorE5E5E5>Hello</c> world',
74
+ '',
75
+ '00:00:03.000 --> 00:00:05.000',
76
+ 'Hello world',
77
+ '',
78
+ 'NOTE This is a comment',
79
+ '',
80
+ '00:00:05.000 --> 00:00:07.000',
81
+ 'Second &amp; final line',
82
+ ].join('\n');
83
+
84
+ const text = parseSubtitleToText(vtt);
85
+ assert.equal(text, 'Hello world Second & final line');
86
+ });
87
+
88
+ test('parseSubtitleToText skips SRT sequence numbers', () => {
89
+ const srt = [
90
+ '1',
91
+ '00:00:01,000 --> 00:00:03,000',
92
+ 'First line',
93
+ '',
94
+ '2',
95
+ '00:00:03,000 --> 00:00:05,000',
96
+ '2024',
97
+ ].join('\n');
98
+
99
+ const text = parseSubtitleToText(srt);
100
+ assert.equal(text, 'First line 2024');
101
+ });
102
+
103
+ test('get_youtube_transcript requires a url or videoId', async () => {
104
+ const result = await executeTool('get_youtube_transcript', {});
105
+ assert.equal(result.status, 'error');
106
+ assert.match(result.message, /valid YouTube URL|videoId/i);
107
+ });
108
+
109
+ test('new tools are exposed through the schema and executable via the standard pipeline', () => {
110
+ const names = toolsSchema.map(t => t.function.name);
111
+ assert.ok(names.includes('get_current_datetime'));
112
+ assert.ok(names.includes('get_youtube_transcript'));
113
+ });