kiku-agent 1.3.2 → 1.3.4

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 (152) hide show
  1. package/README.md +69 -12
  2. package/package.json +2 -1
  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/modules/evaluation/runner.js +4 -14
  18. package/server/agent/eval/modules/evaluation/scoring.js +15 -3
  19. package/server/agent/eval/modules/server/evalApi.js +5 -5
  20. package/server/agent/eval/modules/ui/evalUi.js +7 -4
  21. package/server/agent/eventBus.js +1 -0
  22. package/server/agent/ink/main.jsx +196 -191
  23. package/server/agent/jobManager.js +11 -1
  24. package/server/agent/lanDiscovery.js +721 -23
  25. package/server/agent/llmClient.js +317 -71
  26. package/server/agent/lmStudioWakeup.js +517 -65
  27. package/server/agent/locationService.js +797 -0
  28. package/server/agent/mcpQuizData.js +509 -0
  29. package/server/agent/memory/contextAllocator.js +19 -7
  30. package/server/agent/memory.js +3 -1
  31. package/server/agent/modelCompatibility.js +352 -0
  32. package/server/agent/modelDefaults.js +1 -0
  33. package/server/agent/modelManager.js +46 -2
  34. package/server/agent/newsWatch.js +9 -3
  35. package/server/agent/processManager.js +229 -0
  36. package/server/agent/prompts.js +174 -8
  37. package/server/agent/routingInterceptor.js +9 -1
  38. package/server/agent/sanitizer.js +41 -6
  39. package/server/agent/searchQueryPlanner.js +202 -0
  40. package/server/agent/session.js +59 -4
  41. package/server/agent/tests/agentFollowUps.test.js +128 -2
  42. package/server/agent/tests/agentMusicControl.test.js +245 -0
  43. package/server/agent/tests/apiRouter.test.js +1 -1
  44. package/server/agent/tests/autoContext.test.js +176 -0
  45. package/server/agent/tests/bilibiliEmbed.test.js +45 -0
  46. package/server/agent/tests/chatHardening.test.js +121 -0
  47. package/server/agent/tests/chineseWebSearchEscalation.test.js +62 -0
  48. package/server/agent/tests/clarifyingQuestions.test.js +33 -0
  49. package/server/agent/tests/cliAutomation.test.js +38 -1
  50. package/server/agent/tests/cloudProviders.test.js +91 -3
  51. package/server/agent/tests/commands.test.js +92 -3
  52. package/server/agent/tests/contextIntelligence.test.js +50 -3
  53. package/server/agent/tests/cpuMetrics.test.js +101 -0
  54. package/server/agent/tests/decisionAndSearchEnhancements.test.js +32 -0
  55. package/server/agent/tests/edgeCasesScenarios.test.js +82 -0
  56. package/server/agent/tests/engineService.test.js +436 -0
  57. package/server/agent/tests/engineUnload.test.js +94 -0
  58. package/server/agent/tests/envCommand.test.js +69 -0
  59. package/server/agent/tests/evalMetrics.test.js +8 -0
  60. package/server/agent/tests/fetchApi.test.js +99 -0
  61. package/server/agent/tests/hybridWebSearch.test.js +2 -0
  62. package/server/agent/tests/lanDiscovery.test.js +5 -0
  63. package/server/agent/tests/lanInterfaces.test.js +510 -0
  64. package/server/agent/tests/lmStudioWakeup.test.js +278 -0
  65. package/server/agent/tests/locationTool.test.js +278 -0
  66. package/server/agent/tests/memoryRoute.test.js +173 -0
  67. package/server/agent/tests/memoryUiNormalization.test.js +75 -0
  68. package/server/agent/tests/modelCompatibility.test.js +148 -0
  69. package/server/agent/tests/modelManager.test.js +32 -0
  70. package/server/agent/tests/musicControlUiBridge.test.js +113 -0
  71. package/server/agent/tests/nextGenArchitecture.test.js +4 -2
  72. package/server/agent/tests/openDomainPrompt.test.js +197 -0
  73. package/server/agent/tests/openDomainSearch.test.js +121 -0
  74. package/server/agent/tests/processManager.test.js +144 -0
  75. package/server/agent/tests/providerPersistence.test.js +10 -5
  76. package/server/agent/tests/quickActionsAndModals.test.js +180 -0
  77. package/server/agent/tests/searchQueryPlanner.test.js +108 -0
  78. package/server/agent/tests/securityAuditRemediation.test.js +16 -9
  79. package/server/agent/tests/securityAuditRemediations2.test.js +154 -0
  80. package/server/agent/tests/securityHardening.test.js +4 -4
  81. package/server/agent/tests/sessionAndCopy.test.js +39 -4
  82. package/server/agent/tests/sidebarControls.test.js +34 -0
  83. package/server/agent/tests/statefulMemory.test.js +8 -1
  84. package/server/agent/tests/textProtocolLoop.test.js +71 -0
  85. package/server/agent/tests/textToolParsing.test.js +48 -0
  86. package/server/agent/tests/toolRoutingRegressions.test.js +41 -0
  87. package/server/agent/tests/unifiedEndpoints.test.js +13 -6
  88. package/server/agent/tests/v110Features.test.js +1 -1
  89. package/server/agent/tests/vramEstimator.test.js +75 -0
  90. package/server/agent/tests/webChatDocs.test.js +199 -0
  91. package/server/agent/tests/webChatInferenceControl.test.js +298 -0
  92. package/server/agent/tests/webChatInspection.test.js +256 -5
  93. package/server/agent/tests/webChatMcp.test.js +94 -0
  94. package/server/agent/tests/webExtract.test.js +23 -0
  95. package/server/agent/tests/webSources.test.js +46 -12
  96. package/server/agent/textToolProtocol.js +173 -46
  97. package/server/agent/toolRouter.js +318 -44
  98. package/server/agent/tools.js +877 -96
  99. package/server/agent/ui.js +39 -34
  100. package/server/agent/vramService.js +35 -54
  101. package/server/agent/webSearch.js +441 -162
  102. package/server/agent/websearch/orchestrator.js +15 -5
  103. package/server/agent/websearch/resilience/cache.js +9 -2
  104. package/server/agent/websearch/security/ssrf.js +41 -10
  105. package/server/agent/youtubeAuth.js +5 -20
  106. package/server/apiRouter.js +5558 -4422
  107. package/server/mcp/data/discography.js +2 -2
  108. package/server/mcp/examples/antigravity.example.json +17 -0
  109. package/server/mcp/handler.js +8 -4
  110. package/server/mcp/security.js +220 -21
  111. package/server/mcp/server.js +2 -1
  112. package/server/mcp/tests/connection.test.js +14 -8
  113. package/server/mcp/tests/mcpKeyAuth.test.js +156 -0
  114. package/server/mcp/tools.js +7 -1
  115. package/server/supabase_migration_prefix.sql +102 -0
  116. package/server/supabase_schema.sql +18 -18
  117. package/server/web-chat/core/connection.js +179 -218
  118. package/server/web-chat/core/conversationContext.js +9 -2
  119. package/server/web-chat/core/directCatalogEngine.js +52 -28
  120. package/server/web-chat/core/dramaKnowledge.js +33 -22
  121. package/server/web-chat/core/followUpEngine.js +735 -81
  122. package/server/web-chat/core/futureEventGuard.js +5 -1
  123. package/server/web-chat/core/musicControlAction.js +70 -0
  124. package/server/web-chat/core/security.js +32 -21
  125. package/server/web-chat/core/toolInspector.js +6 -6
  126. package/server/web-chat/core/urlGuard.js +45 -0
  127. package/server/web-chat/router.js +204 -157
  128. package/server/web-chat/routes/chatRoute.js +885 -488
  129. package/server/web-chat/routes/engineRoute.js +270 -0
  130. package/server/web-chat/routes/exportRoute.js +1 -1
  131. package/server/web-chat/routes/lanDiscoveryRoute.js +17 -2
  132. package/server/web-chat/routes/lmStudioLoadRoute.js +172 -0
  133. package/server/web-chat/routes/mcpRoute.js +386 -0
  134. package/server/web-chat/routes/memoryRoute.js +253 -0
  135. package/server/web-chat/routes/providerRoute.js +49 -16
  136. package/server/web-chat/routes/staticRoute.js +306 -17
  137. package/server/web-chat/routes/statusRoute.js +194 -73
  138. package/server/web-chat/routes/suggestRoute.js +11 -3
  139. package/server/web-chat/routes/verifyRoute.js +26 -4
  140. package/server/web-chat/server.js +7 -6
  141. package/server/web-chat/ui/docsBuilder.js +3558 -0
  142. package/server/web-chat/ui/htmlBuilder.js +12 -0
  143. package/server/web-chat/ui/scripts/client.js +3401 -1302
  144. package/server/web-chat/ui/scripts/webchatI18n.js +155 -153
  145. package/server/web-chat/ui/settingsBuilder.js +7870 -0
  146. package/server/web-chat/ui/styles/lan-card.css +165 -0
  147. package/server/web-chat/ui/styles/main.css +4289 -447
  148. package/server/web-chat/ui/sw.js +64 -0
  149. package/server/web-chat/ui/templates/body.html +407 -281
  150. package/src/data/awardsData.js +146 -146
  151. package/src/data/musicVideoData.js +6 -4
  152. package/src/lib/vram/estimator.js +195 -19
