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,101 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { getCpuMetrics, resolveOptimalThreadBudget, clearCpuMetricsCache } from '../cpuMetrics.js';
4
+ import { resolveParallelSlotBudget, buildLmsLoadArgs } from '../lmStudioWakeup.js';
5
+ import { normalizeModelLoadSettings } from '../config.js';
6
+
7
+ test('getCpuMetrics detects CPU topology and returns structured hardware metrics', async () => {
8
+ clearCpuMetricsCache();
9
+ const metrics = await getCpuMetrics();
10
+ assert.ok(metrics, 'Metrics object must be defined');
11
+ assert.ok(typeof metrics.model === 'string' && metrics.model.length > 0, 'CPU model must be non-empty string');
12
+ assert.ok(Number.isFinite(metrics.physicalCores) && metrics.physicalCores >= 1, 'Physical cores must be >= 1');
13
+ assert.ok(Number.isFinite(metrics.logicalCores) && metrics.logicalCores >= metrics.physicalCores, 'Logical cores must be >= physical cores');
14
+ assert.equal(typeof metrics.hyperThreading, 'boolean');
15
+ assert.ok(Number.isFinite(metrics.smtMultiplier) && metrics.smtMultiplier >= 1);
16
+ assert.ok(typeof metrics.statusText === 'string');
17
+ assert.ok(metrics.recommendedComputeThreads >= 1);
18
+ });
19
+
20
+ test('resolveOptimalThreadBudget calculates thread counts for physical, smt, auto, and custom modes', () => {
21
+ const mockCpu = {
22
+ physicalCores: 6,
23
+ logicalCores: 12,
24
+ hyperThreading: true,
25
+ };
26
+
27
+ const phys = resolveOptimalThreadBudget({ mode: 'physical', cpuInfo: mockCpu });
28
+ assert.equal(phys.threads, 6);
29
+ assert.equal(phys.mode, 'physical');
30
+ assert.match(phys.explanation, /physical cores/i);
31
+
32
+ const smt = resolveOptimalThreadBudget({ mode: 'smt', cpuInfo: mockCpu });
33
+ assert.equal(smt.threads, 12);
34
+ assert.equal(smt.mode, 'smt');
35
+ assert.match(smt.explanation, /logical threads/i);
36
+
37
+ const autoDefault = resolveOptimalThreadBudget({ mode: 'auto', cpuInfo: mockCpu });
38
+ assert.equal(autoDefault.threads, 6);
39
+ assert.equal(autoDefault.mode, 'auto');
40
+
41
+ const autoWithReq = resolveOptimalThreadBudget({ mode: 'auto', requested: 8, cpuInfo: mockCpu });
42
+ assert.equal(autoWithReq.threads, 8);
43
+ assert.equal(autoWithReq.mode, 'custom');
44
+
45
+ const custom = resolveOptimalThreadBudget({ mode: 'custom', requested: 4, cpuInfo: mockCpu });
46
+ assert.equal(custom.threads, 4);
47
+ assert.equal(custom.mode, 'custom');
48
+ });
49
+
50
+ test('resolveParallelSlotBudget enforces physical core ceiling when htMode is physical', () => {
51
+ const budgetPhysical = resolveParallelSlotBudget(12, 12, { htMode: 'physical', physicalCores: 6 });
52
+ assert.equal(budgetPhysical.value, 6);
53
+ assert.equal(budgetPhysical.ceiling, 6);
54
+ assert.equal(budgetPhysical.capped, true);
55
+
56
+ const budgetSmt = resolveParallelSlotBudget(12, 12, { htMode: 'smt', physicalCores: 6 });
57
+ assert.equal(budgetSmt.value, 12);
58
+ assert.equal(budgetSmt.ceiling, 12);
59
+ assert.equal(budgetSmt.capped, false);
60
+
61
+ const budgetAuto = resolveParallelSlotBudget(8, 12, { htMode: 'auto', physicalCores: 6 });
62
+ assert.equal(budgetAuto.value, 8);
63
+ assert.equal(budgetAuto.ceiling, 12);
64
+ });
65
+
66
+ test('buildLmsLoadArgs applies HT-aware parallel allocation flags', () => {
67
+ const argsPhys = buildLmsLoadArgs('test-model', {
68
+ cpuThreads: 12,
69
+ availableCpus: 12,
70
+ physicalCores: 6,
71
+ htMode: 'physical'
72
+ });
73
+ assert.ok(argsPhys.includes('--parallel'));
74
+ assert.equal(argsPhys[argsPhys.indexOf('--parallel') + 1], '6');
75
+
76
+ const argsSmt = buildLmsLoadArgs('test-model', {
77
+ cpuThreads: 12,
78
+ availableCpus: 12,
79
+ physicalCores: 6,
80
+ htMode: 'smt'
81
+ });
82
+ assert.ok(argsSmt.includes('--parallel'));
83
+ assert.equal(argsSmt[argsSmt.indexOf('--parallel') + 1], '12');
84
+ });
85
+
86
+ test('normalizeModelLoadSettings persists and validates htMode', () => {
87
+ const def = normalizeModelLoadSettings({});
88
+ assert.equal(def.htMode, 'auto');
89
+
90
+ const phys = normalizeModelLoadSettings({ htMode: 'physical' });
91
+ assert.equal(phys.htMode, 'physical');
92
+
93
+ const smt = normalizeModelLoadSettings({ htMode: 'smt' });
94
+ assert.equal(smt.htMode, 'smt');
95
+
96
+ const allAlias = normalizeModelLoadSettings({ htMode: 'all' });
97
+ assert.equal(allAlias.htMode, 'smt');
98
+
99
+ const invalid = normalizeModelLoadSettings({ htMode: 'invalid_mode' });
100
+ assert.equal(invalid.htMode, 'auto');
101
+ });
@@ -71,6 +71,38 @@ test('compactToolResultForContext projects essential keys and eliminates decorat
71
71
  assert.equal(b.bilibiliSearchUrl, undefined);
72
72
  });
