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,201 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ import { McpClientManager } from '../mcpClient.js';
9
+ import { KikuAgent } from '../agentCore.js';
10
+ import { routeCommand } from '../commands.js';
11
+ import { PACKAGE_VERSION } from '../version.js';
12
+
13
+ const stdioServerPath = fileURLToPath(new URL('../../mcp/stdio.js', import.meta.url));
14
+
15
+ test('McpClientManager connects over stdio, discovers tools, and executes tool call', { timeout: 30_000 }, async () => {
16
+ const manager = new McpClientManager({ clientInfo: { name: 'kiku-agent-test', version: PACKAGE_VERSION }, autoLoad: false });
17
+
18
+ try {
19
+ const connResult = await manager.connect('local_kiku', {
20
+ command: process.execPath,
21
+ args: [stdioServerPath],
22
+ cwd: process.cwd(),
23
+ }, { persist: false });
24
+
25
+ assert.equal(connResult.success, true);
26
+ assert.equal(connResult.serverName, 'local_kiku');
27
+ assert.ok(connResult.toolsCount >= 10);
28
+
29
+ const schemas = manager.getToolsSchema();
30
+ assert.ok(schemas.length >= 10);
31
+ const searchTool = schemas.find(s => s.function.name === 'mcp_local_kiku_search_works');
32
+ assert.ok(searchTool, 'Tool should be registered with server namespace prefix');
33
+ assert.equal(searchTool.type, 'function');
34
+ assert.ok(searchTool.function.description.includes('[MCP Server: local_kiku]'));
35
+
36
+ const callResult = await manager.callTool('mcp_local_kiku_search_works', { year: 2026 });
37
+ assert.equal(callResult.status, 'success');
38
+ assert.equal(callResult.server, 'local_kiku');
39
+ assert.equal(callResult.tool, 'search_works');
40
+ assert.ok(callResult.count > 0);
41
+
42
+
43
+ const directCall = await manager.callTool('search_works', { year: 2026 });
44
+ assert.equal(directCall.status, 'success');
45
+
46
+
47
+ const status = manager.getStatus();
48
+ assert.equal(status.activeServersCount, 1);
49
+ assert.equal(status.servers[0].name, 'local_kiku');
50
+ } finally {
51
+ await manager.disconnectAll();
52
+ }
53
+ });
54
+
55
+ test('KikuAgent dynamically registers MCP tools into tool schema and system prompt', { timeout: 30_000 }, async () => {
56
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'kiku-mcp-agent-test-'));
57
+ const agent = new KikuAgent({
58
+ mcpManager: new McpClientManager({
59
+ autoLoad: false,
60
+ globalConfigPath: path.join(tempDir, 'mcp_servers.json'),
61
+ }),
62
+ client: {
63
+ provider: 'lmstudio',
64
+ model: 'test-model',
65
+ async chat() { return { role: 'assistant', content: 'test response' }; },
66
+ async checkConnection() { return { connected: true, models: ['test-model'] }; }
67
+ }
68
+ });
69
+ await agent.ready();
70
+
71
+ const initialToolsCount = agent.getAllToolsSchema().length;
72
+
73
+ try {
74
+ const conn = await agent.connectMcp('test_kiku', {
75
+ command: process.execPath,
76
+ args: [stdioServerPath],
77
+ cwd: process.cwd(),
78
+ });
79
+ assert.equal(conn.success, true);
80
+
81
+ const updatedTools = agent.getAllToolsSchema();
82
+ assert.ok(updatedTools.length > initialToolsCount);
83
+ assert.ok(updatedTools.some(t => t.function.name.startsWith('mcp_test_kiku_')));
84
+ assert.ok(agent.systemPrompt.includes('mcp_test_kiku_'));
85
+
86
+
87
+ assert.equal(await routeCommand('/mcp status', { agent, sub: 'status' }), 'HANDLED');
88
+ assert.equal(await routeCommand('/mcp tools', { agent, sub: 'tools' }), 'HANDLED');
89
+
90
+
91
+ const disconn = await agent.disconnectMcp('test_kiku');
92
+ assert.equal(disconn, true);
93
+ assert.equal(agent.getAllToolsSchema().length, initialToolsCount);
94
+ } finally {
95
+ await agent.mcpManager.disconnectAll();
96
+ await rm(tempDir, { recursive: true, force: true });
97
+ }
98
+ });
99
+
100
+ test('DynamicToolRouter pins tools mentioned with @ in the prompt with top priority', () => {
101
+ const agent = new KikuAgent({
102
+ mcpManager: new McpClientManager({ autoLoad: false }),
103
+ client: {
104
+ provider: 'lmstudio',
105
+ model: 'test-model',
106
+ async chat() { return { role: 'assistant', content: 'test response' }; },
107
+ async checkConnection() { return { connected: true, models: ['test-model'] }; }
108
+ }
109
+ });
110
+
111
+ const routed = agent.router.routeToolsForQuery('Please tell me about her works @search_works');
112
+ assert.ok(routed.length > 0);
113
+ assert.equal(routed[0].function.name, 'search_works');
114
+
115
+ const routedAwards = agent.router.routeToolsForQuery('@get_awards check all music awards');
116
+ assert.ok(routedAwards.length > 0);
117
+ assert.equal(routedAwards[0].function.name, 'get_awards');
118
+ });
119
+
120
+ test('McpClientManager persists configurations across disconnects and supports save/remove', async () => {
121
+ const manager = new McpClientManager({ autoLoad: false });
122
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'kiku-mcp-config-test-'));
123
+ const configPath = path.join(tempDir, 'mcp_servers.json');
124
+
125
+ try {
126
+ manager.persistedConfigs.set('test_server', {
127
+ type: 'streamable-http',
128
+ url: 'https://example.com/mcp',
129
+ enabled: true,
130
+ });
131
+
132
+ const status = manager.getStatus();
133
+ assert.equal(status.totalConfiguredCount, 1);
134
+ assert.equal(status.servers[0].name, 'test_server');
135
+ assert.equal(status.servers[0].status, 'saved_offline');
136
+
137
+ const saveRes = await manager.savePersistedConfig(configPath);
138
+ assert.equal(saveRes.success, true);
139
+ assert.equal(saveRes.filePath, configPath);
140
+ assert.ok(saveRes.count >= 1);
141
+ const saved = JSON.parse(await readFile(configPath, 'utf8'));
142
+ assert.equal(saved.mcpServers.test_server.url, 'https://example.com/mcp');
143
+
144
+ const removed = await manager.remove('test_server', { persist: false });
145
+ assert.equal(removed, true);
146
+ assert.equal(manager.persistedConfigs.has('test_server'), false);
147
+ } finally {
148
+ await rm(tempDir, { recursive: true, force: true });
149
+ }
150
+ });
151
+
152
+ test('McpClientManager permanently removes a server from every loaded config source', async () => {
153
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'kiku-mcp-source-remove-test-'));
154
+ const globalConfigPath = path.join(tempDir, 'mcp_servers.json');
155
+ const workspaceConfigPath = path.join(tempDir, 'opencode.json');
156
+
157
+ await writeFile(globalConfigPath, JSON.stringify({
158
+ mcpServers: {
159
+ duplicate_server: { type: 'remote', url: 'https://global.example/mcp', enabled: false },
160
+ global_only: { type: 'remote', url: 'https://global-only.example/mcp', enabled: false },
161
+ },
162
+ }, null, 2));
163
+ await writeFile(workspaceConfigPath, JSON.stringify({
164
+ $schema: 'https://opencode.ai/config.json',
165
+ mcp: {
166
+ duplicate_server: { type: 'remote', url: 'https://workspace.example/mcp', enabled: false },
167
+ workspace_only: { type: 'remote', url: 'https://workspace-only.example/mcp', enabled: false },
168
+ },
169
+ }, null, 2));
170
+
171
+ const managerOptions = {
172
+ globalConfigPath,
173
+ configPaths: [globalConfigPath, workspaceConfigPath],
174
+ };
175
+
176
+ try {
177
+ const manager = new McpClientManager(managerOptions);
178
+ await manager.loadPersistedServers();
179
+
180
+ const before = manager.getStatus().servers.find(server => server.name === 'duplicate_server');
181
+ assert.ok(before);
182
+ assert.deepEqual(new Set(before.sources), new Set([globalConfigPath, workspaceConfigPath]));
183
+
184
+ assert.equal(await manager.remove('duplicate_server'), true);
185
+
186
+ const savedGlobal = JSON.parse(await readFile(globalConfigPath, 'utf8'));
187
+ const savedWorkspace = JSON.parse(await readFile(workspaceConfigPath, 'utf8'));
188
+ assert.equal(savedGlobal.mcpServers.duplicate_server, undefined);
189
+ assert.equal(savedWorkspace.mcp.duplicate_server, undefined);
190
+ assert.ok(savedGlobal.mcpServers.global_only);
191
+ assert.ok(savedWorkspace.mcp.workspace_only);
192
+ assert.equal(savedWorkspace.$schema, 'https://opencode.ai/config.json');
193
+
194
+ const restartedManager = new McpClientManager(managerOptions);
195
+ await restartedManager.loadPersistedServers();
196
+ assert.equal(restartedManager.persistedConfigs.has('duplicate_server'), false);
197
+ } finally {
198
+ await rm(tempDir, { recursive: true, force: true });
199
+ }
200
+ });
201
+
@@ -0,0 +1,173 @@
1
+ import test, { after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { Readable } from 'node:stream';
4
+ import { EventEmitter } from 'node:events';
5
+ import { AgentMemory } from '../memory.js';
6
+ import { setMemoryStore, handleMemoryGetRoute, handleMemoryActionRoute } from '../../web-chat/routes/memoryRoute.js';
7
+
8
+ function request(body, headers = {}) {
9
+ const req = Readable.from([JSON.stringify(body === undefined ? {} : body)]);
10
+ req.socket = { remoteAddress: '127.0.0.1' };
11
+ req.headers = headers;
12
+ return req;
13
+ }
14
+
15
+ function response() {
16
+ const res = new EventEmitter();
17
+ res.chunks = '';
18
+ res.writeHead = (code) => { res.statusCode = code; res.headersSent = true; };
19
+ res.write = (data) => { res.chunks += data; };
20
+ res.end = (data = '') => { res.chunks += data; res.writableEnded = true; res.emit('finish'); };
21
+ res.json = () => JSON.parse(res.chunks);
22
+ return res;
23
+ }
24
+
25
+ function freshStore() {
26
+ setMemoryStore(new AgentMemory({ persistence: false }));
27
+ }
28
+
29
+ async function getJson() {
30
+ const res = response();
31
+ await handleMemoryGetRoute(request(), res);
32
+ assert.equal(res.statusCode, 200);
33
+ return res.json();
34
+ }
35
+
36
+ async function postJson(body, headers = {}) {
37
+ const res = response();
38
+ await handleMemoryActionRoute(request(body, headers), res);
39
+ return { status: res.statusCode, body: res.chunks ? res.json() : null };
40
+ }
41
+
42
+ test('memory GET reports empty store with persistence flag', async () => {
43
+ freshStore();
44
+ const data = await getJson();
45
+ assert.equal(data.status, 'ok');
46
+ assert.equal(data.factsCount, 0);
47
+ assert.equal(data.persistent, false);
48
+ assert.deepEqual(data.facts, []);
49
+ assert.deepEqual(data.topTopics, []);
50
+ });
51
+
52
+ test('memory add stores, lists, searches and deletes facts', async () => {
53
+ freshStore();
54
+ const added = await postJson({ action: 'add', content: 'ผู้ใช้ชื่นชอบซีรีส์ In Blossom', category: 'drama', tags: ['In Blossom'] });
55
+ assert.equal(added.status, 200);
56
+ assert.ok(added.body.fact.id);
57
+ assert.equal(added.body.fact.category, 'drama');
58
+ assert.ok(added.body.fact.createdAt);
59
+
60
+ const listed = await getJson();
61
+ assert.equal(listed.factsCount, 1);
62
+ assert.equal(listed.factsByCategory.drama, 1);
63
+
64
+ const found = await postJson({ action: 'search', query: 'Blossom' });
65
+ assert.equal(found.status, 200);
66
+ assert.equal(found.body.count, 1);
67
+
68
+ const missed = await postJson({ action: 'search', query: 'Versace' });
69
+ assert.equal(missed.body.count, 0);
70
+
71
+ const removed = await postJson({ action: 'delete', id: added.body.fact.id });
72
+ assert.equal(removed.status, 200);
73
+ assert.equal((await getJson()).factsCount, 0);
74
+ });
75
+
76
+ test('memory add/delete validate input strictly', async () => {
77
+ freshStore();
78
+ assert.equal((await postJson({ action: 'add', content: ' ' })).status, 400);
79
+ assert.equal((await postJson({ action: 'add', content: 'x'.repeat(2001) })).status, 400);
80
+ assert.equal((await postJson({ action: 'delete', id: 'bad id!' })).status, 400);
81
+ assert.equal((await postJson({ action: 'delete', id: 'fact_missing' })).status, 404);
82
+ assert.equal((await postJson({ action: 'unknown-op' })).status, 400);
83
+ });
84
+
85
+ test('memory preserves raw content verbatim for UI-side escaping', async () => {
86
+ freshStore();
87
+ const evil = '<script>alert(1)</script>';
88
+ const added = await postJson({ action: 'add', content: evil });
89
+ assert.equal(added.status, 200);
90
+ assert.equal(added.body.fact.content, evil);
91
+ });
92
+
93
+ test('memory GET enriches archived sessions with preview and topics', async () => {
94
+ freshStore();
95
+ const data = await getJson();
96
+ assert.deepEqual(data.sessions, []);
97
+ });
98
+
99
+ test('memory session_summary returns 400/404 for invalid or unknown ids', async () => {
100
+ freshStore();
101
+ assert.equal((await postJson({ action: 'session_summary' })).status, 400);
102
+ assert.equal((await postJson({ action: 'session_summary', sessionId: 'bad id!' })).status, 400);
103
+ assert.equal((await postJson({ action: 'session_summary', sessionId: 'sess_missing' })).status, 404);
104
+ });
105
+
106
+ test('memory session_summary builds exchanges from an archived session on disk', async () => {
107
+ freshStore();
108
+ const mem = new AgentMemory({ persistence: false });
109
+ mem.sessions = [{
110
+ id: 'sess_live_1',
111
+ createdAt: new Date('2026-09-15T10:00:00Z'),
112
+ endedAt: new Date('2026-09-15T10:05:00Z'),
113
+ messageCount: 2,
114
+ summary: null,
115
+ }];
116
+ mem.loadArchivedSession = async (sessionId) => {
117
+ assert.equal(sessionId, 'sess_live_1');
118
+ return {
119
+ id: 'sess_live_1',
120
+ createdAt: new Date('2026-09-15T10:00:00Z'),
121
+ endedAt: new Date('2026-09-15T10:05:00Z'),
122
+ stats: { totalDurationMs: 65400, totalToolCalls: 2 },
123
+ messages: [
124
+ { role: 'user', parts: [{ type: 'text', content: 'In Blossom ออกปีไหน' }] },
125
+ { role: 'assistant', parts: [{ type: 'text', content: 'ออกอากาศปี 2024 ค่ะ' }] },
126
+ ],
127
+ };
128
+ };
129
+ setMemoryStore(mem);
130
+ const result = await postJson({ action: 'session_summary', sessionId: 'sess_live_1' });
131
+ assert.equal(result.status, 200);
132
+ assert.equal(result.body.status, 'ok');
133
+ assert.equal(result.body.sessionId, 'sess_live_1');
134
+ assert.equal(result.body.messageCount, 2);
135
+ assert.equal(result.body.stats.totalToolCalls, 2);
136
+ assert.equal(result.body.summary.questionCount, 1);
137
+ assert.equal(result.body.summary.durationMs, 65400);
138
+ assert.equal(result.body.summary.keyExchanges.length, 1);
139
+ assert.equal(result.body.summary.keyExchanges[0].question, 'In Blossom ออกปีไหน');
140
+ assert.equal(result.body.summary.keyExchanges[0].answerPreview, 'ออกอากาศปี 2024 ค่ะ');
141
+ });
142
+
143
+ test('memory export returns a downloadable snapshot', async () => {
144
+ freshStore();
145
+ await postJson({ action: 'add', content: 'fact one' });
146
+ const exported = await postJson({ action: 'export' });
147
+ assert.equal(exported.status, 200);
148
+ assert.ok(exported.body.exportedAt);
149
+ assert.equal(exported.body.facts.length, 1);
150
+ assert.equal(exported.body.facts[0].content, 'fact one');
151
+ });
152
+
153
+ test('memory clear requires an admin key', async () => {
154
+ freshStore();
155
+ await postJson({ action: 'add', content: 'fact one' });
156
+ const origAdminKey = process.env.WEBCHAT_ADMIN_KEY;
157
+ process.env.WEBCHAT_ADMIN_KEY = 'mem-route-test-admin';
158
+ try {
159
+ assert.equal((await postJson({ action: 'clear' })).status, 401);
160
+ assert.equal((await postJson({ action: 'clear' }, { 'x-api-key': 'wrong' })).status, 401);
161
+ assert.equal((await getJson()).factsCount, 1);
162
+ const cleared = await postJson({ action: 'clear' }, { 'x-api-key': 'mem-route-test-admin' });
163
+ assert.equal(cleared.status, 200);
164
+ assert.equal((await getJson()).factsCount, 0);
165
+ } finally {
166
+ if (origAdminKey !== undefined) process.env.WEBCHAT_ADMIN_KEY = origAdminKey;
167
+ else delete process.env.WEBCHAT_ADMIN_KEY;
168
+ }
169
+ });
170
+
171
+ after(() => {
172
+ setMemoryStore(new AgentMemory({ persistence: false }));
173
+ });
@@ -0,0 +1,75 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFile } from 'node:fs/promises';
4
+
5
+ import { getSettingsHtml } from '../../web-chat/ui/settingsBuilder.js';
6
+
7
+ test('memory session fields use normalized wrappers and fluid responsive classes', async () => {
8
+ const html = getSettingsHtml();
9
+ const css = await readFile(new URL('../../web-chat/ui/styles/main.css', import.meta.url), 'utf8');
10
+
11
+ assert.match(html, /class="memory-input-layout memory-input-layout--textarea"/);
12
+ assert.match(html, /class="memory-input-layout memory-search-field"/);
13
+ assert.match(html, /id="mem-search" class="form-control memory-field"/);
14
+ assert.doesNotMatch(html, /id="mem-search"[^>]+min-width:\s*200px/);
15
+ assert.match(css, /#tab-memory \.memory-field::placeholder[\s\S]*line-height:\s*1\.45/);
16
+ assert.match(css, /#tab-memory input\.memory-field::placeholder[\s\S]*text-overflow:\s*ellipsis/);
17
+ assert.match(css, /#tab-memory textarea\.memory-field[\s\S]*line-height:\s*1\.6/);
18
+ assert.match(css, /#tab-memory \.memory-field \{[\s\S]*min-inline-size:\s*0/);
19
+ assert.match(css, /padding:\s*0\.625rem 0\.875rem/);
20
+ assert.match(css, /@media \(max-width:\s*40rem\)[\s\S]*grid-template-columns:\s*minmax\(0, 1fr\)/);
21
+ });
22
+
23
+ test('memory sessions browser ships session list, filter chips and i18n strings', async () => {
24
+ const html = getSettingsHtml();
25
+ const css = await readFile(new URL('../../web-chat/ui/styles/main.css', import.meta.url), 'utf8');
26
+
27
+ assert.match(html, /id="mem-sessions-list" class="mem-sessions"/);
28
+ assert.match(html, /id="mem-category-filter" class="mem-category-filter"/);
29
+ assert.match(html, /onclick="loadMemorySessions\(true\)"/);
30
+ assert.match(html, /toggleMemorySession\(this\.dataset\.sessionId\)/);
31
+ assert.match(html, /filterMemoryFactsByCategory\(this\.dataset\.cat\)/);
32
+ assert.match(html, /cardTitleMemSessions/);
33
+ assert.match(html, /memSessionsEmpty/);
34
+
35
+ assert.match(css, /#tab-memory \.mem-sessions \{[\s\S]*?flex-direction:\s*column/);
36
+ assert.match(css, /#tab-memory \.mem-session \{[\s\S]*?border-radius:\s*14px/);
37
+ assert.match(css, /#tab-memory \.mem-session-head \{[\s\S]*?cursor:\s*pointer/);
38
+ assert.match(css, /#tab-memory \.mem-session-detail-wrap \{[\s\S]*?border-top:\s*1px dashed/);
39
+ assert.match(css, /#tab-memory \.mem-filter-chip \{[\s\S]*?border-radius:\s*999px/);
40
+ assert.match(css, /#tab-memory \.mem-sessions-empty \{[\s\S]*?border:\s*1px dashed/);
41
+ });
42
+
43
+ test('memory sessions use spaced layout classes instead of cramped inline styles', async () => {
44
+ const html = getSettingsHtml();
45
+ const css = await readFile(new URL('../../web-chat/ui/styles/main.css', import.meta.url), 'utf8');
46
+
47
+ assert.match(html, /id="mem-status-grid" class="mem-stats"/);
48
+ assert.match(html, /id="mem-topics" class="mem-topics"/);
49
+ assert.match(html, /id="mem-facts-list" class="mem-facts"/);
50
+ assert.match(html, /class="[^"]*\bmem-fact-content\b/);
51
+ assert.match(html, /class="[^"]*\bmem-fact-meta\b/);
52
+ assert.match(html, /class="[^"]*\bmem-topic-chip\b/);
53
+ assert.doesNotMatch(html, /id="mem-facts-list"[^>]*style=/);
54
+ assert.doesNotMatch(html, /id="mem-status-grid"[^>]*style=/);
55
+
56
+ assert.match(css, /#tab-memory \.mem-stats \{[\s\S]*?gap:\s*14px/);
57
+ assert.match(css, /#tab-memory \.mem-stat \{[\s\S]*?padding:\s*16px 18px/);
58
+ assert.match(css, /#tab-memory \.mem-stat-value \{[\s\S]*?margin-top:\s*8px/);
59
+ assert.match(css, /#tab-memory \.mem-topics \{[\s\S]*?gap:\s*10px/);
60
+ assert.match(css, /#tab-memory \.mem-topic-chip \{[\s\S]*?padding:\s*6px 14px/);
61
+ assert.match(css, /#tab-memory \.mem-facts \{[\s\S]*?gap:\s*12px/);
62
+ assert.match(css, /#tab-memory \.mem-fact \{[\s\S]*?padding:\s*16px 18px/);
63
+ assert.match(css, /#tab-memory \.mem-fact-main \{[\s\S]*?gap:\s*14px/);
64
+ assert.match(css, /#tab-memory \.mem-fact-content \{[\s\S]*?line-height:\s*1\.7/);
65
+ assert.match(css, /#tab-memory \.mem-fact-meta \{[\s\S]*?margin-top:\s*12px/);
66
+ assert.match(css, /#tab-memory \.mem-actions \{[\s\S]*?margin-top:\s*18px/);
67
+ assert.match(css, /#tab-memory \.form-label \{[\s\S]*?margin-bottom:\s*8px/);
68
+ });
69
+
70
+ test('LM Studio settings default Gemma sessions to a 32K loaded context and one parallel slot', () => {
71
+ const html = getSettingsHtml();
72
+ assert.match(html, /id="cfg-lms-context"[^>]+value="32768"/);
73
+ assert.match(html, /id="cfg-cpu-threads"[^>]+value="1"/);
74
+ assert.match(html, /id="cfg-context-limit"[^>]+value="32768"/);
75
+ });
@@ -0,0 +1,148 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { LLMClient } from '../llmClient.js';
5
+ import { getModelCompatibility, normalizeAssistantMessage, StreamContentNormalizer } from '../modelCompatibility.js';
6
+
7
+ test('model compatibility registry selects profiles for major model families', () => {
8
+ assert.equal(getModelCompatibility('vllm', 'Qwen/Qwen3-8B').id, 'qwen3');
9
+ assert.equal(getModelCompatibility('vllm', 'Qwen/Qwen3-Coder-30B').toolProtocol, 'qwen3-xml');
10
+ assert.equal(getModelCompatibility('vllm', 'deepseek-ai/DeepSeek-R1').reasoningProtocol, 'deepseek');
11
+ assert.equal(getModelCompatibility('vllm', 'meta-llama/Llama-3.1-8B-Instruct').toolProtocol, 'llama-json');
12
+ assert.equal(getModelCompatibility('vllm', 'mistralai/Mistral-7B-Instruct-v0.3').toolProtocol, 'mistral');
13
+ });
14
+
15
+ test('normalizer converts legacy function calls and tagged Qwen calls', () => {
16
+ const legacy = normalizeAssistantMessage({
17
+ role: 'assistant',
18
+ content: 'answer',
19
+ function_call: { name: 'search_works', arguments: { query: 'In Blossom' } }
20
+ });
21
+ assert.equal(legacy.tool_calls[0].function.name, 'search_works');
22
+ assert.deepEqual(JSON.parse(legacy.tool_calls[0].function.arguments), { query: 'In Blossom' });
23
+
24
+ const qwen = normalizeAssistantMessage({
25
+ role: 'assistant',
26
+ content: '<tool_call>{"name":"search_works","arguments":{"query":"In Blossom"}}</tool_call>'
27
+ });
28
+ assert.equal(qwen.tool_calls[0].function.name, 'search_works');
29
+ assert.equal(qwen.content, '');
30
+ });
31
+
32
+ test('normalizer keeps reasoning separate from Qwen think content', () => {
33
+ const result = normalizeAssistantMessage({
34
+ role: 'assistant',
35
+ content: '<think>internal plan</think>Final answer'
36
+ });
37
+ assert.equal(result.reasoning_content, 'internal plan');
38
+ assert.equal(result.content, 'Final answer');
39
+ });
40
+
41
+ test('stream content normalizer handles split think tags and flushes final content', () => {
42
+ const normalizer = new StreamContentNormalizer();
43
+ assert.deepEqual(normalizer.push('Before <th'), { answer: 'Before ', reasoning: '' });
44
+ assert.deepEqual(normalizer.push('ink>hidden</think>After'), { answer: 'After', reasoning: 'hidden' });
45
+ assert.deepEqual(normalizer.flush(), { answer: '', reasoning: '' });
46
+ });
47
+
48
+ test('LLMClient applies Qwen3 thinking request options only when requested', async () => {
49
+ const client = new LLMClient({ provider: 'vllm', model: 'Qwen/Qwen3-8B', baseUrl: 'http://127.0.0.1:8000/v1' });
50
+ const originalFetch = globalThis.fetch;
51
+ let request;
52
+ globalThis.fetch = async (url, options) => {
53
+ request = JSON.parse(options.body);
54
+ return new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'ok' } }] }), {
55
+ status: 200,
56
+ headers: { 'Content-Type': 'application/json' }
57
+ });
58
+ };
59
+ try {
60
+ await client.chat([{ role: 'user', content: 'hello' }], { stream: false, thinkingMode: 'hide' });
61
+ assert.deepEqual(request.chat_template_kwargs, { enable_thinking: false });
62
+ } finally {
63
+ globalThis.fetch = originalFetch;
64
+ }
65
+ });
66
+
67
+ test('LLMClient flushes a final SSE event without a trailing newline', async () => {
68
+ const client = new LLMClient({ provider: 'vllm', model: 'Qwen/Qwen2.5-7B-Instruct', baseUrl: 'http://127.0.0.1:8000/v1' });
69
+ const originalFetch = globalThis.fetch;
70
+ globalThis.fetch = async () => {
71
+ const body = `data: ${JSON.stringify({ choices: [{ delta: { content: 'final' } }] })}`;
72
+ const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(body)); controller.close(); } });
73
+ return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
74
+ };
75
+ try {
76
+ const result = await client.chat([{ role: 'user', content: 'hello' }], { stream: true });
77
+ assert.equal(result.content, 'final');
78
+ } finally {
79
+ globalThis.fetch = originalFetch;
80
+ }
81
+ });
82
+
83
+ test('normalizer converts Meta Llama 3.1+ Pythonic tool calls', () => {
84
+ const llama = normalizeAssistantMessage({
85
+ role: 'assistant',
86
+ content: '<|python_tag|>search_works(query="In Blossom", limit=5, verified=true)'
87
+ });
88
+ assert.equal(llama.tool_calls.length, 1);
89
+ assert.equal(llama.tool_calls[0].function.name, 'search_works');
90
+ const args = JSON.parse(llama.tool_calls[0].function.arguments);
91
+ assert.equal(args.query, 'In Blossom');
92
+ assert.equal(args.limit, 5);
93
+ assert.equal(args.verified, true);
94
+ assert.equal(llama.content, '');
95
+ });
96
+
97
+ test('stream content normalizer suppresses tool tags from answer stream and buffers them', () => {
98
+ const normalizer = new StreamContentNormalizer();
99
+ const chunk1 = normalizer.push('Answer before <tool_');
100
+ assert.equal(chunk1.answer, 'Answer before ');
101
+
102
+ const chunk2 = normalizer.push('call>{"name":"search_works","arguments":{"query":"In Blossom"}}</tool_call> answer after');
103
+ assert.equal(chunk2.answer, ' answer after');
104
+ assert.equal(chunk2.reasoning, '');
105
+
106
+ const flushed = normalizer.flush();
107
+ assert.ok(flushed.toolCallBuffer.includes('search_works'));
108
+ });
109
+
110
+ test('toAnthropicMessages preserves extended thinking blocks and groups parallel tool results', async () => {
111
+ const { toAnthropicMessages, normalizeAnthropicResponse } = await import('../cloudAdapters.js');
112
+
113
+ const normalized = normalizeAnthropicResponse({
114
+ content: [
115
+ { type: 'thinking', thinking: 'internal thought plan', signature: 'sig_abc123' },
116
+ { type: 'text', text: 'Calling tool now' },
117
+ { type: 'tool_use', id: 'toolu_1', name: 'search_works', input: { query: 'Ju Jingyi' } }
118
+ ]
119
+ });
120
+ assert.equal(normalized.thinking_blocks.length, 1);
121
+ assert.equal(normalized.thinking_blocks[0].signature, 'sig_abc123');
122
+
123
+ const history = [
124
+ { role: 'user', content: 'Who is Ju Jingyi?' },
125
+ normalized,
126
+ { role: 'tool', tool_call_id: 'toolu_1', content: JSON.stringify({ status: 'success', name: 'Ju Jingyi' }) },
127
+ { role: 'tool', tool_call_id: 'toolu_2', content: JSON.stringify({ status: 'error', error: 'not found' }) }
128
+ ];
129
+
130
+ const anthropic = toAnthropicMessages(history);
131
+
132
+ const asstMsg = anthropic.messages.find(m => m.role === 'assistant');
133
+ assert.ok(asstMsg);
134
+ assert.equal(asstMsg.content[0].type, 'thinking');
135
+ assert.equal(asstMsg.content[0].signature, 'sig_abc123');
136
+ assert.equal(asstMsg.content[1].type, 'text');
137
+ assert.equal(asstMsg.content[2].type, 'tool_use');
138
+
139
+
140
+ const userToolMsg = anthropic.messages[anthropic.messages.length - 1];
141
+ assert.equal(userToolMsg.role, 'user');
142
+ assert.equal(userToolMsg.content.length, 2);
143
+ assert.equal(userToolMsg.content[0].tool_use_id, 'toolu_1');
144
+ assert.equal(userToolMsg.content[0].is_error, undefined);
145
+ assert.equal(userToolMsg.content[1].tool_use_id, 'toolu_2');
146
+ assert.equal(userToolMsg.content[1].is_error, true);
147
+ });
148
+