kiku-agent 1.3.2 → 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 (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,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
+
@@ -28,6 +28,14 @@ test('model manager selects a chat fallback when the requested model is missing'
28
28
  assert.equal(client.model, 'google/gemma-4-e4b');
29
29
  });
30
30
 
31
+ test('model manager fails closed for an explicitly requested unavailable model', async () => {
32
+ const client = clientWith(['google/gemma-4-e4b']);
33
+ const result = await new ModelManager(client, { requestedModel: 'Qwen/Qwen3-8B', strictModel: true }).resolve();
34
+ assert.equal(result.state, 'requested_model_missing');
35
+ assert.equal(result.selectedModel, null);
36
+ assert.equal(client.model, 'requested/model');
37
+ });
38
+
31
39
  test('model manager prefers the provider recommendation over an arbitrary catalog model', async () => {
32
40
  const client = clientWith(['random/chat-model', 'gpt-5.6-luna']);
33
41
  client.provider = 'openai';
@@ -107,3 +115,27 @@ test('model manager allows selecting shorthand quantizations like Q8_0, q8, Q6_K
107
115
  assert.equal(selectedQ4, 'bartowski/gemma-4-e4b-GGUF/gemma-4-e4b-Q4_K_M.gguf');
108
116
  });
109
117
 
118
+ test('model manager reports server_online_model_not_loaded when LM Studio has models on disk but none in VRAM', async () => {
119
+ const client = {
120
+ provider: 'lmstudio',
121
+ model: 'google/gemma-4-e4b',
122
+ async checkConnection() {
123
+ return {
124
+ connected: true,
125
+ models: ['google/gemma-4-e4b'],
126
+ activeLoadedModel: null,
127
+ loadedModels: []
128
+ };
129
+ }
130
+ };
131
+
132
+ const manager = new ModelManager(client);
133
+ const result = await manager.resolve();
134
+ assert.equal(result.connected, true);
135
+ assert.equal(result.state, 'server_online_model_not_loaded');
136
+ assert.equal(result.isModelLoaded, false);
137
+ assert.equal(result.activeLoadedModel, null);
138
+ assert.equal(result.selectedModel, 'google/gemma-4-e4b');
139
+ });
140
+
141
+
@@ -0,0 +1,113 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { EventEmitter } from 'node:events';
4
+ import { Readable } from 'node:stream';
5
+ import { collectMusicControlActions, createMusicControlAction } from '../../web-chat/core/musicControlAction.js';
6
+ import { handleChatRoute } from '../../web-chat/routes/chatRoute.js';
7
+ import { getWebChatHtml } from '../../webChatHtml.js';
8
+
9
+ function request(body) {
10
+ const req = Readable.from([JSON.stringify(body)]);
11
+ req.socket = { remoteAddress: '127.0.0.1' };
12
+ req.headers = {};
13
+ return req;
14
+ }
15
+
16
+ function response() {
17
+ const res = new EventEmitter();
18
+ res.chunks = '';
19
+ res.writeHead = (code) => { res.statusCode = code; res.headersSent = true; };
20
+ res.write = (data) => { res.chunks += data; };
21
+ res.end = (data = '') => { res.chunks += data; res.writableEnded = true; res.emit('finish'); };
22
+ return res;
23
+ }
24
+
25
+ function musicAgent(toolName, result) {
26
+ return {
27
+ session: { id: 'music-test', messages: [] },
28
+ memory: { getStatus: () => ({}) },
29
+ run: async (_query, options) => {
30
+ options.onToolStart(toolName, {});
31
+ options.onToolResult(toolName, result, 3);
32
+ options.onTokenDelta?.('done');
33
+ return { answer: 'done' };
34
+ },
35
+ };
36
+ }
37
+
38
+ test('music tool results normalize to a bounded dedicated UI action', () => {
39
+ const event = createMusicControlAction('play_music', {
40
+ status: 'success',
41
+ action: 'pause',
42
+ playing: 'Heartbeat Alert',
43
+ song: { title: '恋爱告急', youtubeId: '5MLPdCmnlmE', extra: 'not-forwarded' },
44
+ });
45
+
46
+ assert.deepEqual(event, {
47
+ type: 'ui_action',
48
+ target: 'music_player',
49
+ tool: 'play_music',
50
+ action: 'play',
51
+ playing: 'Heartbeat Alert',
52
+ song: {
53
+ id: undefined,
54
+ title: '恋爱告急',
55
+ titleTh: undefined,
56
+ titleCn: undefined,
57
+ titleEn: undefined,
58
+ youtubeId: '5MLPdCmnlmE',
59
+ duration: undefined,
60
+ category: undefined,
61
+ dramaName: undefined,
62
+ },
63
+ });
64
+ assert.equal(createMusicControlAction('search_discography', { status: 'success' }), null);
65
+ assert.deepEqual(createMusicControlAction('pause_music', { status: 'error' }), {
66
+ type: 'ui_action',
67
+ target: 'music_player',
68
+ tool: 'pause_music',
69
+ action: 'pause',
70
+ });
71
+ assert.equal(collectMusicControlActions([
72
+ { name: 'set_volume', result: { status: 'success', volume: 140 } },
73
+ { name: 'seek_music', result: { status: 'success', seconds: -5 } },
74
+ ]).length, 2);
75
+ });
76
+
77
+ test('streaming chat emits music UI actions independently from tool inspector events', async () => {
78
+ const res = response();
79
+ await handleChatRoute(request({ query: 'pause music', provider: 'lmstudio', stream: true }), res, null, {
80
+ resolveConnection: async () => ({
81
+ ready: true,
82
+ agent: musicAgent('pause_music', { status: 'success', action: 'pause', message: 'paused' }),
83
+ }),
84
+ });
85
+
86
+ const events = res.chunks
87
+ .split('\n')
88
+ .filter((line) => line.startsWith('data: {'))
89
+ .map((line) => JSON.parse(line.slice(6)));
90
+ assert.ok(events.some((event) => event.type === 'tool_result' && event.name === 'pause_music'));
91
+ assert.ok(events.some((event) => event.type === 'ui_action' && event.target === 'music_player' && event.action === 'pause'));
92
+ });
93
+
94
+ test('non-streaming chat returns music UI actions and both client stream paths consume them', async () => {
95
+ const res = response();
96
+ await handleChatRoute(request({ query: 'volume 60', provider: 'lmstudio', stream: false }), res, null, {
97
+ resolveConnection: async () => ({
98
+ ready: true,
99
+ agent: musicAgent('set_volume', { status: 'success', action: 'set_volume', volume: 60 }),
100
+ }),
101
+ });
102
+
103
+ const body = JSON.parse(res.chunks);
104
+ assert.equal(body.uiActions[0].action, 'set_volume');
105
+ assert.equal(body.uiActions[0].volume, 60);
106
+
107
+ const html = getWebChatHtml();
108
+ assert.equal((html.match(/event\.type === 'ui_action' && event\.target === 'music_player'/g) || []).length, 2);
109
+ assert.equal((html.match(/const uiActions = Array\.isArray\(data\.uiActions\)/g) || []).length, 2);
110
+ assert.match(html, /miniPlayerState\.isPlaying = false;\s*sendMiniPlayerCommand\('pauseVideo'\)/);
111
+ assert.match(html, /miniPlayerState\.volume = vol;\s*miniPlayerState\.isMuted = vol === 0;/);
112
+ assert.match(html, /sendMiniPlayerCommand\(vol === 0 \? 'mute' : 'unMute'\)/);
113
+ });
@@ -40,8 +40,10 @@ test('ContextCompactor calculates tokens and summarizes history without losing s
40
40
  const compacted = compactor.compact(messages, { maxTokens: 20, keepRecentMessages: 2 });
41
41
  assert.equal(compacted[0].role, 'system');
42
42
  assert.equal(compacted[0].content, 'You are Kiku AI.');
43
- assert.equal(compacted[compacted.length - 1].content, 'What is her latest drama in 2026?');
44
- assert.ok(compacted.some(m => m.content.includes('[CONVERSATION CONTEXT SUMMARY')));
43
+ assert.match(compacted[compacted.length - 1].content, /Previous Session Context Summary/);
44
+ assert.match(compacted[compacted.length - 1].content, /What is her latest drama in 2026\?/);
45
+ assert.equal(compacted.filter(message => message.role === 'system').length, 1);
46
+ assert.ok(compacted.some(m => typeof m.content === 'string' && m.content.includes('[Previous Session Context Summary')));
45
47
  });
46
48
 
47
49
  test('AgentMemory scores fact relevance and applies recency weighting', () => {