kiku-agent 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +7 -7
  2. package/package.json +4 -7
  3. package/server/agent/agentCore.js +2 -2
  4. package/server/agent/catalogEntityResolver.js +8 -0
  5. package/server/agent/cli.js +4 -0
  6. package/server/agent/eval/AGENTS.md +228 -0
  7. package/server/agent/eval/analyze.py +684 -0
  8. package/server/agent/eval/gates.json +15 -0
  9. package/server/agent/eval/modules/analysis/compare.js +198 -0
  10. package/server/agent/eval/modules/analysis/gates.js +72 -0
  11. package/server/agent/eval/modules/analysis/judge.js +70 -0
  12. package/server/agent/eval/modules/core/cli.js +453 -0
  13. package/server/agent/eval/modules/core/config.js +60 -0
  14. package/server/agent/eval/modules/core/store.js +236 -0
  15. package/server/agent/eval/modules/evaluation/cases.js +689 -0
  16. package/server/agent/eval/modules/evaluation/runner.js +298 -0
  17. package/server/agent/eval/modules/evaluation/scoring.js +351 -0
  18. package/server/agent/eval/modules/evaluation/worker.js +122 -0
  19. package/server/agent/eval/modules/reporting/benchmark.js +265 -0
  20. package/server/agent/eval/modules/reporting/benchmarkPng.js +487 -0
  21. package/server/agent/eval/modules/reporting/dashboard.js +1406 -0
  22. package/server/agent/eval/modules/server/evalApi.js +1099 -0
  23. package/server/agent/eval/modules/ui/evalUi.js +4796 -0
  24. package/server/agent/eval/modules/utils/smokes.js +119 -0
  25. package/server/agent/ink/App.jsx +348 -0
  26. package/server/agent/ink/Panel.jsx +54 -0
  27. package/server/agent/ink/PromptInput.jsx +321 -0
  28. package/server/agent/ink/TaskPlanPanel.jsx +88 -0
  29. package/server/agent/ink/ThinkingPanel.jsx +68 -0
  30. package/server/agent/ink/ToolResult.jsx +133 -0
  31. package/server/agent/ink/main.jsx +195 -0
  32. package/server/agent/ink/useEventBus.js +218 -0
  33. package/server/agent/memory/contextAllocator.js +214 -0
  34. package/server/agent/memory/hierarchicalStore.js +523 -0
  35. package/server/agent/memory/reflectionWorker.js +110 -0
  36. package/server/agent/prompts.js +1 -1
  37. package/server/agent/telemetry/hardwareTiers.js +332 -0
  38. package/server/agent/telemetry/runtimeWatchdog.js +66 -0
  39. package/server/agent/telemetry/techPowerUpClient.js +682 -0
  40. package/server/agent/telemetry/techpowerup-mcp.json +22 -0
  41. package/server/agent/tests/agentBubbleSystem.test.js +65 -0
  42. package/server/agent/tests/agentFollowUps.test.js +127 -0
  43. package/server/agent/tests/apiRouter.test.js +108 -0
  44. package/server/agent/tests/architectureFixes.test.js +81 -0
  45. package/server/agent/tests/architectureRefactoring.test.js +194 -0
  46. package/server/agent/tests/autoResearch.test.js +64 -0
  47. package/server/agent/tests/bilibiliEmbed.test.js +251 -0
  48. package/server/agent/tests/catalogSync.test.js +130 -0
  49. package/server/agent/tests/citations.test.js +180 -0
  50. package/server/agent/tests/clarificationAnalytics.test.js +219 -0
  51. package/server/agent/tests/clarifyingQuestions.test.js +594 -0
  52. package/server/agent/tests/cliAutomation.test.js +121 -0
  53. package/server/agent/tests/cloudProviders.test.js +234 -0
  54. package/server/agent/tests/commandInput.test.js +24 -0
  55. package/server/agent/tests/commands.test.js +417 -0
  56. package/server/agent/tests/contextIntelligence.test.js +162 -0
  57. package/server/agent/tests/conversationSafety.test.js +53 -0
  58. package/server/agent/tests/decisionAndSearchEnhancements.test.js +135 -0
  59. package/server/agent/tests/dramaAnswerQuality.test.js +73 -0
  60. package/server/agent/tests/dynamicCatalogIngestion.test.js +285 -0
  61. package/server/agent/tests/dynamicRoutingAndSearch.test.js +92 -0
  62. package/server/agent/tests/edgeCasesScenarios.test.js +49 -0
  63. package/server/agent/tests/entertainmentMetrics.test.js +126 -0
  64. package/server/agent/tests/envBootstrap.test.js +87 -0
  65. package/server/agent/tests/envCommand.test.js +215 -0
  66. package/server/agent/tests/errorHandling.test.js +80 -0
  67. package/server/agent/tests/errors.test.js +13 -0
  68. package/server/agent/tests/evalAnalyze.test.js +54 -0
  69. package/server/agent/tests/evalCommand.test.js +54 -0
  70. package/server/agent/tests/evalCorpus.test.js +126 -0
  71. package/server/agent/tests/evalHarness.test.js +62 -0
  72. package/server/agent/tests/evalJudgeContext.test.js +38 -0
  73. package/server/agent/tests/evalMetrics.test.js +312 -0
  74. package/server/agent/tests/evalStore.test.js +190 -0
  75. package/server/agent/tests/evalStudio.test.js +179 -0
  76. package/server/agent/tests/feedbackLearning.test.js +186 -0
  77. package/server/agent/tests/firstRun.test.js +44 -0
  78. package/server/agent/tests/gemma4eOptimization.test.js +95 -0
  79. package/server/agent/tests/hardwareTiers.test.js +206 -0
  80. package/server/agent/tests/hybridWebSearch.test.js +192 -0
  81. package/server/agent/tests/judgeService.test.js +251 -0
  82. package/server/agent/tests/lanDiscovery.test.js +319 -0
  83. package/server/agent/tests/mcpClient.test.js +201 -0
  84. package/server/agent/tests/modelManager.test.js +109 -0
  85. package/server/agent/tests/multilingualScenarios.test.js +69 -0
  86. package/server/agent/tests/newCommands.test.js +347 -0
  87. package/server/agent/tests/newCommandsTui.test.js +141 -0
  88. package/server/agent/tests/newTools.test.js +113 -0
  89. package/server/agent/tests/newsIntelligence.test.js +178 -0
  90. package/server/agent/tests/nextGenArchitecture.test.js +99 -0
  91. package/server/agent/tests/offlineInput.test.js +217 -0
  92. package/server/agent/tests/packagingGuard.test.js +57 -0
  93. package/server/agent/tests/presentation.test.js +61 -0
  94. package/server/agent/tests/providerPersistence.test.js +39 -0
  95. package/server/agent/tests/queryBenchmarks.test.js +83 -0
  96. package/server/agent/tests/queryDetails.test.js +126 -0
  97. package/server/agent/tests/realWorldValidation.test.js +391 -0
  98. package/server/agent/tests/resilienceAndConcurrency.test.js +117 -0
  99. package/server/agent/tests/runtimeWatchdog.test.js +32 -0
  100. package/server/agent/tests/sanitizer.test.js +37 -0
  101. package/server/agent/tests/scenariosData.js +378 -0
  102. package/server/agent/tests/schemaRouting.test.js +54 -0
  103. package/server/agent/tests/securityAuditRemediation.test.js +214 -0
  104. package/server/agent/tests/securityHardening.test.js +146 -0
  105. package/server/agent/tests/securitySanitizer.test.js +38 -0
  106. package/server/agent/tests/sessionAndCopy.test.js +232 -0
  107. package/server/agent/tests/sidebarControls.test.js +101 -0
  108. package/server/agent/tests/statefulMemory.test.js +270 -0
  109. package/server/agent/tests/subagent.test.js +216 -0
  110. package/server/agent/tests/subagentProgress.test.js +109 -0
  111. package/server/agent/tests/subagentTextFallback.test.js +115 -0
  112. package/server/agent/tests/suggestions.test.js +199 -0
  113. package/server/agent/tests/suggestionsTui.test.js +251 -0
  114. package/server/agent/tests/telemetryAndProfiles.test.js +179 -0
  115. package/server/agent/tests/terminalImage.test.js +80 -0
  116. package/server/agent/tests/terminalMarkdown.test.js +166 -0
  117. package/server/agent/tests/terminalText.test.js +133 -0
  118. package/server/agent/tests/textProtocolLoop.test.js +126 -0
  119. package/server/agent/tests/textToolParsing.test.js +139 -0
  120. package/server/agent/tests/theme.test.js +26 -0
  121. package/server/agent/tests/toolRegistryParity.test.js +53 -0
  122. package/server/agent/tests/toolRoutingRegressions.test.js +119 -0
  123. package/server/agent/tests/tools.test.js +259 -0
  124. package/server/agent/tests/unifiedEndpoints.test.js +152 -0
  125. package/server/agent/tests/v110Features.test.js +262 -0
  126. package/server/agent/tests/versionBump.test.js +69 -0
  127. package/server/agent/tests/vramEstimator.test.js +520 -0
  128. package/server/agent/tests/webChatInspection.test.js +300 -0
  129. package/server/agent/tests/webChatMemory.test.js +112 -0
  130. package/server/agent/tests/webChatSecurity.test.js +169 -0
  131. package/server/agent/tests/webExtract.test.js +261 -0
  132. package/server/agent/tests/webSources.test.js +247 -0
  133. package/server/agent/websearch/adapters/duckduckgo.js +97 -0
  134. package/server/agent/websearch/adapters/jina.js +77 -0
  135. package/server/agent/websearch/adapters/searxng.js +57 -0
  136. package/server/agent/websearch/core/canonical.js +89 -0
  137. package/server/agent/websearch/core/fusion.js +81 -0
  138. package/server/agent/websearch/core/types.js +29 -0
  139. package/server/agent/websearch/extract.js +605 -0
  140. package/server/agent/websearch/index.js +43 -0
  141. package/server/agent/websearch/orchestrator.js +266 -0
  142. package/server/agent/websearch/resilience/breaker.js +97 -0
  143. package/server/agent/websearch/resilience/cache.js +59 -0
  144. package/server/agent/websearch/resilience/limiter.js +93 -0
  145. package/server/agent/websearch/security/filter.js +36 -0
  146. package/server/agent/websearch/security/ssrf.js +289 -0
  147. package/server/agent/websearch/telemetry.js +71 -0
  148. package/server/apiRouter.js +7 -11
  149. package/server/eval/judgeRubrics.js +110 -0
  150. package/server/eval/judgeService.js +290 -0
  151. package/server/eval/runBenchmark.js +182 -0
  152. package/server/mcp/examples/librechat.example.yaml +16 -0
  153. package/server/mcp/tests/connection.test.js +156 -0
  154. package/server/routes/agent.js +55 -0
  155. package/server/supabase_schema.sql +63 -0