@@ -0,0 +1,108 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { appendDomainFilters, planSearchRequest, rerankSearchResults } from '../searchQueryPlanner.js';
5
+ import { prepareToolArguments, toolsSchema } from '../tools.js';
6
+
7
+ test('search planner normalizes conversational Thai and romanized Chinese entities', () => {
8
+ const plan = planSearchRequest({
9
+ keywords: 'ช่วยอปเดตขาวกองถาย Wan hua sin jie ปี 2026 หน่อย',
10
+ target_language: 'zh',
11
+ domain_filter: ['https://www.weibo.com/search', 'bilibili.com'],
12
+ }, { kind: 'chinese' });
13
+
14
+ assert.match(plan.keywords, /万花世界/);
15
+ assert.match(plan.keywords, /电视剧/);
16
+ assert.match(plan.keywords, /拍摄进度/);
17
+ assert.match(plan.keywords, /2026/);
18
+ assert.deepEqual(plan.domains, ['weibo.com', 'bilibili.com']);
19
+ });
20
+
21
+ test('search planner resolves a romanized performer name to native script and qualifier', () => {
22
+ const plan = planSearchRequest('latest update for Song Xinran', { kind: 'news' });
23
+ assert.match(plan.keywords, /宋昕冉/);
24
+ assert.match(plan.keywords, /SNH48/);
25
+ });
26
+
27
+ test('post-retrieval reranker promotes intent and domain matches', () => {
28
+ const plan = planSearchRequest({ keywords: 'Song Xinran event', domain_filter: ['weibo.com'] });
29
+ const ranked = rerankSearchResults([
30
+ { title: 'Unrelated sports result', snippet: 'football table', url: 'https://example.com/a' },
31
+ { title: '宋昕冉 SNH48 event update', snippet: 'latest official schedule', url: 'https://weibo.com/a' },
32
+ ], plan);
33
+ assert.match(ranked[0].title, /宋昕冉/);
34
+ assert.ok(ranked.length === 1 || ranked[0].relevanceScore > ranked.at(-1).relevanceScore);
35
+ });
36
+
37
+ test('structured search fields pass through tool argument sanitation while legacy query remains accepted', () => {
38
+ const structured = prepareToolArguments('web_search', {
39
+ keywords: '万花世界 拍摄进度',
40
+ target_language: 'zh',
41
+ domain_filter: ['weibo.com'],
42
+ }, toolsSchema);
43
+ assert.equal(structured.safe, true);
44
+ assert.deepEqual(structured.args.domain_filter, ['weibo.com']);
45
+
46
+ const legacy = prepareToolArguments('web_search', { query: 'Ju Jingyi news' }, toolsSchema);
47
+ assert.equal(legacy.safe, true);
48
+ assert.equal(legacy.args.query, 'Ju Jingyi news');
49
+ assert.match(appendDomainFilters('鞠婧祎', ['weibo.com']), /site:weibo\.com/);
50
+ });
51
+
52
+ test('planner parity: bilibili, youtube, encyclopedia, and music tools accept canonical keywords', () => {
53
+ const bili = prepareToolArguments('search_bilibili', {
54
+ keywords: '宋昕冉 SNH48 舞台',
55
+ target_language: 'zh',
56
+ }, toolsSchema);
57
+ assert.equal(bili.safe, true);
58
+ assert.equal(bili.args.keywords, '宋昕冉 SNH48 舞台');
59
+
60
+ const biliLegacy = prepareToolArguments('search_bilibili', { keyword: '鞠婧祎 舞台' }, toolsSchema);
61
+ assert.equal(biliLegacy.safe, true);
62
+ assert.equal(biliLegacy.args.keyword, '鞠婧祎 舞台');
63
+
64
+ const yt = prepareToolArguments('search_youtube', { keywords: 'Ju Jingyi FIND THE WAY' }, toolsSchema);
65
+ assert.equal(yt.safe, true);
66
+ assert.equal(yt.args.keywords, 'Ju Jingyi FIND THE WAY');
67
+
68
+ const ytLegacy = prepareToolArguments('search_youtube', { query: 'Ju Jingyi live stage' }, toolsSchema);
69
+ assert.equal(ytLegacy.safe, true);
70
+
71
+ const baike = prepareToolArguments('search_encyclopedia', {
72
+ keywords: '宋昕冉 SNH48',
73
+ target_language: 'zh',
74
+ }, toolsSchema);
75
+ assert.equal(baike.safe, true);
76
+
77
+ const baikeLegacy = prepareToolArguments('search_encyclopedia', { term: '鞠婧祎', language: 'zh' }, toolsSchema);
78
+ assert.equal(baikeLegacy.safe, true);
79
+
80
+ const music = prepareToolArguments('search_music_platforms', { keywords: 'Ju Jingyi 叹云兮' }, toolsSchema);
81
+ assert.equal(music.safe, true);
82
+
83
+ const musicLegacy = prepareToolArguments('search_music_platforms', { term: 'Ju Jingyi' }, toolsSchema);
84
+ assert.equal(musicLegacy.safe, true);
85
+ });
86
+
87
+ test('planner parity: handlers reject empty input without touching the network', async () => {
88
+ const { toolHandlers } = await import('../tools.js');
89
+ const bili = await toolHandlers.search_bilibili({});
90
+ assert.equal(bili.status, 'error');
91
+ assert.match(bili.message, /keyword parameter is required/);
92
+
93
+ const enc = await toolHandlers.search_encyclopedia({});
94
+ assert.equal(enc.status, 'error');
95
+
96
+ const music = await toolHandlers.search_music_platforms({});
97
+ assert.equal(music.status, 'error');
98
+
99
+ const yt = await toolHandlers.search_youtube({});
100
+ assert.equal(yt.status, 'error');
101
+ });
102
+
103
+ test('planner resolves performer queries to entity-native terms for encyclopedia-style lookup', () => {
104
+ const plan = planSearchRequest({ keywords: 'ค้นหาข้อมูลของ Song Xinran SNH48 ให้หน่อย' });
105
+ const native = plan.entities[0]?.native;
106
+ assert.ok(native && /宋昕冉|SNH48/.test(`${native} ${plan.keywords}`));
107
+ assert.match(plan.keywords, /宋昕冉/);
108
+ });
@@ -46,14 +46,14 @@ test('Security Remediation: StaticRoute blocks path traversal and sensitive file
46
46
 
47
47
  test('Security Remediation: SSRF Alternate IP Encodings (decimal, hex, octal, classful) are detected and blocked', async () => {
48
48
  const encodings = [
49
- '2130706433', // 127.0.0.1 in decimal integer
50
- '0x7f000001', // 127.0.0.1 in hex integer
51
- '0x7f.0.0.1', // 127.0.0.1 in dotted hex
52
- '0177.0.0.1', // 127.0.0.1 in octal
53
- '127.1', // 127.0.0.1 in 2-part classful
54
- '10.1', // 10.0.0.1 in 2-part classful
55
- '192.168.1', // 192.168.0.1 in 3-part classful
56
- '0', // 0.0.0.0
49
+ '2130706433',
50
+ '0x7f000001',
51
+ '0x7f.0.0.1',
52
+ '0177.0.0.1',
53
+ '127.1',
54
+ '10.1',
55
+ '192.168.1',
56
+ '0',
57
57
  '0.0.0.0'
58
58
  ];
59
59
 
@@ -77,6 +77,13 @@ test('Security Remediation: SSRF Alternate IP Encodings (decimal, hex, octal, cl
77
77
  }
78
78
  });
79
79
 
80
+ test('Security Remediation: IPv4-mapped and compatible IPv6 private encodings are blocked', async () => {
81
+ for (const ip of ['::ffff:7f00:1', '0:0:0:0:0:ffff:7f00:1', '::7f00:1']) {
82
+ const result = await assertSafeUrlHostnameSsrf(ip);
83
+ assert.equal(result.safe, false, `assertSafeUrlHostnameSsrf should reject ${ip}`);
84
+ }
85
+ });
86
+
80
87
  test('Security Remediation: HierarchicalStore.resolvePointer strictly confines file resolution to allowed store directories', async () => {
81
88
  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'kiku-store-test-'));
82
89
  try {
@@ -196,7 +203,7 @@ test('Security Remediation: ?testRateLimit= is completely ignored when NODE_ENV
196
203
  const req = {
197
204
  method: 'GET',
198
205
  url: '/api/v1/health?testRateLimit=1',
199
- headers: { host: 'localhost:3000' },
206
+ headers: { host: 'localhost:3000', 'x-kiku-force-production': 'true' },
200
207
  socket: { remoteAddress: '127.0.0.1' }
201
208
  };
202
209
  const res = {
@@ -0,0 +1,154 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { Readable } from 'node:stream';
4
+ import { EventEmitter } from 'node:events';
5
+ import { resolveClientIp } from '../../web-chat/core/security.js';
6
+ import { isTrustedRequest } from '../../web-chat/core/urlGuard.js';
7
+ import { AgentJobManager } from '../jobManager.js';
8
+ import { handleChatRoute } from '../../web-chat/routes/chatRoute.js';
9
+
10
+ test('Security Remediation 2: resolveClientIp blocks spoofed loopback IPs from external proxy connections', () => {
11
+ const origVercel = process.env.VERCEL;
12
+ const origTrust = process.env.KIKU_TRUST_PROXY;
13
+ try {
14
+ process.env.VERCEL = '1';
15
+
16
+
17
+ const reqSpoof = {
18
+ socket: { remoteAddress: '203.0.113.50' },
19
+ headers: { 'x-forwarded-for': '127.0.0.1, 203.0.113.50' }
20
+ };
21
+ const ip = resolveClientIp(reqSpoof);
22
+ assert.equal(ip, '203.0.113.50');
23
+ assert.equal(isTrustedRequest(reqSpoof), false, 'Spoofed loopback header must not make request trusted');
24
+
25
+
26
+ const reqSpoofV6 = {
27
+ socket: { remoteAddress: '198.51.100.99' },
28
+ headers: { 'x-forwarded-for': '::1' }
29
+ };
30
+ const ipV6 = resolveClientIp(reqSpoofV6);
31
+ assert.equal(ipV6, '198.51.100.99');
32
+ assert.equal(isTrustedRequest(reqSpoofV6), false);
33
+
34
+
35
+ const reqDirectLocal = {
36
+ socket: { remoteAddress: '127.0.0.1' },
37
+ headers: {}
38
+ };
39
+ assert.equal(resolveClientIp(reqDirectLocal), '127.0.0.1');
40
+ assert.equal(isTrustedRequest(reqDirectLocal), true);
41
+ } finally {
42
+ if (origVercel !== undefined) process.env.VERCEL = origVercel;
43
+ else delete process.env.VERCEL;
44
+ if (origTrust !== undefined) process.env.KIKU_TRUST_PROXY = origTrust;
45
+ else delete process.env.KIKU_TRUST_PROXY;
46
+ }
47
+ });
48
+
49
+ test('Concurrency Remediation 2: AgentJobManager bounds concurrent heavy research/verify jobs', async () => {
50
+ const manager = new AgentJobManager(10, 2);
51
+
52
+ let finishJob1;
53
+ const p1 = new Promise(r => { finishJob1 = r; });
54
+ let finishJob2;
55
+ const p2 = new Promise(r => { finishJob2 = r; });
56
+
57
+ const run1 = manager.run('research', 'Task 1', () => p1);
58
+ const run2 = manager.run('verify', 'Task 2', () => p2);
59
+
60
+
61
+ await assert.rejects(
62
+ async () => {
63
+ await manager.run('research', 'Task 3', async () => 'result 3');
64
+ },
65
+ /Too many concurrent research tasks running/
66
+ );
67
+
68
+ finishJob1('done 1');
69
+ await run1;
70
+
71
+
72
+ const result3 = await manager.run('research', 'Task 3', async () => 'result 3');
73
+ assert.equal(result3, 'result 3');
74
+
75
+ finishJob2('done 2');
76
+ await run2;
77
+ });
78
+
79
+ test('Lifecycle Parity Remediation 2: chatRoute JSON mode persists conversation history and catches errors gracefully', async () => {
80
+ const mockAgent = {
81
+ conversationHistory: [],
82
+ session: {
83
+ createUserMessage(msg) { this.lastUser = msg; },
84
+ createAssistantMessage(msg) { this.lastAssistant = msg; }
85
+ },
86
+ runResearch: async (target) => {
87
+ if (target === 'fail-query') {
88
+ throw new Error('LLM provider timeout');
89
+ }
90
+ return {
91
+ answer: `Detailed findings on ${target}`,
92
+ sources: ['https://example.com/source'],
93
+ reports: [{ toolCalls: [{ name: 'search_works', args: { query: target }, result: { count: 1 } }] }]
94
+ };
95
+ }
96
+ };
97
+
98
+ const req = Readable.from([JSON.stringify({ query: '/research In Blossom roles', stream: false })]);
99
+ req.method = 'POST';
100
+ req.url = '/api/chat';
101
+ req.headers = { 'content-type': 'application/json' };
102
+ req.socket = { remoteAddress: '127.0.0.1' };
103
+
104
+ let responseData = null;
105
+ const mockRes = new EventEmitter();
106
+ mockRes.setHeader = () => {};
107
+ mockRes.writeHead = () => {};
108
+ mockRes.write = () => {};
109
+ mockRes.end = (data) => {
110
+ mockRes.writableEnded = true;
111
+ if (data) {
112
+ try { responseData = JSON.parse(data); } catch { responseData = data; }
113
+ }
114
+ };
115
+
116
+ const mockResolve = async () => ({
117
+ ready: true,
118
+ agent: mockAgent,
119
+ client: { provider: 'lmstudio' },
120
+ provider: 'lmstudio'
121
+ });
122
+
123
+ const url = new URL('http://localhost/api/chat');
124
+ await handleChatRoute(req, mockRes, url, { resolveConnection: mockResolve });
125
+
126
+ assert.equal(responseData.status, 'success');
127
+ assert.match(responseData.answer, /Detailed findings on In Blossom roles/);
128
+ assert.equal(mockAgent.conversationHistory.length, 2, 'History must contain user and assistant messages');
129
+ assert.equal(mockAgent.conversationHistory[0].role, 'user');
130
+ assert.equal(mockAgent.conversationHistory[1].role, 'assistant');
131
+
132
+
133
+ const reqFail = Readable.from([JSON.stringify({ query: '/verify fail-query', stream: false })]);
134
+ reqFail.method = 'POST';
135
+ reqFail.url = '/api/chat';
136
+ reqFail.headers = { 'content-type': 'application/json' };
137
+ reqFail.socket = { remoteAddress: '127.0.0.1' };
138
+
139
+ let failData = null;
140
+ const mockResFail = new EventEmitter();
141
+ mockResFail.setHeader = () => {};
142
+ mockResFail.writeHead = () => {};
143
+ mockResFail.write = () => {};
144
+ mockResFail.end = (data) => {
145
+ mockResFail.writableEnded = true;
146
+ if (data) {
147
+ try { failData = JSON.parse(data); } catch { failData = data; }
148
+ }
149
+ };
150
+
151
+ await handleChatRoute(reqFail, mockResFail, url, { resolveConnection: mockResolve });
152
+ assert.equal(failData.status, 'error');
153
+ assert.match(failData.answer, /LLM provider timeout/);
154
+ });
@@ -116,10 +116,10 @@ test('Security: Security response headers are applied (CSP, HSTS, X-Content-Type
116
116
  await withApiServer(async baseUrl => {
117
117
  const docsRes = await fetch(`${baseUrl}/api-docs`);
118
118
  assert.equal(docsRes.status, 200);
119
- const contentSecurityPolicy = docsRes.headers.get('content-security-policy');
120
- assert.ok(contentSecurityPolicy);
121
- assert.match(contentSecurityPolicy, /font-src[^;]*'self'/);
122
- assert.match(contentSecurityPolicy, /font-src[^;]*https:\/\/fonts\.gstatic\.com/);
119
+ const contentSecurityPolicy = docsRes.headers.get('content-security-policy');
120
+ assert.ok(contentSecurityPolicy);
121
+ assert.match(contentSecurityPolicy, /font-src[^;]*'self'/);
122
+ assert.match(contentSecurityPolicy, /font-src[^;]*https:\/\/fonts\.gstatic\.com/);
123
123
  assert.equal(docsRes.headers.get('x-content-type-options'), 'nosniff');
124
124
  assert.equal(docsRes.headers.get('x-frame-options'), 'DENY');
125
125
  assert.ok(docsRes.headers.get('permissions-policy'));
@@ -8,7 +8,7 @@ import { LLMClient } from '../llmClient.js';
8
8
  import { routeCommand } from '../commands.js';
9
9
 
10
10
  test('AgentSession records full conversation metadata, tools, reasoning, and exports', async () => {
11
- const session = new AgentSession();
11
+ const session = new AgentSession({ persistence: false });
12
12
 
13
13
  const userMsg = session.createUserMessage('Who is Ju Jingyi?', { queryNumber: 1 });
14
14
  assert.equal(userMsg.role, 'user');
@@ -122,7 +122,7 @@ test('queryDetails parses session and granular query copy requests', () => {
122
122
  assert.equal(reqQ1Tool.view, 'tool');
123
123
  assert.equal(reqQ1Tool.toolIndex, 2);
124
124
 
125
- const session = new AgentSession();
125
+ const session = new AgentSession({ persistence: false });
126
126
  session.createUserMessage('Test question');
127
127
  const asst = session.createAssistantMessage({ model: 'gemma-4' });
128
128
  session.addTextPart(asst, 'Test answer');
@@ -139,7 +139,7 @@ test('queryDetails parses session and granular query copy requests', () => {
139
139
  test('Commands /session export and /session clear execute cleanly', async () => {
140
140
  let ended = false;
141
141
  const mockAgent = {
142
- session: new AgentSession(),
142
+ session: new AgentSession({ persistence: false }),
143
143
  endSession: async () => {
144
144
  ended = true;
145
145
  return true;
@@ -180,7 +180,7 @@ test('Commands /copy routes answer, thinking, tools, all, and session variants',
180
180
  status: 'complete',
181
181
  };
182
182
 
183
- const session = new AgentSession();
183
+ const session = new AgentSession({ persistence: false });
184
184
  session.createUserMessage('Who is Ju Jingyi?');
185
185
  const asst = session.createAssistantMessage({ model: 'gemma-4' });
186
186
  session.addTextPart(asst, 'Ju Jingyi is a singer and actress.');
@@ -230,3 +230,38 @@ test('Commands /copy routes answer, thinking, tools, all, and session variants',
230
230
  assert.ok(copiedText.includes('# Kiku AI Agent — Session Transcript'));
231
231
  assert.ok(copiedText.includes('Who is Ju Jingyi?'));
232
232
  });
233
+
234
+ test('AgentSession synchronizes totalToolCalls and /session info from message tool parts', async () => {
235
+ const session = new AgentSession({ persistence: false });
236
+ session.restoreFromSnapshot({
237
+ id: 'sess_test_sync',
238
+ createdAt: new Date(),
239
+ messages: [
240
+ { role: 'user', parts: [{ type: 'text', content: 'hello' }] },
241
+ {
242
+ role: 'assistant',
243
+ parts: [
244
+ { type: 'tool', name: 'search_works', args: { query: 'In Blossom' }, status: 'ok', durationMs: 40 },
245
+ { type: 'text', content: 'In Blossom info' }
246
+ ]
247
+ }
248
+ ],
249
+ stats: {
250
+ totalQueries: 1,
251
+ totalToolCalls: 0,
252
+ totalDurationMs: 0
253
+ }
254
+ });
255
+
256
+ assert.equal(session.stats.totalToolCalls, 1);
257
+ assert.equal(session.getTotalToolCalls(), 1);
258
+ assert.equal(session.stats.totalDurationMs, 40);
259
+
260
+ const toolStats = session.getToolStats();
261
+ assert.equal(toolStats.search_works.count, 1);
262
+
263
+ const mockAgent = { session };
264
+ const res = await routeCommand('/session info', { agent: mockAgent, sub: 'info', extra: '' });
265
+ assert.equal(res, 'HANDLED');
266
+ });
267
+
@@ -84,6 +84,40 @@ test('jukebox derives sidebar and tab counts from the playlist', () => {
84
84
  assert.doesNotMatch(html, /ทั้งหมด \(44\)/);
85
85
  });
86
86
 
87
+ test('quick-action-menu jukebox entry opens through a single-call wrapper', () => {
88
+ const html = getWebChatHtml();
89
+
90
+ assert.match(html, /function openMusicFromQuickActions\(\)/);
91
+ const wrapperStart = html.indexOf('function openMusicFromQuickActions()');
92
+ const wrapperBody = html.slice(wrapperStart, wrapperStart + 300);
93
+ assert.match(wrapperBody, /closeQuickActions\(\)/);
94
+ assert.match(wrapperBody, /openMusicModal\(\)/);
95
+ assert.ok(wrapperBody.indexOf('closeQuickActions()') < wrapperBody.indexOf('openMusicModal()'));
96
+
97
+ assert.match(html, /onclick="openMusicFromQuickActions\(\)"/);
98
+ assert.doesNotMatch(html, /onclick="openMusicModal\(\); closeQuickActions\(\)"/);
99
+ });
100
+
101
+ test('every static onclick handler in the chat shell is exposed on window', () => {
102
+ const html = getWebChatHtml();
103
+
104
+ for (const name of [
105
+ 'openMusicModal',
106
+ 'openMusicFromQuickActions',
107
+ 'closeMusicModal',
108
+ 'toggleAppSidebar',
109
+ 'toggleQuickActions',
110
+ 'closeQuickActions',
111
+ 'openMiniPlayerFromCard',
112
+ 'switchVoiceToDetectedLang',
113
+ 'handleQuickChipClick',
114
+ 'cycleSidebarAd'
115
+ ]) {
116
+ assert.match(html, new RegExp('function ' + name + '\\('), name + ' is defined');
117
+ assert.match(html, new RegExp('\\b' + name + ','), name + ' is exposed on window');
118
+ }
119
+ });
120
+
87
121
  test('sidebar export downloads through the export API and reports failures', () => {
88
122
  const html = getWebChatHtml();
89
123
  const start = html.indexOf('async function exportChat()');
@@ -5,11 +5,18 @@ import path from 'node:path';
5
5
  import { mkdtemp, rm } from 'node:fs/promises';
6
6
 
7
7
  import { HierarchicalStore, parseAndValidatePath, computeContentHash } from '../memory/hierarchicalStore.js';
8
- import { ContextAllocator } from '../memory/contextAllocator.js';
8
+ import { ContextAllocator, extractWordTokens } from '../memory/contextAllocator.js';
9
9
  import { ReflectionWorker } from '../memory/reflectionWorker.js';
10
10
  import { AgentMemory } from '../memory.js';
11
11
  import { executeTool } from '../tools.js';
12
12
 
13
+ test('extractWordTokens segments Thai and CJK text without whitespace', () => {
14
+ const thaiTokens = extractWordTokens('จวีจิ้งอีผลงานเพลง');
15
+ const cjkTokens = extractWordTokens('鞠婧祎音乐作品');
16
+ assert.ok(thaiTokens.length >= 2, `expected Thai segmentation, got ${thaiTokens.join('|')}`);
17
+ assert.ok(cjkTokens.length >= 2, `expected CJK segmentation, got ${cjkTokens.join('|')}`);
18
+ });
19
+
13
20
  test('parseAndValidatePath correctly parses valid hierarchical memory paths', () => {
14
21
  const p1 = parseAndValidatePath('kiku/runs/run_task_001/state');
15
22
  assert.equal(p1.section, 'runs');
@@ -124,3 +124,74 @@ test('systemDirectives callbacks are injected as system messages without touchin
124
124
  await rm(tempDir, { recursive: true, force: true });
125
125
  }
126
126
  });
127
+
128
+ test('catalog tool returning not_found automatically escalates to external internet search and calls onToolRemove', async () => {
129
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'kiku-escalate-'));
130
+ const storageFile = path.join(tempDir, 'session.json');
131
+ const observedMessages = [];
132
+ const removedTools = [];
133
+ const startedTools = [];
134
+ let call = 0;
135
+
136
+ const client = {
137
+ provider: 'lmstudio',
138
+ providerName: 'LM Studio',
139
+ model: 'test-model',
140
+ async chat(options) {
141
+ call++;
142
+ observedMessages.push(options.messages.map(message => ({
143
+ role: message.role,
144
+ content: typeof message.content === 'string' ? message.content : null,
145
+ tool_calls: message.tool_calls || undefined,
146
+ tool_call_id: message.tool_call_id || undefined,
147
+ name: message.name || undefined
148
+ })));
149
+ if (call === 1) {
150
+ return {
151
+ role: 'assistant',
152
+ content: 'ขอตรวจสอบภาพในแค็ตตาล็อกก่อนค่ะ\n```json\n{"action": "get_gallery_photos", "parameters": {"query": "万花世界"}}\n```',
153
+ };
154
+ }
155
+ return { role: 'assistant', content: 'พบภาพจากแหล่งข่าวภายนอกแล้วค่ะ' };
156
+ },
157
+ };
158
+
159
+ const agent = new KikuAgent({
160
+ client,
161
+ memory: createMemoryStub(),
162
+ mcpManager: new McpClientManager({
163
+ autoLoad: false,
164
+ globalConfigPath: path.join(tempDir, 'mcp.json'),
165
+ }),
166
+ sessionId: 'escalate_test',
167
+ sessionStorageFile: storageFile,
168
+ });
169
+
170
+ try {
171
+ await agent.ready();
172
+ const answer = await agent.run('อยากเห็นภาพซีรีส์ 万花世界 (Wanhua Sin)', {
173
+ onToolStart: (name) => startedTools.push(name),
174
+ onToolRemove: (name) => removedTools.push(name),
175
+ });
176
+
177
+ assert.match(answer, /พบภาพจากแหล่งข่าวภายนอกแล้วค่ะ/);
178
+ assert.ok(startedTools.includes('get_gallery_photos'), 'get_gallery_photos must have started');
179
+ assert.ok(removedTools.includes('get_gallery_photos'), 'get_gallery_photos must have been removed from UI');
180
+ assert.ok(
181
+ startedTools.includes('search_web_images') || startedTools.includes('web_search'),
182
+ 'external internet search tool must have been triggered'
183
+ );
184
+
185
+ const secondCall = observedMessages[1];
186
+ const toolMsg = secondCall.find(m => m.role === 'tool');
187
+ assert.ok(toolMsg, 'tool result message must be sent to model');
188
+ assert.ok(
189
+ toolMsg.name === 'search_web_images' || toolMsg.name === 'web_search',
190
+ 'tool message name must be the external search tool'
191
+ );
192
+ } finally {
193
+ await agent.mcpManager.disconnectAll().catch(() => { });
194
+ await rm(tempDir, { recursive: true, force: true });
195
+ }
196
+ });
197
+
@@ -137,3 +137,51 @@ test('static catalog tools are TTL-cached while the social feed stays fresh', as
137
137
  const feedSecond = await executeTool('get_social_feed', { limit: 1 });
138
138
  assert.notEqual(feedSecond, feedFirst);
139
139
  });
140
+
141
+ test('text tool call parsing handles [TOOLCALL] syntax with <|"|> tokenizer delimiters and websearch alias', () => {
142
+ const agent = bareAgent();
143
+ const rawSnippet = [
144
+ '💖 โอ๊ย! เข้าใจเลยค่ะว่าอยากเห็นบรรยากาศของซีรีส์ก่อนดูจริง ๆ ใช่ไหมคะ? ✨',
145
+ 'แต่ Concept Art ที่ระบุเจาะจงในฐานข้อมูลแกลเลอรีของเรายังไม่มีการบันทึกเข้ามาอย่างเป็นทางการค่ะ 🥺',
146
+ 'ดิฉันจึงได้ทำการค้นหาภาพบรรยากาศล่าสุดจากแหล่งข่าวภายนอกมาให้คุณดู',
147
+ '',
148
+ '[TOOLCALL]',
149
+ 'websearch{keywords:<|"|>万花世界 概念图 万花世界 剧照<|"|>,targetlanguage:<|"|>zh<|"|>}<toolcall|>',
150
+ '',
151
+ 'พบปัยหาเเล้ว'
152
+ ].join('\n');
153
+
154
+ const { calls, malformed } = agent._extractTextToolCalls(rawSnippet);
155
+ assert.equal(malformed, 0);
156
+ assert.equal(calls.length, 1);
157
+ assert.equal(calls[0].function.name, 'web_search');
158
+ const args = JSON.parse(calls[0].function.arguments);
159
+ assert.equal(args.keywords, '万花世界 概念图 万花世界 剧照');
160
+ assert.equal(args.target_language, 'zh');
161
+ });
162
+
163
+ test('stripToolCallBlocks and sanitizeResponse thoroughly strip [TOOLCALL] and synthetic tokens', async () => {
164
+ const { stripToolCallBlocks } = await import('../textToolProtocol.js');
165
+ const { sanitizeResponse } = await import('../sanitizer.js');
166
+
167
+ const rawSnippet = [
168
+ '💖 โอ๊ย! เข้าใจเลยค่ะ',
169
+ '[TOOLCALL]',
170
+ 'websearch{keywords:<|"|>万花世界 概念图 万花世界 剧照<|"|>,targetlanguage:<|"|>zh<|"|>}<toolcall|>',
171
+ 'ข้อความต่อมา'
172
+ ].join('\n');
173
+
174
+ const stripped = stripToolCallBlocks(rawSnippet);
175
+ assert.ok(!stripped.includes('[TOOLCALL]'));
176
+ assert.ok(!stripped.includes('<toolcall'));
177
+ assert.ok(!stripped.includes('<|"|>'));
178
+ assert.ok(!stripped.includes('websearch'));
179
+ assert.ok(stripped.includes('💖 โอ๊ย! เข้าใจเลยค่ะ'));
180
+ assert.ok(stripped.includes('ข้อความต่อมา'));
181
+
182
+ const sanitized = sanitizeResponse(rawSnippet);
183
+ assert.ok(!sanitized.includes('[TOOLCALL]'));
184
+ assert.ok(!sanitized.includes('<toolcall'));
185
+ assert.ok(!sanitized.includes('<|"|>'));
186
+ });
187
+
@@ -44,6 +44,47 @@ test('profile queries no longer leak SYSTEM_SKILL tools via "file" inside "profi
44
44
  assert.ok(!intents.includes('SYSTEM_SKILL'), '"file" inside "profile" must not route SYSTEM_SKILL');
45
45
  });
46
46
 
47
+ test('off-topic queries enforce internet tools and suppress all catalog tools', () => {
48
+ const router = new DynamicToolRouter();
49
+ const catalogTools = new Set([
50
+ 'search_works', 'get_work_detail', 'search_discography', 'get_profile_and_fandom',
51
+ 'get_awards', 'get_brand_endorsements', 'get_career_timeline', 'get_character_roles',
52
+ 'get_gallery_photos', 'get_quotes', 'get_social_feed', 'global_search', 'get_media_links',
53
+ ]);
54
+ for (const q of [
55
+ 'วิธีทำต้มยำกุ้งน้ำข้น',
56
+ 'แนะนำเพลงสากลเพราะๆ หน่อย',
57
+ 'หนัง Dune ภาคใหม่สนุกไหม',
58
+ 'ประวัติศาสตร์อยุธยาตอนต้น',
59
+ 'เขียนฟังก์ชันเรียงข้อมูลให้หน่อย',
60
+ ]) {
61
+ const names = router.routeToolsForQuery(q).map(t => t.function?.name);
62
+ assert.ok(names.includes('web_search'), `${q} must include web_search`);
63
+ for (const c of catalogTools) {
64
+ assert.ok(!names.includes(c), `${q} must not route catalog tool ${c}`);
65
+ }
66
+ }
67
+ });
68
+
69
+ test('off-topic enforcement keeps in-domain music routing intact', () => {
70
+ const router = new DynamicToolRouter();
71
+ const names = router.routeToolsForQuery('เพลง 0.2s ใครร้อง').map(t => t.function?.name);
72
+ assert.ok(names.includes('search_discography'), 'in-domain song query keeps search_discography');
73
+ });
74
+
75
+ test('explicit @mention pins survive off-topic enforcement', () => {
76
+ const router = new DynamicToolRouter();
77
+ const names = router.routeToolsForQuery('วิธีทำต้มยำกุ้ง @search_works').map(t => t.function?.name);
78
+ assert.ok(names.includes('search_works'), 'explicit user pin must survive enforcement');
79
+ });
80
+
81
+ test('ฟังก์ชัน no longer collides with the ฟัง music intent', () => {
82
+ const router = new DynamicToolRouter();
83
+ const types = router.detectIntents('เขียนฟังก์ชันเรียงข้อมูลให้หน่อย').map(p => p.type);
84
+ assert.ok(!types.includes('MEDIA'), 'ฟังก์ชัน must not route MEDIA');
85
+ assert.ok(router.detectIntents('ขอฟังเพลงก่อนนอนหน่อย').map(p => p.type).includes('MEDIA'), 'ฟังเพลง must still route MEDIA');
86
+ });
87
+
47
88
  test('promotion intent no longer fires on generic words like "look" or "brand"', () => {
48
89
  assert.equal(detectPromotionIntent('What does she look like?'), false);
49
90
  assert.equal(detectPromotionIntent('a brand new song'), false);