73
73
 
74
+ test('compactToolResultForContext preserves image evidence links and relevance scores', () => {
75
+ const longImageUrl = `https://example.com/images/${'a'.repeat(300)}.jpg`;
76
+ const imageResult = {
77
+ status: 'success',
78
+ query: '鞠婧祎 万花世界 路透',
79
+ normalizedQuery: '鞠婧祎 万花世界 电视剧 路透',
80
+ count: 1,
81
+ results: [
82
+ {
83
+ title: '鞠婧祎 万花世界 路透照',
84
+ snippet: 'on-set stills from 万花世界 filming',
85
+ url: 'https://example.com/article',
86
+ source: 'Baidu Images',
87
+ imageUrl: longImageUrl,
88
+ thumbUrl: longImageUrl,
89
+ sourcePageUrl: longImageUrl,
90
+ width: 1080,
91
+ height: 1440,
92
+ relevanceScore: 42.5,
93
+ }
94
+ ]
95
+ };
96
+
97
+ const compacted = compactToolResultForContext(imageResult, { maxTokens: 60 });
98
+ assert.equal(compacted.results.length, 1);
99
+ const item = compacted.results[0];
100
+ assert.equal(item.imageUrl, longImageUrl);
101
+ assert.equal(item.thumbUrl, longImageUrl);
102
+ assert.equal(item.sourcePageUrl, longImageUrl);
103
+ assert.equal(item.relevanceScore, 42.5);
104
+ });
105
+
74
106
  test('scoreCoverage normalizes Buddhist Era years and minute-second durations', () => {
75
107
  const caseBe = {
76
108
  groups: [
@@ -4,6 +4,9 @@ import { sanitizeQuery } from '../sanitizer.js';
4
4
  import { DynamicToolRouter } from '../toolRouter.js';
5
5
  import { executeTool } from '../tools.js';
6
6
  import { ContextCompactor } from '../contextCompactor.js';
7
+ import { KikuAgent } from '../agentCore.js';
8
+ import { handleVerifyRoute } from '../../web-chat/routes/verifyRoute.js';
9
+ import { applyFutureEventGuard } from '../../web-chat/core/futureEventGuard.js';
7
10
 
8
11
  test('Edge Cases Suite: Sanitizer handles empty, whitespace, and extreme length strings gracefully', () => {
9
12
  const emptyRes = sanitizeQuery('');
@@ -47,3 +50,82 @@ test('Edge Cases Suite: ContextCompactor handles empty, malformed, or oversized
47
50
  const singleMsg = [{ role: 'user', content: 'Hello' }];
48
51
  assert.deepEqual(compactor.compact(singleMsg), singleMsg);
49
52
  });
53
+
54
+ test('Edge Cases Suite: KikuAgent immunizes vision limitation statements from refusal interceptor', async () => {
55
+ let callCount = 0;
56
+ const fakeClient = {
57
+ provider: 'lmstudio',
58
+ model: 'google/gemma-4-e4b',
59
+ chat: async () => {
60
+ callCount++;
61
+ return { role: 'assistant', content: 'ฉันเป็นระบบข้อความ ไม่สามารถดูรูปภาพได้โดยตรง จึงไม่มีข้อมูลของภาพนี้' };
62
+ }
63
+ };
64
+ const agent = new KikuAgent({ client: fakeClient, maxIterations: 2 });
65
+ const result = await agent.run('ช่วยดูรูปนี้ให้หน่อย จวีจิ้งอี\n\n[USER ATTACHMENT: ผู้ใช้ได้แนบรูปภาพจำนวน 1 รูป: "my_photo.jpg" (image/jpeg)]');
66
+ assert.equal(callCount, 1);
67
+ assert.ok(result.includes('ไม่สามารถดูรูปภาพ'));
68
+ });
69
+
70
+ test('Edge Cases Suite: KikuAgent provides graceful fallback for image attachments instead of raw failure', async () => {
71
+ const fakeClient = {
72
+ provider: 'lmstudio',
73
+ model: 'google/gemma-4-e4b',
74
+ chat: async () => ({ role: 'assistant', content: '' })
75
+ };
76
+ const agent = new KikuAgent({ client: fakeClient, maxIterations: 1 });
77
+ const result = await agent.run('ดูภาพนี้\n\n[USER ATTACHMENT: ผู้ใช้ได้แนบรูปภาพจำนวน 1 รูป: "unknown.png" (image/png)]');
78
+ assert.ok(!result.includes('ขออภัยค่ะ ไม่สามารถประมวลผลคำตอบได้'));
79
+ assert.ok(result.includes('ได้รับรูปภาพเรียบร้อยแล้วค่ะ'));
80
+ });
81
+
82
+ test('Edge Cases Suite: KikuAgent does not intercept substantive negative answers as refusals', async () => {
83
+ let callCount = 0;
84
+ const fakeClient = {
85
+ provider: 'lmstudio',
86
+ model: 'google/gemma-4-e4b',
87
+ chat: async () => {
88
+ callCount++;
89
+ return {
90
+ role: 'assistant',
91
+ content: 'จากการตรวจสอบข้อมูลและประวัติการทำงานของจวีจิ้งอี ไม่พบว่ามีประวัติการกระทำความผิดกฎหมายหรือเรื่องอื้อฉาวใดๆ ในวงการบันเทิง เธอเป็นศิลปินและนักแสดงที่มีผลงานซีรีส์อย่าง In Blossom และ The Legend of White Snake อย่างต่อเนื่อง'
92
+ };
93
+ }
94
+ };
95
+ const agent = new KikuAgent({ client: fakeClient, maxIterations: 2 });
96
+ const result = await agent.run('จวีจิ้งอีเคยมีประวัติด้านลบหรือทำผิดกฎหมายอะไรไหม');
97
+ assert.equal(callCount, 1);
98
+ assert.ok(result.includes('ไม่พบว่ามีประวัติการกระทำความผิด'));
99
+ });
100
+
101
+ test('Edge Cases Suite: handleVerifyRoute verifies natural language claim using token overlap', async () => {
102
+ let jsonResponse = null;
103
+ let status = null;
104
+ const payload = JSON.stringify({ claim: 'Ju Jingyi starred as Yang Caiwei in the 2024 historical drama In Blossom' });
105
+ const req = {
106
+ method: 'POST',
107
+ url: '/api/verify',
108
+ headers: { 'content-type': 'application/json' },
109
+ on: (event, cb) => {
110
+ if (event === 'data') cb(Buffer.from(payload));
111
+ if (event === 'end') cb();
112
+ return req;
113
+ }
114
+ };
115
+ const res = {
116
+ writeHead: (s) => { status = s; },
117
+ end: (data) => { jsonResponse = JSON.parse(data); }
118
+ };
119
+ await handleVerifyRoute(req, res);
120
+ assert.equal(status, 200);
121
+ assert.equal(jsonResponse.verdict, 'PARTIALLY_VERIFIED');
122
+ assert.ok(jsonResponse.evidence.length > 0);
123
+ });
124
+
125
+ test('Edge Cases Suite: applyFutureEventGuard preserves confirmed 2026 dates when catalog tools are executed', () => {
126
+ const answer = 'ซีรีส์เรื่อง 《千香》 (The Eternal Fragrance) มีกำหนดออกอากาศในปี 2026';
127
+ const guarded = applyFutureEventGuard(answer, { tools: [{ name: 'search_works' }], now: new Date('2024-01-01') });
128
+ assert.ok(guarded.includes('2026'));
129
+ assert.ok(!guarded.includes('วันที่ในอนาคตที่ยังไม่ยืนยัน'));
130
+ });
131
+
@@ -0,0 +1,436 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ checkEngineCompatibility,
6
+ buildVllmServeArgs,
7
+ buildVllmExecutionPlan,
8
+ detectPlatformAndWsl,
9
+ calculateAutoTensorSplit,
10
+ } from '../engineService.js';
11
+ import {
12
+ MODEL_LOAD_DEFAULTS,
13
+ normalizeModelLoadSettings,
14
+ getActiveLocalEngine,
15
+ setActiveLocalEngine,
16
+ getPersistedModelLoadSettings,
17
+ savePersistedModelLoadSettings,
18
+ } from '../config.js';
19
+ import { routeCommand } from '../commands.js';
20
+
21
+ test('engine compatibility: LM Studio supports Windows, macOS, and Linux', () => {
22
+ const win = checkEngineCompatibility('lms', { platform: 'win32', hasWsl: false });
23
+ assert.equal(win.supported, true);
24
+ assert.equal(win.engine, 'lms');
25
+
26
+ const mac = checkEngineCompatibility('lms', { platform: 'darwin', hasWsl: false });
27
+ assert.equal(mac.supported, true);
28
+
29
+ const linux = checkEngineCompatibility('lms', { platform: 'linux', hasWsl: false });
30
+ assert.equal(linux.supported, true);
31
+ });
32
+
33
+ test('engine compatibility: vLLM is enterprise Linux only (supported on Linux and Windows via WSL2, rejected on macOS)', () => {
34
+ const linux = checkEngineCompatibility('vllm', { platform: 'linux', isWsl: false });
35
+ assert.equal(linux.supported, true);
36
+ assert.equal(linux.isEnterprise, true);
37
+
38
+ const winWithWsl = checkEngineCompatibility('vllm', { platform: 'win32', hasWsl: true });
39
+ assert.equal(winWithWsl.supported, true);
40
+ assert.equal(winWithWsl.viaWsl, true);
41
+ assert.equal(winWithWsl.isEnterprise, true);
42
+
43
+ const winNoWsl = checkEngineCompatibility('vllm', { platform: 'win32', hasWsl: false });
44
+ assert.equal(winNoWsl.supported, false);
45
+ assert.equal(winNoWsl.isEnterprise, true);
46
+ assert.match(winNoWsl.reason, /WSL2/i);
47
+
48
+ const mac = checkEngineCompatibility('vllm', { platform: 'darwin', hasWsl: false });
49
+ assert.equal(mac.supported, false);
50
+ assert.equal(mac.isEnterprise, true);
51
+ assert.match(mac.reason, /macOS/i);
52
+ });
53
+
54
+ test('buildVllmServeArgs constructs CLI options accurately with custom parameters', () => {
55
+ const args = buildVllmServeArgs('Qwen/Qwen2.5-7B-Instruct', {
56
+ port: 8000,
57
+ gpuMemoryUtilization: 0.85,
58
+ tensorParallelSize: 2,
59
+ maxModelLen: 8192,
60
+ dtype: 'bfloat16',
61
+ enforceEager: true,
62
+ enablePrefixCaching: true,
63
+ trustRemoteCode: true,
64
+ });
65
+
66
+ assert.ok(args.includes('serve'));
67
+ assert.ok(args.includes('Qwen/Qwen2.5-7B-Instruct'));
68
+ assert.ok(args.includes('--port'));
69
+ assert.ok(args.includes('8000'));
70
+ assert.ok(args.includes('--gpu-memory-utilization'));
71
+ assert.ok(args.includes('0.85'));
72
+ assert.ok(args.includes('--tensor-parallel-size'));
73
+ assert.ok(args.includes('2'));
74
+ assert.ok(args.includes('--max-model-len'));
75
+ assert.ok(args.includes('8192'));
76
+ assert.ok(args.includes('--dtype'));
77
+ assert.ok(args.includes('bfloat16'));
78
+ assert.ok(args.includes('--enforce-eager'));
79
+ assert.ok(args.includes('--enable-prefix-caching'));
80
+ assert.ok(args.includes('--trust-remote-code'));
81
+ });
82
+
83
+ test('buildVllmExecutionPlan formats command for Windows WSL2 or native Linux', () => {
84
+ const winPlan = buildVllmExecutionPlan('google/gemma-4-e4b', {
85
+ platform: 'win32',
86
+ distro: 'Ubuntu-26.04',
87
+ gpuMemoryUtilization: 0.9,
88
+ });
89
+ assert.equal(winPlan.isWsl, true);
90
+ assert.equal(winPlan.command, 'wsl.exe');
91
+ assert.ok(winPlan.args.includes('-d'));
92
+ assert.ok(winPlan.args.includes('Ubuntu-26.04'));
93
+ assert.ok(winPlan.displayCommand.includes('wsl -d Ubuntu-26.04'));
94
+
95
+ const linuxPlan = buildVllmExecutionPlan('google/gemma-4-e4b', {
96
+ platform: 'linux',
97
+ gpuMemoryUtilization: 0.9,
98
+ });
99
+ assert.equal(linuxPlan.isWsl, false);
100
+ assert.equal(linuxPlan.command, 'vllm');
101
+ assert.ok(linuxPlan.displayCommand.startsWith('vllm serve'));
102
+ });
103
+
104
+ test('config: normalizeModelLoadSettings and active engine management', () => {
105
+ const normalized = normalizeModelLoadSettings({
106
+ activeEngine: 'vllm',
107
+ gpuOffload: 'max',
108
+ cpuThreads: 6,
109
+ gpuMemoryUtilization: 0.88,
110
+ tensorParallelSize: 2,
111
+ });
112
+
113
+ assert.equal(normalized.activeEngine, 'vllm');
114
+ assert.equal(normalized.gpuOffload, 'max');
115
+ assert.equal(normalized.cpuThreads, 6);
116
+ assert.equal(normalized.vllm.gpuMemoryUtilization, 0.88);
117
+ assert.equal(normalized.vllm.tensorParallelSize, 2);
118
+
119
+ setActiveLocalEngine('vllm');
120
+ assert.equal(getActiveLocalEngine(), 'vllm');
121
+ setActiveLocalEngine('lms');
122
+ assert.equal(getActiveLocalEngine(), 'lms');
123
+ });
124
+
125
+ test('/model load handles parameter parsing and OS compatibility checks', async () => {
126
+ const logs = [];
127
+ const origLog = console.log;
128
+ console.log = (...args) => logs.push(args.join(' '));
129
+
130
+ const ctx = {
131
+ client: { provider: 'lmstudio', providerName: 'LM Studio' },
132
+ setIsProcessing: () => {},
133
+ };
134
+
135
+ try {
136
+
137
+ const resInvalid = await routeCommand('/model load test-model --engine unknown', ctx);
138
+ assert.equal(resInvalid, 'HANDLED');
139
+ assert.ok(logs.some(l => l.includes('Incompatible') || l.includes('UNKNOWN') || l.includes('unknown')));
140
+
141
+
142
+ savePersistedModelLoadSettings({ activeEngine: 'lms', gpuOffload: 'max', cpuThreads: 8 });
143
+ assert.equal(getActiveLocalEngine(), 'lms');
144
+ const persisted = getPersistedModelLoadSettings();
145
+ assert.equal(persisted.activeEngine, 'lms');
146
+ assert.equal(persisted.gpuOffload, 'max');
147
+ assert.equal(persisted.cpuThreads, 8);
148
+ } finally {
149
+ console.log = origLog;
150
+ }
151
+ });
152
+
153
+ test('/model engine handles inspection, compatibility, and switching', async () => {
154
+ const logs = [];
155
+ const origLog = console.log;
156
+ console.log = (...args) => logs.push(args.join(' '));
157
+
158
+ const ctx = {
159
+ client: {
160
+ provider: 'lmstudio',
161
+ providerName: 'LM Studio',
162
+ model: 'google/gemma-4-e4b',
163
+ switchProvider: (p, opt) => { ctx.client.provider = p; if (opt?.model) ctx.client.model = opt.model; },
164
+ },
165
+ setIsProcessing: () => {},
166
+ };
167
+
168
+ try {
169
+ const resInspect = await routeCommand('/model engine', ctx);
170
+ assert.equal(resInspect, 'HANDLED');
171
+
172
+ const resSwitchLms = await routeCommand('/model engine lms', ctx);
173
+ assert.equal(resSwitchLms, 'HANDLED');
174
+ assert.equal(getActiveLocalEngine(), 'lms');
175
+
176
+ const resInvalid = await routeCommand('/model engine invalidEngine123', ctx);
177
+ assert.equal(resInvalid, 'HANDLED');
178
+ } finally {
179
+ console.log = origLog;
180
+ }
181
+ });
182
+
183
+ test('/model config and /model params inspect, update, and reset parameters', async () => {
184
+ const logs = [];
185
+ const origLog = console.log;
186
+ console.log = (...args) => logs.push(args.join(' '));
187
+
188
+ const ctx = {
189
+ client: { provider: 'lmstudio', providerName: 'LM Studio' },
190
+ setIsProcessing: () => {},
191
+ };
192
+
193
+ try {
194
+ const resShow = await routeCommand('/model config', ctx);
195
+ assert.equal(resShow, 'HANDLED');
196
+
197
+ const resUpdate = await routeCommand('/model config --gpu max --threads 8 --mem-util 0.85 --enforce-eager --prefix-caching', ctx);
198
+ assert.equal(resUpdate, 'HANDLED');
199
+ const updated = getPersistedModelLoadSettings();
200
+ assert.equal(updated.gpuOffload, 'max');
201
+ assert.equal(updated.cpuThreads, 8);
202
+ assert.equal(updated.vllm.gpuMemoryUtilization, 0.85);
203
+ assert.equal(updated.vllm.enforceEager, true);
204
+ assert.equal(updated.vllm.enablePrefixCaching, true);
205
+
206
+ const resReset = await routeCommand('/model params --reset', ctx);
207
+ assert.equal(resReset, 'HANDLED');
208
+ const resetSettings = getPersistedModelLoadSettings();
209
+ assert.equal(resetSettings.gpuOffload, 'auto');
210
+ assert.equal(resetSettings.cpuThreads, 1);
211
+ assert.equal(resetSettings.contextLength, 32768);
212
+ } finally {
213
+ console.log = origLog;
214
+ }
215
+ });
216
+
217
+ test('/model preset lists curated presets and applies preset', async () => {
218
+ const logs = [];
219
+ const origLog = console.log;
220
+ console.log = (...args) => logs.push(args.join(' '));
221
+
222
+ let switchedProvider = null;
223
+ let switchedModel = null;
224
+ const ctx = {
225
+ client: {
226
+ provider: 'lmstudio',
227
+ providerName: 'LM Studio',
228
+ model: 'google/gemma-4-e4b',
229
+ switchProvider: (p, opt) => {
230
+ switchedProvider = p;
231
+ if (opt?.model) switchedModel = opt.model;
232
+ },
233
+ },
234
+ setIsProcessing: () => {},
235
+ };
236
+
237
+ try {
238
+ const resList = await routeCommand('/model preset', ctx);
239
+ assert.equal(resList, 'HANDLED');
240
+
241
+ const resApply = await routeCommand('/model preset gemma4', ctx);
242
+ assert.equal(resApply, 'HANDLED');
243
+ assert.equal(switchedProvider, 'lmstudio');
244
+ assert.equal(switchedModel, 'google/gemma-4-e4b');
245
+
246
+ const resUnknown = await routeCommand('/model preset unknown_xyz_preset', ctx);
247
+ assert.equal(resUnknown, 'HANDLED');
248
+ } finally {
249
+ console.log = origLog;
250
+ }
251
+ });
252
+
253
+ test('/model files discovers installed GGUF models', async () => {
254
+ const logs = [];
255
+ const origLog = console.log;
256
+ console.log = (...args) => logs.push(args.join(' '));
257
+
258
+ const ctx = {
259
+ client: { provider: 'lmstudio', providerName: 'LM Studio' },
260
+ setIsProcessing: () => {},
261
+ };
262
+
263
+ try {
264
+ const resFiles = await routeCommand('/model files', ctx);
265
+ assert.equal(resFiles, 'HANDLED');
266
+
267
+ const resGguf = await routeCommand('/model gguf', ctx);
268
+ assert.equal(resGguf, 'HANDLED');
269
+ } finally {
270
+ console.log = origLog;
271
+ }
272
+ });
273
+
274
+ test('/model inference parameters (temp, topp, ctx, thinking) update settings', async () => {
275
+ const logs = [];
276
+ const origLog = console.log;
277
+ console.log = (...args) => logs.push(args.join(' '));
278
+
279
+ const ctx = {
280
+ client: { provider: 'lmstudio', providerName: 'LM Studio' },
281
+ setIsProcessing: () => {},
282
+ };
283
+
284
+ try {
285
+ const resTemp = await routeCommand('/model temp 0.85', ctx);
286
+ assert.equal(resTemp, 'HANDLED');
287
+
288
+ const resTopp = await routeCommand('/model topp 0.90', ctx);
289
+ assert.equal(resTopp, 'HANDLED');
290
+
291
+ const resCtx = await routeCommand('/model ctx 32k', ctx);
292
+ assert.equal(resCtx, 'HANDLED');
293
+
294
+ const resThinking = await routeCommand('/model thinking collapse', ctx);
295
+ assert.equal(resThinking, 'HANDLED');
296
+
297
+ const { getPersistedInferenceSettings } = await import('../config.js');
298
+ const inf = getPersistedInferenceSettings();
299
+ assert.equal(inf.temperature, 0.85);
300
+ assert.equal(inf.topP, 0.90);
301
+ assert.equal(inf.contextLimit, 32768);
302
+ assert.equal(inf.thinking, 'collapse');
303
+ } finally {
304
+ console.log = origLog;
305
+ }
306
+ });
307
+
308
+ test('/model status renders enriched engine and inference diagnostics', async () => {
309
+ const logs = [];
310
+ const origLog = console.log;
311
+ console.log = (...args) => logs.push(args.join(' '));
312
+
313
+ const ctx = {
314
+ client: {
315
+ provider: 'lmstudio',
316
+ providerName: 'LM Studio',
317
+ model: 'google/gemma-4-e4b',
318
+ checkConnection: async () => ({ connected: true, latencyMs: 12, models: ['google/gemma-4-e4b'], activeLoadedModel: 'google/gemma-4-e4b' }),
319
+ },
320
+ setIsProcessing: () => {},
321
+ baseUrl: 'http://127.0.0.1:1234/v1',
322
+ };
323
+
324
+ try {
325
+ const resStatus = await routeCommand('/model status', ctx);
326
+ assert.equal(resStatus, 'HANDLED');
327
+ } finally {
328
+ console.log = origLog;
329
+ }
330
+ });
331
+
332
+ test('calculateAutoTensorSplit computes safe ratios across multiple GPUs with headroom deduction', () => {
333
+ const dualGpus = {
334
+ gpus: [
335
+ { index: 0, name: 'RTX 4090', freeGb: 23.5, totalGb: 24 },
336
+ { index: 1, name: 'RTX 4070', freeGb: 12.0, totalGb: 12 },
337
+ ]
338
+ };
339
+ const split = calculateAutoTensorSplit(dualGpus, 3.5, 0);
340
+ assert.equal(split.applicable, true);
341
+ assert.equal(split.tensorSplit, '20,12');
342
+ assert.deepEqual(split.ratios, [20, 12]);
343
+ assert.equal(String(split), '20,12');
344
+
345
+ const singleGpu = {
346
+ gpus: [{ index: 0, name: 'RTX 3060', freeGb: 12.0, totalGb: 12 }]
347
+ };
348
+ const singleSplit = calculateAutoTensorSplit(singleGpu);
349
+ assert.equal(singleSplit.applicable, false);
350
+ assert.equal(singleSplit.tensorSplit, '1');
351
+ });
352
+
353
+ test('/model minp and /model repeat update inference samplers', async () => {
354
+ const logs = [];
355
+ const origLog = console.log;
356
+ console.log = (...args) => logs.push(args.join(' '));
357
+
358
+ const ctx = {
359
+ client: { provider: 'lmstudio', providerName: 'LM Studio' },
360
+ setIsProcessing: () => {},
361
+ };
362
+
363
+ try {
364
+ const resMinP = await routeCommand('/model minp 0.08', ctx);
365
+ assert.equal(resMinP, 'HANDLED');
366
+
367
+ const resRepeat = await routeCommand('/model repeat 1.15', ctx);
368
+ assert.equal(resRepeat, 'HANDLED');
369
+
370
+ const { getPersistedInferenceSettings } = await import('../config.js');
371
+ const inf = getPersistedInferenceSettings();
372
+ assert.equal(inf.minP, 0.08);
373
+ assert.equal(inf.repeatPenalty, 1.15);
374
+ } finally {
375
+ console.log = origLog;
376
+ }
377
+ });
378
+
379
+ test('/model config parses multi-GPU, flash-attention, and batching parameters', async () => {
380
+ const logs = [];
381
+ const origLog = console.log;
382
+ console.log = (...args) => logs.push(args.join(' '));
383
+
384
+ const ctx = {
385
+ client: { provider: 'lmstudio', providerName: 'LM Studio' },
386
+ setIsProcessing: () => {},
387
+ };
388
+
389
+ try {
390
+ const res = await routeCommand('/model config --split-mode layer --tensor-split 20,12 --main-gpu 0 --fa -b 2048 -ub 512 --ctk q8_0', ctx);
391
+ assert.equal(res, 'HANDLED');
392
+
393
+ const settings = getPersistedModelLoadSettings();
394
+ assert.equal(settings.splitMode, 'layer');
395
+ assert.equal(settings.tensorSplit, '20,12');
396
+ assert.equal(settings.mainGpu, 0);
397
+ assert.equal(settings.flashAttn, true);
398
+ assert.equal(settings.batchSize, 2048);
399
+ assert.equal(settings.ubatchSize, 512);
400
+ assert.equal(settings.kvCacheDtype, 'q8_0');
401
+ } finally {
402
+ console.log = origLog;
403
+ }
404
+ });
405
+
406
+ test('LLMClient includes min_p and repeat_penalty in chat completion payload', async () => {
407
+ const { LLMClient } = await import('../llmClient.js');
408
+ const client = new LLMClient({ provider: 'lmstudio', baseUrl: 'http://127.0.0.1:1234/v1' });
409
+
410
+ let capturedBody = null;
411
+ const originalFetch = globalThis.fetch;
412
+ globalThis.fetch = async (url, options) => {
413
+ if (url.includes('/chat/completions')) {
414
+ capturedBody = JSON.parse(options.body);
415
+ return new Response(JSON.stringify({
416
+ id: 'chatcmpl-test',
417
+ choices: [{ message: { role: 'assistant', content: 'hello' } }]
418
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
419
+ }
420
+ return originalFetch(url, options);
421
+ };
422
+
423
+ try {
424
+ await client.chat([{ role: 'user', content: 'test' }], {
425
+ stream: false,
426
+ minP: 0.08,
427
+ repeatPenalty: 1.10,
428
+ });
429
+
430
+ assert.ok(capturedBody);
431
+ assert.equal(capturedBody.min_p, 0.08);
432
+ assert.equal(capturedBody.repeat_penalty, 1.10);
433
+ } finally {
434
+ globalThis.fetch = originalFetch;
435
+ }
436
+ });