@@ -0,0 +1,190 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ acquireRunLock,
8
+ archiveSessionReport,
9
+ createSaver,
10
+ loadReportWithBackups,
11
+ rotateBackups,
12
+ writeBlockedReport,
13
+ } from '../eval/modules/core/store.js';
14
+
15
+ function makeRow(id) {
16
+ return {
17
+ id,
18
+ category: 'music',
19
+ language: 'en',
20
+ question: `q-${id}`,
21
+ status: 'completed',
22
+ answer: `a-${id}`,
23
+ coverage: { matched: 1, total: 1, allMatched: true, missing: [] },
24
+ toolCorrectness: { isCorrect: true, notApplicable: false },
25
+ trajectoryHealth: { hasLoop: false },
26
+ groundedness: { isGrounded: true, notApplicable: false, supportRatio: 1 },
27
+ durationMs: 100,
28
+ latencyBreakdown: { llmInferenceMs: 10, toolExecutionMs: 5, decodeTps: 30 },
29
+ };
30
+ }
31
+
32
+ async function makeTempDir() {
33
+ return fs.mkdtemp(path.join(os.tmpdir(), 'kiku-store-test-'));
34
+ }
35
+
36
+ test('createSaver flushes the report atomically and recomputes the summary', async () => {
37
+ const dir = await makeTempDir();
38
+ const outPath = path.join(dir, 'live.json');
39
+ const report = { metadata: { caseIds: ['c1', 'c2'] }, rows: [] };
40
+ const save = createSaver(outPath, report);
41
+
42
+ report.rows.push(makeRow('c1'));
43
+ await save(report.rows[0], { force: true });
44
+
45
+ const written = JSON.parse(await fs.readFile(outPath, 'utf8'));
46
+ assert.equal(written.rows.length, 1);
47
+ assert.equal(written.summary.planned, 2);
48
+ assert.ok(written.summary.completed === 1);
49
+ const files = await fs.readdir(dir);
50
+ assert.ok(!files.includes('live.json.tmp'), 'tmp file must be renamed away');
51
+ });
52
+
53
+ test('createSaver does not flush before the cadence when rows are appended', async () => {
54
+ const dir = await makeTempDir();
55
+ const outPath = path.join(dir, 'live.json');
56
+ const report = { metadata: { caseIds: ['c1'] }, rows: [] };
57
+ const save = createSaver(outPath, report);
58
+
59
+ await assert.rejects(() => fs.readFile(outPath, 'utf8'));
60
+ report.rows.push(makeRow('c1'));
61
+ await save(report.rows[0]);
62
+ await assert.rejects(() => fs.readFile(outPath, 'utf8'));
63
+
64
+ await save(null, { force: true });
65
+ const written = JSON.parse(await fs.readFile(outPath, 'utf8'));
66
+ assert.equal(written.rows.length, 1);
67
+ });
68
+
69
+ test('a finished report is archived exactly once across saver instances', async () => {
70
+ const dir = await makeTempDir();
71
+ const outPath = path.join(dir, 'live.json');
72
+ const report = {
73
+ metadata: { caseIds: ['c1', 'c2'], finishedAt: new Date().toISOString() },
74
+ rows: [makeRow('c1'), makeRow('c2')],
75
+ };
76
+
77
+ const save1 = createSaver(outPath, report);
78
+ await save1(null, { force: true });
79
+ const save2 = createSaver(outPath, report);
80
+ await save2(null, { force: true });
81
+ const save3 = createSaver(outPath, report);
82
+ await save3(null, { force: true });
83
+
84
+ const historyFiles = (await fs.readdir(path.join(dir, 'history'))).filter(f => f.startsWith('run_'));
85
+ assert.equal(historyFiles.length, 1);
86
+ assert.ok(report.metadata.archivedAt, 'live report must record archivedAt');
87
+ });
88
+
89
+ test('archiveSessionReport marks incomplete runs as partial', async () => {
90
+ const dir = await makeTempDir();
91
+ const outPath = path.join(dir, 'live.json');
92
+ const report = {
93
+ metadata: { caseIds: ['c1', 'c2', 'c3'], finishedAt: new Date().toISOString() },
94
+ rows: [makeRow('c1')],
95
+ };
96
+
97
+ const archivePath = await archiveSessionReport(report, outPath);
98
+ assert.ok(archivePath);
99
+ const archived = JSON.parse(await fs.readFile(archivePath, 'utf8'));
100
+ assert.equal(archived.metadata.archivedPartial, true);
101
+
102
+ const second = await archiveSessionReport(report, outPath);
103
+ assert.equal(second, null, 'already-archived reports must not be re-archived');
104
+ });
105
+
106
+ test('archiveSessionReport stamps complete runs as non-partial with unique names', async () => {
107
+ const dir = await makeTempDir();
108
+ const outPath = path.join(dir, 'live.json');
109
+ const report = {
110
+ metadata: { caseIds: ['c1'], finishedAt: new Date().toISOString() },
111
+ rows: [makeRow('c1')],
112
+ };
113
+
114
+ const archivePath = await archiveSessionReport(report, outPath);
115
+ assert.ok(archivePath);
116
+ const archived = JSON.parse(await fs.readFile(archivePath, 'utf8'));
117
+ assert.equal(archived.metadata.archivedPartial, false);
118
+ assert.match(path.basename(archivePath), /run_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-\d{3}\.json/);
119
+ });
120
+
121
+ test('writeBlockedReport writes a sibling .blocked.json and never touches the live report', async () => {
122
+ const dir = await makeTempDir();
123
+ const outPath = path.join(dir, 'live.json');
124
+ const liveContent = JSON.stringify({ metadata: {}, rows: [makeRow('c1')] }, null, 2);
125
+ await fs.writeFile(outPath, liveContent);
126
+
127
+ const blockedPath = await writeBlockedReport(outPath, { reason: 'LM Studio offline', planned: 5 });
128
+ assert.equal(blockedPath, path.join(dir, 'live.blocked.json'));
129
+ assert.equal(await fs.readFile(outPath, 'utf8'), liveContent, 'live report must stay untouched');
130
+
131
+ const blocked = JSON.parse(await fs.readFile(blockedPath, 'utf8'));
132
+ assert.equal(blocked.status, 'blocked');
133
+ assert.equal(blocked.reason, 'LM Studio offline');
134
+ assert.equal(blocked.planned, 5);
135
+ });
136
+
137
+ test('rotateBackups keeps a bounded chain without a duplicate .bak copy', async () => {
138
+ const dir = await makeTempDir();
139
+ const outPath = path.join(dir, 'live.json');
140
+
141
+ await fs.writeFile(outPath, 'v1');
142
+ await rotateBackups(outPath, 3);
143
+ await fs.writeFile(outPath, 'v2');
144
+ await rotateBackups(outPath, 3);
145
+ await fs.writeFile(outPath, 'v3');
146
+ await rotateBackups(outPath, 3);
147
+
148
+ assert.equal(await fs.readFile(path.join(`${outPath}.bak.1`), 'utf8'), 'v3');
149
+ assert.equal(await fs.readFile(path.join(`${outPath}.bak.2`), 'utf8'), 'v2');
150
+ assert.equal(await fs.readFile(path.join(`${outPath}.bak.3`), 'utf8'), 'v1');
151
+ await assert.rejects(() => fs.stat(`${outPath}.bak`), 'legacy duplicate .bak must no longer be written');
152
+ });
153
+
154
+ test('loadReportWithBackups prefers the live file then .bak.1 then .bak', async () => {
155
+ const dir = await makeTempDir();
156
+ const outPath = path.join(dir, 'live.json');
157
+ const report = { metadata: {}, rows: [makeRow('c1')] };
158
+
159
+ assert.equal(await loadReportWithBackups(outPath), null);
160
+
161
+ await fs.writeFile(`${outPath}.bak.1`, JSON.stringify(report));
162
+ const fromBak1 = await loadReportWithBackups(outPath);
163
+ assert.equal(fromBak1.rows[0].id, 'c1');
164
+
165
+ await fs.writeFile(outPath, JSON.stringify({ metadata: {}, rows: [makeRow('c2')] }));
166
+ const fromLive = await loadReportWithBackups(outPath);
167
+ assert.equal(fromLive.rows[0].id, 'c2');
168
+ });
169
+
170
+ test('acquireRunLock serializes writers and recovers stale locks', async () => {
171
+ const dir = await makeTempDir();
172
+ const outPath = path.join(dir, 'live.json');
173
+
174
+ const lock = await acquireRunLock(outPath);
175
+ await assert.rejects(
176
+ () => acquireRunLock(outPath),
177
+ error => error.status === 409
178
+ );
179
+ await lock.release();
180
+
181
+ const second = await acquireRunLock(outPath);
182
+ await second.release();
183
+
184
+ const lockPath = `${outPath}.lock`;
185
+ await fs.writeFile(lockPath, '999999', { flag: 'wx' });
186
+ const staleTime = new Date(Date.now() - 2 * 60 * 60 * 1000);
187
+ await fs.utimes(lockPath, staleTime, staleTime);
188
+ const staleLock = await acquireRunLock(outPath);
189
+ await staleLock.release();
190
+ });
@@ -0,0 +1,179 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { getEvalUiHtml } from '../eval/modules/ui/evalUi.js';
4
+ import { handleEvalApiRequest } from '../eval/modules/server/evalApi.js';
5
+
6
+ test('evalUi generator produces valid HTML with essential studio components', () => {
7
+ const html = getEvalUiHtml();
8
+ assert.ok(typeof html === 'string');
9
+ assert.ok(html.includes('Kiku Eval Studio'));
10
+ assert.ok(html.includes('/api/eval/events'));
11
+ assert.ok(html.includes('/api/eval/status'));
12
+ assert.ok(html.includes('id="btnStart"'));
13
+ assert.ok(html.includes('id="btnStop"'));
14
+ assert.ok(html.includes('id="casesTableBody"'));
15
+ assert.ok(html.includes('id="detailDrawer"'));
16
+ assert.ok(html.includes('id="kpiPassAt1"'));
17
+ assert.ok(html.includes('id="compareModal"'));
18
+ assert.ok(html.includes('id="playgroundModal"'));
19
+ assert.ok(html.includes('id="suitesModal"'));
20
+ assert.ok(html.includes('id="arenaModal"'));
21
+ assert.ok(html.includes('id="judgeModal"'));
22
+ assert.ok(html.includes('id="studioAlertModal"'));
23
+ assert.ok(html.includes('id="studioPromptModal"'));
24
+ assert.ok(!html.includes('alert('), 'evalUi must not use browser alert dialogs');
25
+ assert.ok(!html.includes('prompt('), 'evalUi must not use browser prompt dialogs');
26
+ assert.ok(!/[\p{Extended_Pictographic}]/u.test(html), 'evalUi must enforce zero emoji policy');
27
+ });
28
+
29
+ function createMockHttp() {
30
+ const headers = {};
31
+ let body = '';
32
+ let statusCode = 200;
33
+
34
+ const res = {
35
+ writeHead(code, h = {}) {
36
+ statusCode = code;
37
+ Object.assign(headers, h);
38
+ },
39
+ setHeader(k, v) {
40
+ headers[k] = v;
41
+ },
42
+ end(data = '') {
43
+ body += data;
44
+ },
45
+ write(chunk = '') {
46
+ body += chunk;
47
+ },
48
+ destroy() {},
49
+ };
50
+
51
+ return { res, getResult: () => ({ statusCode, headers, body }) };
52
+ }
53
+
54
+ test('handleEvalApiRequest handles /eval route and serves HTML', async () => {
55
+ const req = { url: '/eval', method: 'GET', on() {} };
56
+ const { res, getResult } = createMockHttp();
57
+
58
+ const handled = await handleEvalApiRequest(req, res);
59
+ assert.equal(handled, true);
60
+ const result = getResult();
61
+ assert.equal(result.statusCode, 200);
62
+ assert.ok(result.headers['Content-Type'].includes('text/html'));
63
+ assert.ok(result.body.includes('Kiku Eval Studio'));
64
+ });
65
+
66
+ test('handleEvalApiRequest handles /api/eval/status route', async () => {
67
+ const req = { url: '/api/eval/status', method: 'GET', on() {} };
68
+ const { res, getResult } = createMockHttp();
69
+
70
+ const handled = await handleEvalApiRequest(req, res);
71
+ assert.equal(handled, true);
72
+ const result = getResult();
73
+ assert.equal(result.statusCode, 200);
74
+ assert.ok(result.headers['Content-Type'].includes('application/json'));
75
+ const parsed = JSON.parse(result.body);
76
+ assert.equal(parsed.running, false);
77
+ });
78
+
79
+ test('handleEvalApiRequest handles /api/eval/cases and /api/eval/suites routes with dynamic counts', async () => {
80
+ const req = { url: '/api/eval/cases', method: 'GET', on() {} };
81
+ const { res, getResult } = createMockHttp();
82
+
83
+ const handled = await handleEvalApiRequest(req, res);
84
+ assert.equal(handled, true);
85
+ const result = getResult();
86
+ assert.equal(result.statusCode, 200);
87
+ const list = JSON.parse(result.body);
88
+ assert.equal(list.length, 500);
89
+ assert.ok(list.some(c => c.id === 'w01'));
90
+
91
+ const reqSuites = { url: '/api/eval/suites', method: 'GET', on() {} };
92
+ const mockSuites = createMockHttp();
93
+ assert.equal(await handleEvalApiRequest(reqSuites, mockSuites.res), true);
94
+ const suites = JSON.parse(mockSuites.getResult().body);
95
+ assert.equal(suites.catalog.count, 350);
96
+ assert.equal(suites.all.count, 500);
97
+ assert.equal(suites.security.count, 150);
98
+ assert.equal(suites.expanded.count, 360);
99
+ assert.equal(suites.legacy.count, 140);
100
+ });
101
+
102
+ test('handleEvalApiRequest handles /api/eval/history and /api/eval/custom-suites routes', async () => {
103
+ const reqHistory = { url: '/api/eval/history', method: 'GET', on() {} };
104
+ const mockHist = createMockHttp();
105
+ assert.equal(await handleEvalApiRequest(reqHistory, mockHist.res), true);
106
+ const histList = JSON.parse(mockHist.getResult().body);
107
+ assert.ok(Array.isArray(histList));
108
+
109
+ const reqSuites = { url: '/api/eval/custom-suites', method: 'GET', on() {} };
110
+ const mockSuites = createMockHttp();
111
+ assert.equal(await handleEvalApiRequest(reqSuites, mockSuites.res), true);
112
+ const suitesList = JSON.parse(mockSuites.getResult().body);
113
+ assert.ok(Array.isArray(suitesList));
114
+ assert.ok(suitesList.length >= 1);
115
+ });
116
+
117
+ test('handleEvalApiRequest handles /api/eval/judge-rubric and /api/eval/matrix routes', async () => {
118
+ const reqRubric = { url: '/api/eval/judge-rubric', method: 'GET', on() {} };
119
+ const mockRubric = createMockHttp();
120
+ assert.equal(await handleEvalApiRequest(reqRubric, mockRubric.res), true);
121
+ const rubric = JSON.parse(mockRubric.getResult().body);
122
+ assert.ok(Array.isArray(rubric.criteria));
123
+ assert.ok(rubric.criteria.length >= 4);
124
+
125
+ const reqMatrix = { url: '/api/eval/matrix', method: 'GET', on() {} };
126
+ const mockMatrix = createMockHttp();
127
+ assert.equal(await handleEvalApiRequest(reqMatrix, mockMatrix.res), true);
128
+ const matrix = JSON.parse(mockMatrix.getResult().body);
129
+ assert.ok(Array.isArray(matrix));
130
+ });
131
+
132
+ test('handleEvalApiRequest handles export routes for CSV, PNG, and JSON with UUID filenames', async () => {
133
+ const uuidPattern = /attachment; filename="[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(csv|png|json)"/;
134
+
135
+ const reqCsv = { url: '/api/eval/export/csv', method: 'GET', on() {} };
136
+ const mockCsv = createMockHttp();
137
+ assert.equal(await handleEvalApiRequest(reqCsv, mockCsv.res), true);
138
+ const csvRes = mockCsv.getResult();
139
+ assert.equal(csvRes.statusCode, 200);
140
+ assert.ok(csvRes.headers['Content-Type'].includes('text/csv'));
141
+ assert.ok(csvRes.body.includes('id,category,language,status'));
142
+ assert.match(csvRes.headers['Content-Disposition'], uuidPattern);
143
+ assert.ok(csvRes.headers['Content-Disposition'].endsWith('.csv"'));
144
+
145
+ const reqPng = { url: '/api/eval/export/png', method: 'GET', on() {} };
146
+ const mockPng = createMockHttp();
147
+ assert.equal(await handleEvalApiRequest(reqPng, mockPng.res), true);
148
+ const pngRes = mockPng.getResult();
149
+ assert.equal(pngRes.statusCode, 200);
150
+ assert.ok(pngRes.headers['Content-Type'].includes('image/png'));
151
+ assert.match(pngRes.headers['Content-Disposition'], uuidPattern);
152
+ assert.ok(pngRes.headers['Content-Disposition'].endsWith('.png"'));
153
+
154
+ const reqJson = { url: '/api/eval/export/json', method: 'GET', on() {} };
155
+ const mockJson = createMockHttp();
156
+ assert.equal(await handleEvalApiRequest(reqJson, mockJson.res), true);
157
+ const jsonRes = mockJson.getResult();
158
+ assert.equal(jsonRes.statusCode, 200);
159
+ assert.ok(jsonRes.headers['Content-Type'].includes('application/json'));
160
+ assert.match(jsonRes.headers['Content-Disposition'], uuidPattern);
161
+ assert.ok(jsonRes.headers['Content-Disposition'].endsWith('.json"'));
162
+ });
163
+
164
+ test('handleEvalApiRequest /api/eval/start with retryErrors processes gracefully without wiping dataset', async () => {
165
+ const req = {
166
+ url: '/api/eval/start',
167
+ method: 'POST',
168
+ on(event, cb) {
169
+ if (event === 'data') cb(Buffer.from(JSON.stringify({ retryErrors: true })));
170
+ if (event === 'end') cb();
171
+ }
172
+ };
173
+ const { res, getResult } = createMockHttp();
174
+ const handled = await handleEvalApiRequest(req, res);
175
+ assert.equal(handled, true);
176
+ const result = getResult();
177
+ assert.ok([200, 400, 409].includes(result.statusCode));
178
+ });
179
+
@@ -0,0 +1,186 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { mkdtemp, rm } from 'node:fs/promises';
6
+
7
+ // These tests exercise feedback routes directly without an admin key, so opt in
8
+ // to the test-admin bypass explicitly instead of relying on test-mode sniffing.
9
+ process.env.WEBCHAT_ALLOW_TEST_ADMIN = 'true';
10
+
11
+ import {
12
+ FeedbackLearning,
13
+ extractAnswerFeatures,
14
+ detectAnswerLanguage,
15
+ FEEDBACK_MIN_SAMPLES,
16
+ } from '../feedbackLearning.js';
17
+ import { AgentMemory } from '../memory.js';
18
+
19
+ function tempStore() {
20
+ return mkdtemp(path.join(os.tmpdir(), 'kiku-feedback-')).then(dir => ({
21
+ dir,
22
+ store: new FeedbackLearning({ storagePath: path.join(dir, 'answer_feedback.json') }),
23
+ }));
24
+ }
25
+
26
+ const SHORT_BULLET = '## สรุป\n- ประเด็นหนึ่ง\n- ประเด็นสอง\nดู https://www.youtube.com/watch?v=abcdefghijk';
27
+ const LONG_PLAIN = ('คำตอบที่ยาวมากและไม่มีโครงสร้างอะไรเลย '.repeat(120)).trim();
28
+
29
+ test('feature extraction detects structure, links, and length buckets', () => {
30
+ const f = extractAnswerFeatures(SHORT_BULLET, ['search_works']);
31
+ assert.equal(f.lengthBucket, 'short');
32
+ assert.equal(f.hasHeaders, true);
33
+ assert.equal(f.hasBullets, true);
34
+ assert.equal(f.videoLinks, 1);
35
+ assert.equal(f.language, 'th');
36
+ assert.equal(f.usedDeepResearch, false);
37
+
38
+ const deep = extractAnswerFeatures(SHORT_BULLET, ['run_deep_research']);
39
+ assert.equal(deep.usedDeepResearch, true);
40
+
41
+ const plain = extractAnswerFeatures(LONG_PLAIN, []);
42
+ assert.equal(plain.lengthBucket, 'long');
43
+ assert.equal(plain.hasHeaders, false);
44
+ assert.equal(plain.hasBullets, false);
45
+ assert.equal(detectAnswerLanguage('Hello world this is English'), 'en');
46
+ });
47
+
48
+ test('learned prefer directive emerges after enough likes', async () => {
49
+ const { dir, store } = await tempStore();
50
+ try {
51
+ for (let i = 0; i < FEEDBACK_MIN_SAMPLES - 1; i++) {
52
+ const r = store.record({ rating: 'like', answer: SHORT_BULLET, toolsUsed: ['search_works'] });
53
+ assert.equal(r.directives.length, 0, 'no directive below threshold');
54
+ }
55
+ const r = store.record({ rating: 'like', answer: SHORT_BULLET, toolsUsed: ['search_works'] });
56
+ assert.ok(r.directives.length > 0);
57
+ assert.ok(r.directives.some(d => d.kind === 'prefer'));
58
+ assert.equal(r.totals.like, FEEDBACK_MIN_SAMPLES);
59
+ assert.match(store.buildStyleContext(), /ตอบแนวนี้/);
60
+ } finally {
61
+ await rm(dir, { recursive: true, force: true });
62
+ }
63
+ });
64
+
65
+ test('sustained dislikes of long plain answers produce an avoid directive', async () => {
66
+ const { dir, store } = await tempStore();
67
+ try {
68
+ let last = null;
69
+ for (let i = 0; i < FEEDBACK_MIN_SAMPLES; i++) {
70
+ last = store.record({ rating: 'dislike', answer: LONG_PLAIN, toolsUsed: [] });
71
+ }
72
+ assert.ok(last.directives.some(d => d.kind === 'avoid' && /ยืดเยื้อ|ข้อความล้วน/.test(d.text)));
73
+ assert.match(store.buildStyleContext(), /หลีกเลี่ยงแนวนี้/);
74
+ } finally {
75
+ await rm(dir, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ test('mixed feedback below threshold learns nothing', async () => {
80
+ const { dir, store } = await tempStore();
81
+ try {
82
+ store.record({ rating: 'like', answer: SHORT_BULLET });
83
+ store.record({ rating: 'dislike', answer: SHORT_BULLET });
84
+ const stats = store.getStats();
85
+ assert.equal(stats.directives.length, 0);
86
+ assert.equal(store.buildStyleContext(), '');
87
+ assert.equal(stats.totals.like, 1);
88
+ assert.equal(stats.totals.dislike, 1);
89
+ } finally {
90
+ await rm(dir, { recursive: true, force: true });
91
+ }
92
+ });
93
+
94
+ test('feedback record rejects invalid ratings and persists across reloads', async () => {
95
+ const { dir, store } = await tempStore();
96
+ try {
97
+ assert.throws(() => store.record({ rating: 'meh', answer: 'x' }), /rating must be/);
98
+ store.record({ rating: 'like', answer: SHORT_BULLET, messageId: 'msg_1' });
99
+ const reloaded = new FeedbackLearning({ storagePath: path.join(dir, 'answer_feedback.json') });
100
+ assert.equal(reloaded.getStats().totals.like, 1);
101
+ assert.equal(reloaded.getStats().recentCount, 1);
102
+ } finally {
103
+ await rm(dir, { recursive: true, force: true });
104
+ }
105
+ });
106
+
107
+ test('learned directives flow into agent memory context', () => {
108
+ const memory = new AgentMemory();
109
+ assert.equal(memory.buildFeedbackStyleContext(), '');
110
+ memory.applyFeedbackDirectives([
111
+ { id: 'prefer:fmt:bullets', kind: 'prefer', text: 'ใช้ bullet สรุปประเด็นสำคัญ', support: 6 },
112
+ { id: 'avoid:len:long', kind: 'avoid', text: 'หลีกเลี่ยงคำตอบที่ยาวยืดเยื้อ', support: 5 },
113
+ ]);
114
+ assert.match(memory.buildFeedbackStyleContext(), /bullet/);
115
+ assert.match(memory.buildMemoryContext('เพลง'), /Learned answer-style preferences/);
116
+ assert.match(memory.buildMemoryContext('เพลง'), /bullet/);
117
+ });
118
+
119
+ function mockReq(body) {
120
+ const handlers = {};
121
+ return {
122
+ on: (ev, cb) => { handlers[ev] = cb; },
123
+ _flush: () => {
124
+ const s = Buffer.from(JSON.stringify(body));
125
+ handlers.data?.(s);
126
+ handlers.end?.();
127
+ },
128
+ };
129
+ }
130
+
131
+ function mockRes() {
132
+ return {
133
+ headersSent: false,
134
+ statusCode: null,
135
+ body: null,
136
+ writeHead(code) { this.statusCode = code; },
137
+ end(str) { this.body = str; },
138
+ };
139
+ }
140
+
141
+ test('POST /api/feedback records rating and returns learned directives', async () => {
142
+ const { dir, store } = await tempStore();
143
+ try {
144
+ const { handleFeedbackRoute, handleFeedbackStatsRoute, setFeedbackStore } = await import('../../web-chat/routes/feedbackRoute.js');
145
+ setFeedbackStore(store);
146
+
147
+ for (let i = 0; i < FEEDBACK_MIN_SAMPLES; i++) {
148
+ const req = mockReq({
149
+ rating: 'like',
150
+ query: 'เพลงล่าสุด',
151
+ answer: SHORT_BULLET,
152
+ tools: ['search_discography'],
153
+ model: 'google/gemma-4-e4b',
154
+ persona: 'default',
155
+ sessionId: 'sess_test',
156
+ messageId: `msg_${i}`,
157
+ });
158
+ const res = mockRes();
159
+ const pending = handleFeedbackRoute(req, res);
160
+ req._flush();
161
+ await pending;
162
+ assert.equal(res.statusCode, 200);
163
+ const json = JSON.parse(res.body);
164
+ assert.equal(json.status, 'ok');
165
+ assert.equal(json.rating, 'like');
166
+ }
167
+ const statsReq = mockReq({});
168
+ const statsRes = mockRes();
169
+ await handleFeedbackStatsRoute(statsReq, statsRes);
170
+ const stats = JSON.parse(statsRes.body);
171
+ assert.equal(stats.status, 'ok');
172
+ assert.equal(stats.totals.like, FEEDBACK_MIN_SAMPLES);
173
+ assert.ok(stats.directives.length > 0);
174
+
175
+ const badReq = mockReq({ rating: 'meh' });
176
+ const badRes = mockRes();
177
+ const badPending = handleFeedbackRoute(badReq, badRes);
178
+ badReq._flush();
179
+ await badPending;
180
+ assert.equal(badRes.statusCode, 400);
181
+ } finally {
182
+ const { setFeedbackStore } = await import('../../web-chat/routes/feedbackRoute.js');
183
+ setFeedbackStore(null);
184
+ await rm(dir, { recursive: true, force: true });
185
+ }
186
+ });
@@ -0,0 +1,44 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import path from 'node:path';
4
+ import { readFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import os from 'node:os';
7
+ import { mkdtemp, rm } from 'node:fs/promises';
8
+
9
+ import { getKikuDataDir } from '../appPaths.js';
10
+ import { runDoctor } from '../doctor.js';
11
+
12
+ test('app data path honors explicit and platform-standard locations', () => {
13
+ assert.equal(getKikuDataDir({ KIKU_DATA_DIR: '/tmp/custom-kiku' }, 'linux'), path.normalize('/tmp/custom-kiku'));
14
+ assert.equal(getKikuDataDir({ KIKU_DATA_DIR: '.kiku-data' }, 'linux'), path.join(os.homedir(), '.kiku-data'));
15
+ assert.equal(getKikuDataDir({ XDG_DATA_HOME: '/tmp/xdg' }, 'linux'), path.join('/tmp/xdg', 'kiku-agent'));
16
+ assert.equal(getKikuDataDir({ APPDATA: 'C:\\Users\\Miju\\AppData\\Roaming' }, 'win32'), path.join('C:\\Users\\Miju\\AppData\\Roaming', 'kiku-agent'));
17
+ assert.equal(getKikuDataDir({ LOCALAPPDATA: 'C:\\Users\\Miju\\AppData\\Local' }, 'win32'), path.join('C:\\Users\\Miju\\AppData\\Local', 'kiku-agent'));
18
+ });
19
+
20
+ test('doctor treats LM Studio and audio dependencies as optional', async () => {
21
+ const dataDir = await mkdtemp(path.join(os.tmpdir(), 'kiku-doctor-test-'));
22
+ try {
23
+ const report = await runDoctor({
24
+ client: { async checkConnection() { return { connected: false, models: [] }; } },
25
+ dataDir,
26
+ });
27
+ assert.equal(report.ok, true);
28
+ assert.equal(report.checks.find(check => check.id === 'lmstudio').required, false);
29
+ assert.equal(report.checks.find(check => check.id === 'ffmpeg').required, false);
30
+ assert.equal(report.checks.find(check => check.id === 'node').ok, true);
31
+ } finally {
32
+ await rm(dataDir, { recursive: true, force: true });
33
+ }
34
+ });
35
+
36
+ test('published production dependencies contain no native audio or Node 22 Ink stack', () => {
37
+ const here = path.dirname(fileURLToPath(import.meta.url));
38
+ const pkg = JSON.parse(readFileSync(path.resolve(here, '..', '..', '..', 'package.json'), 'utf8'));
39
+ for (const dependency of ['speaker', 'ffmpeg-static', 'fluent-ffmpeg', '@distube/ytdl-core', 'ink']) {
40
+ assert.equal(pkg.dependencies?.[dependency], undefined, `${dependency} must not be a production dependency`);
41
+ assert.equal(pkg.optionalDependencies?.[dependency], undefined, `${dependency} must not install by default as optional`);
42
+ }
43
+ assert.ok(pkg.devDependencies.ink, 'Ink may remain available for local UI development');
44
+ });