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,417 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { COMMAND_NAMES } from '../commandRegistry.js';
5
+ import { handlers, routeCommand } from '../commands.js';
6
+ import { getThemeName } from '../theme.js';
7
+
8
+ const HIDDEN_LEGACY_HANDLERS = ['/autoresearch', '/endpoint', '/ping', '/sources', '/url', '/version'].sort();
9
+
10
+ test('every canonical slash command has exactly one handler', () => {
11
+ assert.equal(new Set(COMMAND_NAMES).size, COMMAND_NAMES.length);
12
+ for (const name of COMMAND_NAMES) {
13
+ assert.equal(typeof handlers[name], 'function', `${name} must have a handler`);
14
+ }
15
+ const hidden = Object.keys(handlers).filter(name => !COMMAND_NAMES.includes(name)).sort();
16
+ assert.deepEqual(hidden, HIDDEN_LEGACY_HANDLERS, 'hidden legacy handlers must stay minimal');
17
+ });
18
+
19
+ test('/ads receives the router context and displays one sponsor card', async () => {
20
+ let calls = 0;
21
+ const result = await routeCommand('/ads', {
22
+ printAd() { calls += 1; },
23
+ });
24
+
25
+ assert.equal(result, 'HANDLED');
26
+ assert.equal(calls, 1);
27
+ });
28
+
29
+ test('/gallery displays an image through the managed TUI callback and aliases work', async () => {
30
+ let calls = 0;
31
+ const ctx = {
32
+ showTerminalImage() { calls += 1; return true; },
33
+ };
34
+
35
+ assert.equal(await routeCommand('/gallery', ctx), 'HANDLED');
36
+ assert.equal(await routeCommand('/image', ctx), 'HANDLED');
37
+ assert.equal(await routeCommand('/photo', ctx), 'HANDLED');
38
+ assert.equal(calls, 3);
39
+ });
40
+
41
+ test('/quiz retries the current question after a wrong managed-TUI answer', async () => {
42
+ const originalFetch = global.fetch;
43
+ const originalRandom = Math.random;
44
+ const originalLog = console.log;
45
+ const prompts = [];
46
+ const output = [];
47
+ const preferences = {};
48
+ let claimBody = null;
49
+
50
+ global.fetch = async (_url, options = {}) => {
51
+ if (!options.method) {
52
+ return {
53
+ ok: true,
54
+ json: async () => ({ data: { challengeToken: 'kiku_chal_test' } }),
55
+ };
56
+ }
57
+ claimBody = JSON.parse(options.body);
58
+ return {
59
+ json: async () => ({ status: 'success', data: { apiKey: 'kiku_vip_sk_server_test' } }),
60
+ };
61
+ };
62
+ Math.random = () => 0;
63
+ console.log = (...args) => output.push(args.join(' '));
64
+
65
+ try {
66
+ const result = await routeCommand('/quiz lang en', {
67
+ sub: 'lang',
68
+ extra: 'en',
69
+ readInput: async options => {
70
+ prompts.push(options);
71
+ return prompts.length === 1 ? 'a' : 'd';
72
+ },
73
+ agent: {
74
+ memory: {
75
+ preferences,
76
+ setPreference(key, value) { preferences[key] = value; },
77
+ },
78
+ },
79
+ });
80
+
81
+ assert.equal(result, 'HANDLED');
82
+ assert.equal(prompts.length, 16);
83
+ assert.ok(prompts.every(prompt => prompt.prompt === 'Answer ❯ '));
84
+ assert.ok(prompts.every(prompt => prompt.history === false && prompt.cancelValue === 'q'));
85
+ assert.match(output.join('\n'), /Incorrect, try again!/);
86
+ assert.match(output.join('\n'), /Perfect Score!/);
87
+ assert.equal(preferences.vipKey, 'kiku_vip_sk_server_test');
88
+ assert.equal(claimBody.challengeToken, 'kiku_chal_test');
89
+ assert.equal(claimBody.answers.length, 15);
90
+ assert.ok(claimBody.answers.every(answer => answer.answerIndex === 0));
91
+ } finally {
92
+ global.fetch = originalFetch;
93
+ Math.random = originalRandom;
94
+ console.log = originalLog;
95
+ }
96
+ });
97
+
98
+ test('/quiz can be cancelled through the managed TUI input row', async () => {
99
+ const originalFetch = global.fetch;
100
+ const originalLog = console.log;
101
+ const output = [];
102
+
103
+ global.fetch = async () => ({ ok: false });
104
+ console.log = (...args) => output.push(args.join(' '));
105
+
106
+ try {
107
+ const result = await routeCommand('/quiz lang en', {
108
+ sub: 'lang',
109
+ extra: 'en',
110
+ readInput: async () => 'q',
111
+ agent: { memory: { preferences: {} } },
112
+ });
113
+
114
+ assert.equal(result, 'HANDLED');
115
+ assert.match(output.join('\n'), /Quiz cancelled/);
116
+ } finally {
117
+ global.fetch = originalFetch;
118
+ console.log = originalLog;
119
+ }
120
+ });
121
+
122
+ test('/skill list and learn commands update user skills', async () => {
123
+ const skills = [];
124
+ const preferences = { userSkills: skills, skillsEnabled: true };
125
+ const mockMemory = {
126
+ preferences,
127
+ addUserSkill(instruction) {
128
+ const skill = { id: 's_test', instruction, createdAt: Date.now() };
129
+ skills.push(skill);
130
+ return skill;
131
+ },
132
+ removeUserSkill(id) {
133
+ const idx = skills.findIndex(s => s.id === id);
134
+ if (idx !== -1) { skills.splice(idx, 1); return true; }
135
+ return false;
136
+ },
137
+ clearUserSkills() { skills.length = 0; },
138
+ setPreference(key, val) { preferences[key] = val; }
139
+ };
140
+
141
+ const agent = { memory: mockMemory };
142
+ assert.equal(await routeCommand('/skill list', { agent }), 'HANDLED');
143
+ assert.equal(await routeCommand('/skill learn always be kind', { agent, sub: 'learn', extra: 'always be kind' }), 'HANDLED');
144
+ assert.equal(skills.length, 1);
145
+ assert.equal(await routeCommand('/skill off', { agent, sub: 'off' }), 'HANDLED');
146
+ assert.equal(preferences.skillsEnabled, false);
147
+ });
148
+
149
+ test('/subagent status returns gracefully when no previous run', async () => {
150
+ const agent = {};
151
+ assert.equal(await routeCommand('/subagent status', { agent, sub: 'status' }), 'HANDLED');
152
+ });
153
+
154
+ test('/research status and sources return gracefully', async () => {
155
+ const agent = {
156
+ getResearchStatus() { return { state: 'idle', question: '', workers: [], lastResult: null }; }
157
+ };
158
+ assert.equal(await routeCommand('/research status', { agent, sub: 'status' }), 'HANDLED');
159
+ assert.equal(await routeCommand('/research sources', { agent, sub: 'sources' }), 'HANDLED');
160
+ });
161
+
162
+ test('/clear resets TUI viewport without appending duplicate banners', async () => {
163
+ let ended = false;
164
+ const agent = { endSession: async () => { ended = true; } };
165
+ const mockTuiPrompt = {
166
+ viewportLines: ['line1', 'line2'],
167
+ scrollOffset: 5,
168
+ rendered: false,
169
+ addViewportLines(line) { this.viewportLines.push(line); },
170
+ render() { this.rendered = true; }
171
+ };
172
+
173
+ const res = await routeCommand('/clear', { agent, tuiPrompt: mockTuiPrompt });
174
+ assert.equal(res, 'HANDLED');
175
+ assert.equal(ended, true);
176
+ assert.equal(mockTuiPrompt.scrollOffset, 0);
177
+ assert.equal(mockTuiPrompt.rendered, true);
178
+ assert.ok(mockTuiPrompt.viewportLines.some(l => l.includes('Fresh start')));
179
+ });
180
+
181
+ test('/gpu and /system return handled status and display hardware information', async () => {
182
+ assert.equal(await routeCommand('/gpu', {}), 'HANDLED');
183
+ assert.equal(await routeCommand('/vram', {}), 'HANDLED');
184
+ assert.equal(await routeCommand('/system', {}), 'HANDLED');
185
+ });
186
+
187
+ test('/mcp status includes every connected tool command in one panel output', async () => {
188
+ const tools = Array.from({ length: 18 }, (_, index) => ({
189
+ function: {
190
+ name: `mcp_kiku_tool_${index + 1}`,
191
+ description: `Tool ${index + 1}`,
192
+ parameters: { type: 'object', properties: {} },
193
+ },
194
+ }));
195
+ const agent = {
196
+ mcpManager: {
197
+ getStatus() {
198
+ return {
199
+ activeServersCount: 1,
200
+ totalToolsCount: tools.length,
201
+ servers: [{ name: 'kiku', type: 'streamable-http', toolsCount: tools.length, resourcesCount: 5 }],
202
+ };
203
+ },
204
+ getToolsSchema() { return tools; },
205
+ },
206
+ };
207
+ const originalLog = console.log;
208
+ const output = [];
209
+ console.log = (...args) => output.push(args.join(' '));
210
+ try {
211
+ assert.equal(await routeCommand('/mcp', { agent }), 'HANDLED');
212
+ } finally {
213
+ console.log = originalLog;
214
+ }
215
+ const rendered = output.join('\n');
216
+ for (const tool of tools) assert.match(rendered, new RegExp(`@${tool.function.name}`));
217
+ assert.equal(output.length, 1, 'the panel should be emitted as one TUI batch');
218
+ });
219
+
220
+ test('/update is no longer registered or routed', async () => {
221
+ const originalLog = console.log;
222
+ console.log = () => {};
223
+ try {
224
+ assert.equal(COMMAND_NAMES.includes('/update'), false);
225
+ assert.equal(await routeCommand('/update check', {}), 'UNKNOWN');
226
+ } finally {
227
+ console.log = originalLog;
228
+ }
229
+ });
230
+
231
+ test('audioPlayer tracks playback offset and resumes from saved position', async () => {
232
+ const { audioPlayer } = await import('../audioPlayer.js');
233
+ const originalCreateStream = audioPlayer._createStream;
234
+ audioPlayer._createStream = async () => {};
235
+ try {
236
+ audioPlayer.isPlaying = true;
237
+ audioPlayer.isPaused = false;
238
+ audioPlayer.currentTrack = 'Test Track';
239
+ audioPlayer._playbackStartedAt = Date.now() - 35000;
240
+ audioPlayer._currentOffsetSeconds = 10;
241
+
242
+ const pauseMsg = audioPlayer.pause();
243
+ assert.ok(pauseMsg.includes('Paused: Test Track'));
244
+ assert.ok(pauseMsg.includes('0:45'));
245
+ assert.equal(audioPlayer.isPaused, true);
246
+ assert.equal(audioPlayer._playbackStartedAt, 0);
247
+ assert.ok(audioPlayer._currentOffsetSeconds >= 44 && audioPlayer._currentOffsetSeconds <= 46);
248
+
249
+ const resumeMsg = audioPlayer.resume();
250
+ assert.ok(resumeMsg.includes('Resumed: Test Track'));
251
+ assert.ok(resumeMsg.includes('from 0:45'));
252
+ assert.equal(audioPlayer.isPaused, false);
253
+ assert.ok(audioPlayer._playbackStartedAt > 0);
254
+
255
+ audioPlayer.stop();
256
+ assert.equal(audioPlayer._currentOffsetSeconds, 0);
257
+ assert.equal(audioPlayer._playbackStartedAt, 0);
258
+ } finally {
259
+ audioPlayer._createStream = originalCreateStream;
260
+ audioPlayer.stop();
261
+ }
262
+ });
263
+
264
+ test('getCommandDetails returns structured metadata, subcommands, and examples', async () => {
265
+ const { getCommandDetails } = await import('../commandRegistry.js');
266
+
267
+ const suggestionDetails = getCommandDetails('/suggestion');
268
+ assert.ok(suggestionDetails, 'Should have details for /suggestion');
269
+ assert.ok(suggestionDetails.subcommands.length >= 4);
270
+ assert.ok(suggestionDetails.subcommands.some(s => s.cmd === 'list'));
271
+ assert.ok(suggestionDetails.subcommands.some(s => s.cmd.startsWith('run')));
272
+ assert.ok(suggestionDetails.subcommands.some(s => s.cmd.startsWith('topic')));
273
+ assert.ok(suggestionDetails.examples.length >= 3);
274
+
275
+ const mcpDetails = getCommandDetails('mcp');
276
+ assert.ok(mcpDetails, 'Should normalize command without leading slash');
277
+ assert.ok(mcpDetails.subcommands.some(s => s.cmd.startsWith('connect')));
278
+ assert.ok(mcpDetails.subcommands.some(s => s.cmd.startsWith('save')));
279
+ assert.ok(mcpDetails.subcommands.some(s => s.cmd.startsWith('remove')));
280
+
281
+ const providerDetails = getCommandDetails('/provider');
282
+ assert.ok(providerDetails);
283
+ assert.ok(providerDetails.subcommands.some(s => s.cmd.startsWith('use')));
284
+ });
285
+
286
+ test('/<cmd> help and /help <cmd> route to help handler with subcommands', async () => {
287
+ let printedCmd = null;
288
+ const ctx = {
289
+ printHelp(cmd) { printedCmd = cmd; },
290
+ };
291
+
292
+ assert.equal(await routeCommand('/help suggestion', ctx), 'HANDLED');
293
+ assert.equal(printedCmd, 'suggestion');
294
+
295
+ assert.equal(await routeCommand('/suggestion help', ctx), 'HANDLED');
296
+ assert.equal(printedCmd, '/suggestion');
297
+ });
298
+
299
+ test('/cancel command aborts running query and returns handled', async () => {
300
+ let cancelled = false;
301
+ const ctx = {
302
+ cancelActiveQuery: () => {
303
+ cancelled = true;
304
+ return true;
305
+ },
306
+ };
307
+
308
+ assert.equal(await routeCommand('/cancel', ctx), 'HANDLED');
309
+ assert.equal(cancelled, true);
310
+
311
+ assert.equal(await routeCommand('/abort', ctx), 'HANDLED');
312
+ assert.equal(cancelled, true);
313
+ });
314
+
315
+ test('/copy command routes to copyQueryDetails context method and aliases work', async () => {
316
+ let copiedRef = null;
317
+ const ctx = {
318
+ copyQueryDetails: (ref) => {
319
+ copiedRef = ref;
320
+ return true;
321
+ },
322
+ };
323
+
324
+ assert.equal(await routeCommand('/copy', ctx), 'HANDLED');
325
+ assert.equal(copiedRef, '');
326
+
327
+ assert.equal(await routeCommand('/copy thinking', ctx), 'HANDLED');
328
+ assert.equal(copiedRef, 'thinking');
329
+
330
+ assert.equal(await routeCommand('/cp Q2 answer', ctx), 'HANDLED');
331
+ assert.equal(copiedRef, 'Q2 answer');
332
+
333
+ assert.equal(await routeCommand('/clipboard tool 1', ctx), 'HANDLED');
334
+ assert.equal(copiedRef, 'tool 1');
335
+ });
336
+
337
+ test('/stats renders an AI insight when a chat client is available', async () => {
338
+ const originalLog = console.log;
339
+ const output = [];
340
+ console.log = (...args) => output.push(args.join(' '));
341
+ const ctx = {
342
+ agent: {
343
+ memory: { preferences: { language: null } },
344
+ client: { chat: async () => ({ content: 'Dramas dominate the catalog.' }) },
345
+ },
346
+ };
347
+ try {
348
+ assert.equal(await routeCommand('/stats', ctx), 'HANDLED');
349
+ } finally {
350
+ console.log = originalLog;
351
+ }
352
+ const text = output.join('\n');
353
+ assert.match(text, /Database Overview/);
354
+ assert.match(text, /Dramas dominate the catalog/);
355
+ });
356
+
357
+ test('/stats falls back gracefully when no chat client is available', async () => {
358
+ const originalLog = console.log;
359
+ const output = [];
360
+ console.log = (...args) => output.push(args.join(' '));
361
+ try {
362
+ assert.equal(await routeCommand('/stats', {}), 'HANDLED');
363
+ } finally {
364
+ console.log = originalLog;
365
+ }
366
+ const text = output.join('\n');
367
+ assert.match(text, /Database Overview/);
368
+ assert.doesNotMatch(text, /STATS ERROR/);
369
+ });
370
+
371
+ test('/theme next cycles to next theme and renders tuiPrompt if available', async () => {
372
+ let rendered = false;
373
+ const mockTuiPrompt = { render() { rendered = true; } };
374
+ const current = getThemeName();
375
+ assert.equal(await routeCommand('/theme next', { tuiPrompt: mockTuiPrompt }), 'HANDLED');
376
+ assert.equal(rendered, true);
377
+ assert.notEqual(getThemeName(), current);
378
+
379
+
380
+ assert.equal(await routeCommand('/theme prev', { tuiPrompt: mockTuiPrompt }), 'HANDLED');
381
+ assert.equal(getThemeName(), current);
382
+ });
383
+
384
+ test('all hotkey target commands are routeable and handled', async () => {
385
+ const dummyCtx = {
386
+ agent: {
387
+ session: {
388
+ setThinkingMode() {},
389
+ toggleThinkingMode() { return 'show'; },
390
+ exportToMarkdown: async () => ({ filePath: 'export.md' }),
391
+ },
392
+ getAllToolsSchema: () => [],
393
+ },
394
+ client: {},
395
+ printHelp: () => {},
396
+ printQueryDetails: () => {},
397
+ copyQueryDetails: () => ({ error: null }),
398
+ showTerminalImage: () => true,
399
+ lastQuery: 'test query',
400
+ };
401
+
402
+
403
+ assert.equal(await routeCommand('/help', dummyCtx), 'HANDLED');
404
+ assert.equal(await routeCommand('/details', dummyCtx), 'HANDLED');
405
+ assert.equal(await routeCommand('/details thinking', dummyCtx), 'HANDLED');
406
+ assert.equal(await routeCommand('/details tools', dummyCtx), 'HANDLED');
407
+ assert.equal(await routeCommand('/copy', dummyCtx), 'HANDLED');
408
+ assert.equal(await routeCommand('/gallery', dummyCtx), 'HANDLED');
409
+ assert.equal(await routeCommand('/theme next', { ...dummyCtx, tuiPrompt: { render() {} } }), 'HANDLED');
410
+ assert.equal(await routeCommand('/stats', dummyCtx), 'HANDLED');
411
+ assert.equal(await routeCommand('/provider', dummyCtx), 'HANDLED');
412
+ assert.equal(await routeCommand('/mcp list', dummyCtx), 'HANDLED');
413
+ assert.equal(await routeCommand('/thinking toggle', dummyCtx), 'HANDLED');
414
+ assert.equal(await routeCommand('/retry', dummyCtx), 'RETRY');
415
+ assert.equal(await routeCommand('/save', dummyCtx), 'HANDLED');
416
+ });
417
+
@@ -0,0 +1,162 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { ContextCompactor, estimateTextTokens, estimateTokenCount } from '../contextCompactor.js';
5
+ import { KikuAgent } from '../agentCore.js';
6
+
7
+ function bareAgent() {
8
+ const agent = Object.create(KikuAgent.prototype);
9
+ agent.mcpManager = null;
10
+ agent.allowedToolNames = null;
11
+ agent.conversationHistory = [];
12
+ return agent;
13
+ }
14
+
15
+ test('context window split never orphans a tool result from its owning assistant message', () => {
16
+ const compactor = new ContextCompactor({ keepRecentMessages: 3 });
17
+ const history = [
18
+ { role: 'system', content: 'sys' },
19
+ { role: 'user', content: 'u1' },
20
+ { role: 'assistant', content: null, tool_calls: [{ id: 'c1', function: { name: 'web_search', arguments: '{}' } }] },
21
+ { role: 'tool', tool_call_id: 'c1', name: 'web_search', content: 'result1' },
22
+ { role: 'user', content: 'u2' },
23
+ { role: 'assistant', content: 'answer2' },
24
+ ];
25
+
26
+ const out = compactor.compact(history, { keepRecentMessages: 3 });
27
+
28
+ assert.equal(out[0].role, 'system');
29
+ const firstKept = out.find(m => m.role !== 'system');
30
+
31
+ assert.equal(firstKept.role, 'assistant');
32
+ assert.ok(Array.isArray(firstKept.tool_calls));
33
+ assert.ok(out.some(m => m.role === 'tool' && m.tool_call_id === 'c1'));
34
+ });
35
+
36
+ test('compaction never keeps a dangling tool result at the window boundary', () => {
37
+ const compactor = new ContextCompactor({ keepRecentMessages: 3 });
38
+ const history = [
39
+ { role: 'system', content: 'sys' },
40
+ { role: 'tool', name: 'web_search', content: 'dangling' },
41
+ { role: 'user', content: 'u1' },
42
+ { role: 'assistant', content: 'answer1' },
43
+ { role: 'user', content: 'u2' },
44
+ { role: 'assistant', content: 'answer2' },
45
+ ];
46
+
47
+ const out = compactor.compact(history, { keepRecentMessages: 3 });
48
+ const firstKept = out.find(m => m.role !== 'system');
49
+ assert.equal(firstKept.role, 'assistant');
50
+ assert.ok(!out.some(m => m.role === 'tool' && m.content === 'dangling'));
51
+ });
52
+
53
+ test('compactAsync prefers the LLM summarizer and falls back to the heuristic on failure', async () => {
54
+ const compactor = new ContextCompactor({ keepRecentMessages: 3 });
55
+ const history = [
56
+ { role: 'system', content: 'sys' },
57
+ { role: 'user', content: 'who won the 2026 election of nowhere?' },
58
+ { role: 'assistant', content: 'no verified data' },
59
+ { role: 'user', content: 'u2' },
60
+ { role: 'assistant', content: 'a2' },
61
+ { role: 'user', content: 'u3' },
62
+ { role: 'assistant', content: 'a3' },
63
+ ];
64
+
65
+ const llmOut = await compactor.compactAsync(history, {
66
+ keepRecentMessages: 3,
67
+ summarizer: async (older) => `LLM summary of ${older.length} messages`,
68
+ });
69
+ assert.match(llmOut[1].content, /LLM summary of 3 messages/);
70
+
71
+ const fallbackOut = await compactor.compactAsync(history, {
72
+ keepRecentMessages: 3,
73
+ summarizer: async () => { throw new Error('provider down'); },
74
+ });
75
+ assert.match(fallbackOut[1].content, /User asked: "who won the 2026 election of nowhere\?"/);
76
+ });
77
+
78
+ test('_enforceContextBudget trims oldest tool results when KIKU_CONTEXT_LIMIT is exceeded', () => {
79
+ const agent = bareAgent();
80
+ agent.conversationHistory = [
81
+ { role: 'system', content: 'x'.repeat(50) },
82
+ { role: 'user', content: 'y'.repeat(50) },
83
+ { role: 'tool', name: 'web_search', content: JSON.stringify({ payload: 'z'.repeat(2000) }) },
84
+ { role: 'tool', name: 'get_awards', content: JSON.stringify({ payload: 'q'.repeat(2000) }) },
85
+ ];
86
+
87
+ const previousLimit = process.env.KIKU_CONTEXT_LIMIT;
88
+ process.env.KIKU_CONTEXT_LIMIT = '100';
89
+ try {
90
+ assert.equal(agent._enforceContextBudget(), true);
91
+ assert.match(agent.conversationHistory[2].content, /omitted/);
92
+ assert.match(agent.conversationHistory[3].content, /omitted/);
93
+ assert.equal(agent.conversationHistory[0].content, 'x'.repeat(50));
94
+ assert.equal(agent.conversationHistory[1].content, 'y'.repeat(50));
95
+
96
+
97
+ assert.equal(agent._enforceContextBudget(), false);
98
+ } finally {
99
+ if (previousLimit === undefined) delete process.env.KIKU_CONTEXT_LIMIT;
100
+ else process.env.KIKU_CONTEXT_LIMIT = previousLimit;
101
+ }
102
+ });
103
+
104
+ test('_enforceContextBudget is inert without KIKU_CONTEXT_LIMIT', () => {
105
+ const agent = bareAgent();
106
+ const previousLimit = process.env.KIKU_CONTEXT_LIMIT;
107
+ delete process.env.KIKU_CONTEXT_LIMIT;
108
+ try {
109
+ agent.conversationHistory = [{ role: 'user', content: 'hi' }];
110
+ assert.equal(agent._enforceContextBudget(), false);
111
+ assert.equal(agent.conversationHistory[0].content, 'hi');
112
+ } finally {
113
+ if (previousLimit !== undefined) process.env.KIKU_CONTEXT_LIMIT = previousLimit;
114
+ }
115
+ });
116
+
117
+ test('session restore summarizes prior tool evidence instead of dropping it silently', () => {
118
+ const agent = bareAgent();
119
+ agent.session = {
120
+ messages: [
121
+ { role: 'user', parts: [{ type: 'text', content: 'เธอได้รางวัลอะไรบ้าง?' }] },
122
+ {
123
+ role: 'assistant',
124
+ parts: [
125
+ { type: 'tool', name: 'get_awards', status: 'ok', result: { count: 3 } },
126
+ { type: 'text', content: 'เธอได้รางวัล Forbes 30 Under 30 Asia (2019)' },
127
+ ],
128
+ },
129
+ { role: 'user', parts: [{ type: 'text', content: 'ขอบคุณ' }] },
130
+ { role: 'assistant', parts: [{ type: 'text', content: 'ด้วยความยินดีจ้ะ' }] },
131
+ ],
132
+ };
133
+
134
+ const lines = agent._rehydrateSessionMessages();
135
+ assert.equal(lines.length, 1);
136
+ assert.match(lines[0], /get_awards\(ok, 3\)/);
137
+ assert.match(lines[0], /Forbes 30 Under 30/);
138
+ assert.match(lines[0], /เธอได้รางวัลอะไรบ้าง\?/);
139
+
140
+ agent.conversationHistory = [{ role: 'system', content: 'sys' }];
141
+ agent._pushRestoredToolContext();
142
+ assert.equal(agent.conversationHistory.length, 2);
143
+ assert.match(agent.conversationHistory[1].content, /RESTORED SESSION TOOL CONTEXT/);
144
+ });
145
+
146
+ test('estimateTokenCount accounts for native tool_calls payloads', () => {
147
+ const withCalls = estimateTokenCount([
148
+ { role: 'assistant', content: '', tool_calls: [{ function: { name: 'x', arguments: 'a'.repeat(350) } }] },
149
+ ]);
150
+ const withoutCalls = estimateTokenCount([{ role: 'assistant', content: '' }]);
151
+ assert.ok(withCalls > withoutCalls);
152
+ assert.ok(withCalls >= 100);
153
+ });
154
+
155
+ test('Thai and CJK text is no longer under-counted by the flat chars/3.5 rule', () => {
156
+ const thai = 'จวีจิ้งอีแสดงเป็นหยางไฉ่เวยในเรื่อง In Blossom ปี สองพันยี่สิบสี่ ตัวละครมีความลึกล้ำและซับซ้อนมาก';
157
+ const asciiEquivalent = 'x'.repeat(thai.length);
158
+
159
+
160
+ assert.ok(estimateTextTokens(thai) > estimateTextTokens(asciiEquivalent) * 2);
161
+ assert.ok(estimateTokenCount([{ role: 'user', content: thai }]) > thai.length / 7);
162
+ });
@@ -0,0 +1,53 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ detectConversationDomain,
6
+ resolveConversationContext
7
+ } from '../../web-chat/core/conversationContext.js';
8
+ import { applyFutureEventGuard, findFutureDates } from '../../web-chat/core/futureEventGuard.js';
9
+ import { executeDirectCatalogKnowledge } from '../../web-chat/core/directCatalogEngine.js';
10
+
11
+ test('ambiguous Thai follow-up inherits the Vogue Gala topic instead of the artist default', () => {
12
+ const context = resolveConversationContext('ทำไมน่าจับตามอง?', [
13
+ { role: 'user', text: 'เล่าเรื่อง Vogue Gala 2026 ให้ฟังหน่อย' },
14
+ { role: 'bot', text: 'Vogue Gala เป็นงานแฟชั่นที่มีธีมและรายชื่อแขกรับเชิญประจำปี' }
15
+ ], new Date('2026-09-03T00:00:00Z'));
16
+
17
+ assert.equal(context.contextual, true);
18
+ assert.equal(context.domain, 'fashion_event');
19
+ assert.match(context.resolvedQuery, /Vogue Gala 2026/);
20
+ assert.match(context.resolvedQuery, /Do not switch the subject/);
21
+ });
22
+
23
+ test('domain detection gives the active fashion event priority over stray drama mentions', () => {
24
+ assert.equal(
25
+ detectConversationDomain('Vogue Gala 2026 ไม่ใช่การคุยเรื่อง Legend of Yunxi'),
26
+ 'fashion_event'
27
+ );
28
+ });
29
+
30
+ test('future-event guard marks and softens unverified invitation claims', () => {
31
+ const now = new Date('2026-09-03T00:00:00Z');
32
+ const answer = 'งานจะจัดวันที่ 23 ตุลาคม 2026 และเธอได้รับเชิญเข้าร่วมงานแน่นอน';
33
+ assert.equal(findFutureDates(answer, now).length > 0, true);
34
+
35
+ const guarded = applyFutureEventGuard(answer, { now, tools: [] });
36
+ assert.match(guarded, /สถานะข้อมูลล่วงหน้า/);
37
+ assert.doesNotMatch(guarded, /ได้รับเชิญเข้าร่วมงานแน่นอน/);
38
+ assert.doesNotMatch(guarded, /23 ตุลาคม 2026/);
39
+ assert.match(guarded, /วันที่ในอนาคตที่ยังไม่ยืนยัน/);
40
+ });
41
+
42
+ test('direct catalog fallback preserves an inherited event topic', async () => {
43
+ const conversationContext = resolveConversationContext('ทำไมน่าจับตามอง?', [
44
+ { role: 'user', text: 'Vogue Gala 2026 มีอะไรน่าสนใจ' },
45
+ { role: 'bot', text: 'ประเด็นหลักคือธีมของงานแฟชั่นและกำหนดการ' }
46
+ ]);
47
+ const result = await executeDirectCatalogKnowledge('ทำไมน่าจับตามอง?', 'default', { conversationContext });
48
+
49
+ assert.match(result.answer, /อ้างอิงหัวข้อเดิม/);
50
+ assert.doesNotMatch(result.answer, /ข้อมูลสรุป จวีจิ้งอี/);
51
+ assert.doesNotMatch(result.answer, /หยุนซี|Yunxi/i);
52
+ assert.equal(result.tools.length, 0);
53
+ });