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.
- package/README.md +7 -7
- package/package.json +4 -7
- package/server/agent/agentCore.js +2 -2
- package/server/agent/catalogEntityResolver.js +8 -0
- package/server/agent/cli.js +4 -0
- package/server/agent/eval/AGENTS.md +228 -0
- package/server/agent/eval/analyze.py +684 -0
- package/server/agent/eval/gates.json +15 -0
- package/server/agent/eval/modules/analysis/compare.js +198 -0
- package/server/agent/eval/modules/analysis/gates.js +72 -0
- package/server/agent/eval/modules/analysis/judge.js +70 -0
- package/server/agent/eval/modules/core/cli.js +453 -0
- package/server/agent/eval/modules/core/config.js +60 -0
- package/server/agent/eval/modules/core/store.js +236 -0
- package/server/agent/eval/modules/evaluation/cases.js +689 -0
- package/server/agent/eval/modules/evaluation/runner.js +298 -0
- package/server/agent/eval/modules/evaluation/scoring.js +351 -0
- package/server/agent/eval/modules/evaluation/worker.js +122 -0
- package/server/agent/eval/modules/reporting/benchmark.js +265 -0
- package/server/agent/eval/modules/reporting/benchmarkPng.js +487 -0
- package/server/agent/eval/modules/reporting/dashboard.js +1406 -0
- package/server/agent/eval/modules/server/evalApi.js +1099 -0
- package/server/agent/eval/modules/ui/evalUi.js +4796 -0
- package/server/agent/eval/modules/utils/smokes.js +119 -0
- package/server/agent/ink/App.jsx +348 -0
- package/server/agent/ink/Panel.jsx +54 -0
- package/server/agent/ink/PromptInput.jsx +321 -0
- package/server/agent/ink/TaskPlanPanel.jsx +88 -0
- package/server/agent/ink/ThinkingPanel.jsx +68 -0
- package/server/agent/ink/ToolResult.jsx +133 -0
- package/server/agent/ink/main.jsx +195 -0
- package/server/agent/ink/useEventBus.js +218 -0
- package/server/agent/memory/contextAllocator.js +214 -0
- package/server/agent/memory/hierarchicalStore.js +523 -0
- package/server/agent/memory/reflectionWorker.js +110 -0
- package/server/agent/prompts.js +1 -1
- package/server/agent/telemetry/hardwareTiers.js +332 -0
- package/server/agent/telemetry/runtimeWatchdog.js +66 -0
- package/server/agent/telemetry/techPowerUpClient.js +682 -0
- package/server/agent/telemetry/techpowerup-mcp.json +22 -0
- package/server/agent/tests/agentBubbleSystem.test.js +65 -0
- package/server/agent/tests/agentFollowUps.test.js +127 -0
- package/server/agent/tests/apiRouter.test.js +108 -0
- package/server/agent/tests/architectureFixes.test.js +81 -0
- package/server/agent/tests/architectureRefactoring.test.js +194 -0
- package/server/agent/tests/autoResearch.test.js +64 -0
- package/server/agent/tests/bilibiliEmbed.test.js +251 -0
- package/server/agent/tests/catalogSync.test.js +130 -0
- package/server/agent/tests/citations.test.js +180 -0
- package/server/agent/tests/clarificationAnalytics.test.js +219 -0
- package/server/agent/tests/clarifyingQuestions.test.js +594 -0
- package/server/agent/tests/cliAutomation.test.js +121 -0
- package/server/agent/tests/cloudProviders.test.js +234 -0
- package/server/agent/tests/commandInput.test.js +24 -0
- package/server/agent/tests/commands.test.js +417 -0
- package/server/agent/tests/contextIntelligence.test.js +162 -0
- package/server/agent/tests/conversationSafety.test.js +53 -0
- package/server/agent/tests/decisionAndSearchEnhancements.test.js +135 -0
- package/server/agent/tests/dramaAnswerQuality.test.js +73 -0
- package/server/agent/tests/dynamicCatalogIngestion.test.js +285 -0
- package/server/agent/tests/dynamicRoutingAndSearch.test.js +92 -0
- package/server/agent/tests/edgeCasesScenarios.test.js +49 -0
- package/server/agent/tests/entertainmentMetrics.test.js +126 -0
- package/server/agent/tests/envBootstrap.test.js +87 -0
- package/server/agent/tests/envCommand.test.js +215 -0
- package/server/agent/tests/errorHandling.test.js +80 -0
- package/server/agent/tests/errors.test.js +13 -0
- package/server/agent/tests/evalAnalyze.test.js +54 -0
- package/server/agent/tests/evalCommand.test.js +54 -0
- package/server/agent/tests/evalCorpus.test.js +126 -0
- package/server/agent/tests/evalHarness.test.js +62 -0
- package/server/agent/tests/evalJudgeContext.test.js +38 -0
- package/server/agent/tests/evalMetrics.test.js +312 -0
- package/server/agent/tests/evalStore.test.js +190 -0
- package/server/agent/tests/evalStudio.test.js +179 -0
- package/server/agent/tests/feedbackLearning.test.js +186 -0
- package/server/agent/tests/firstRun.test.js +44 -0
- package/server/agent/tests/gemma4eOptimization.test.js +95 -0
- package/server/agent/tests/hardwareTiers.test.js +206 -0
- package/server/agent/tests/hybridWebSearch.test.js +192 -0
- package/server/agent/tests/judgeService.test.js +251 -0
- package/server/agent/tests/lanDiscovery.test.js +319 -0
- package/server/agent/tests/mcpClient.test.js +201 -0
- package/server/agent/tests/modelManager.test.js +109 -0
- package/server/agent/tests/multilingualScenarios.test.js +69 -0
- package/server/agent/tests/newCommands.test.js +347 -0
- package/server/agent/tests/newCommandsTui.test.js +141 -0
- package/server/agent/tests/newTools.test.js +113 -0
- package/server/agent/tests/newsIntelligence.test.js +178 -0
- package/server/agent/tests/nextGenArchitecture.test.js +99 -0
- package/server/agent/tests/offlineInput.test.js +217 -0
- package/server/agent/tests/packagingGuard.test.js +57 -0
- package/server/agent/tests/presentation.test.js +61 -0
- package/server/agent/tests/providerPersistence.test.js +39 -0
- package/server/agent/tests/queryBenchmarks.test.js +83 -0
- package/server/agent/tests/queryDetails.test.js +126 -0
- package/server/agent/tests/realWorldValidation.test.js +391 -0
- package/server/agent/tests/resilienceAndConcurrency.test.js +117 -0
- package/server/agent/tests/runtimeWatchdog.test.js +32 -0
- package/server/agent/tests/sanitizer.test.js +37 -0
- package/server/agent/tests/scenariosData.js +378 -0
- package/server/agent/tests/schemaRouting.test.js +54 -0
- package/server/agent/tests/securityAuditRemediation.test.js +214 -0
- package/server/agent/tests/securityHardening.test.js +146 -0
- package/server/agent/tests/securitySanitizer.test.js +38 -0
- package/server/agent/tests/sessionAndCopy.test.js +232 -0
- package/server/agent/tests/sidebarControls.test.js +101 -0
- package/server/agent/tests/statefulMemory.test.js +270 -0
- package/server/agent/tests/subagent.test.js +216 -0
- package/server/agent/tests/subagentProgress.test.js +109 -0
- package/server/agent/tests/subagentTextFallback.test.js +115 -0
- package/server/agent/tests/suggestions.test.js +199 -0
- package/server/agent/tests/suggestionsTui.test.js +251 -0
- package/server/agent/tests/telemetryAndProfiles.test.js +179 -0
- package/server/agent/tests/terminalImage.test.js +80 -0
- package/server/agent/tests/terminalMarkdown.test.js +166 -0
- package/server/agent/tests/terminalText.test.js +133 -0
- package/server/agent/tests/textProtocolLoop.test.js +126 -0
- package/server/agent/tests/textToolParsing.test.js +139 -0
- package/server/agent/tests/theme.test.js +26 -0
- package/server/agent/tests/toolRegistryParity.test.js +53 -0
- package/server/agent/tests/toolRoutingRegressions.test.js +119 -0
- package/server/agent/tests/tools.test.js +259 -0
- package/server/agent/tests/unifiedEndpoints.test.js +152 -0
- package/server/agent/tests/v110Features.test.js +262 -0
- package/server/agent/tests/versionBump.test.js +69 -0
- package/server/agent/tests/vramEstimator.test.js +520 -0
- package/server/agent/tests/webChatInspection.test.js +300 -0
- package/server/agent/tests/webChatMemory.test.js +112 -0
- package/server/agent/tests/webChatSecurity.test.js +169 -0
- package/server/agent/tests/webExtract.test.js +261 -0
- package/server/agent/tests/webSources.test.js +247 -0
- package/server/agent/websearch/adapters/duckduckgo.js +97 -0
- package/server/agent/websearch/adapters/jina.js +77 -0
- package/server/agent/websearch/adapters/searxng.js +57 -0
- package/server/agent/websearch/core/canonical.js +89 -0
- package/server/agent/websearch/core/fusion.js +81 -0
- package/server/agent/websearch/core/types.js +29 -0
- package/server/agent/websearch/extract.js +605 -0
- package/server/agent/websearch/index.js +43 -0
- package/server/agent/websearch/orchestrator.js +266 -0
- package/server/agent/websearch/resilience/breaker.js +97 -0
- package/server/agent/websearch/resilience/cache.js +59 -0
- package/server/agent/websearch/resilience/limiter.js +93 -0
- package/server/agent/websearch/security/filter.js +36 -0
- package/server/agent/websearch/security/ssrf.js +289 -0
- package/server/agent/websearch/telemetry.js +71 -0
- package/server/apiRouter.js +7 -11
- package/server/eval/judgeRubrics.js +110 -0
- package/server/eval/judgeService.js +290 -0
- package/server/eval/runBenchmark.js +182 -0
- package/server/mcp/examples/librechat.example.yaml +16 -0
- package/server/mcp/tests/connection.test.js +156 -0
- package/server/routes/agent.js +55 -0
- package/server/supabase_schema.sql +63 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import {
|
|
7
|
+
estimateTokens,
|
|
8
|
+
calculateTokenCost,
|
|
9
|
+
formatCostUsd,
|
|
10
|
+
formatTokenCount,
|
|
11
|
+
MODEL_PRICING
|
|
12
|
+
} from '../telemetry.js';
|
|
13
|
+
import {
|
|
14
|
+
BUILTIN_PROFILES,
|
|
15
|
+
loadProfiles,
|
|
16
|
+
getProfile,
|
|
17
|
+
getActiveProfile,
|
|
18
|
+
setActiveProfile,
|
|
19
|
+
createOrUpdateProfile,
|
|
20
|
+
deleteProfile,
|
|
21
|
+
listProfiles
|
|
22
|
+
} from '../profiles.js';
|
|
23
|
+
import { parseCliArgs } from '../cli.js';
|
|
24
|
+
import { routeCommand } from '../commands.js';
|
|
25
|
+
import { PACKAGE_VERSION } from '../version.js';
|
|
26
|
+
|
|
27
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
28
|
+
const __dirname = path.dirname(__filename);
|
|
29
|
+
const cliPath = path.resolve(__dirname, '../cli.js');
|
|
30
|
+
|
|
31
|
+
test('Telemetry: estimateTokens calculates token heuristics for various scripts', () => {
|
|
32
|
+
assert.equal(estimateTokens(''), 0);
|
|
33
|
+
assert.equal(estimateTokens(null), 0);
|
|
34
|
+
|
|
35
|
+
const enTokens = estimateTokens('Ju Jingyi is an accomplished artist and singer.');
|
|
36
|
+
assert.ok(enTokens >= 8 && enTokens <= 20, `enTokens ${enTokens} out of expected range`);
|
|
37
|
+
|
|
38
|
+
const zhTokens = estimateTokens('鞠婧祎中国女演员歌手');
|
|
39
|
+
assert.ok(zhTokens >= 10 && zhTokens <= 25, `zhTokens ${zhTokens} out of expected range`);
|
|
40
|
+
|
|
41
|
+
const thTokens = estimateTokens('จวีจิ้งอี นักแสดงและนักร้องสาว');
|
|
42
|
+
assert.ok(thTokens >= 15 && thTokens <= 40, `thTokens ${thTokens} out of expected range`);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('Telemetry: calculateTokenCost calculates pricing accurately across providers', () => {
|
|
46
|
+
const lmCost = calculateTokenCost('lmstudio', 'google/gemma-4-e4b', 1000, 500);
|
|
47
|
+
assert.equal(lmCost, 0);
|
|
48
|
+
|
|
49
|
+
const groqCost = calculateTokenCost('groq', 'openai/gpt-oss-20b', 1000, 1000);
|
|
50
|
+
assert.ok(groqCost > 0 && groqCost < 0.01, `Groq cost: ${groqCost}`);
|
|
51
|
+
|
|
52
|
+
const deepseekCost = calculateTokenCost('deepseek', 'deepseek-v4-flash', 10000, 5000);
|
|
53
|
+
assert.ok(deepseekCost > 0 && deepseekCost < 0.05, `DeepSeek cost: ${deepseekCost}`);
|
|
54
|
+
|
|
55
|
+
const openaiCost = calculateTokenCost('openai', 'gpt-5.6-luna', 1000, 1000);
|
|
56
|
+
assert.ok(openaiCost > 0, `OpenAI cost: ${openaiCost}`);
|
|
57
|
+
|
|
58
|
+
const geminiCost = calculateTokenCost('gemini', 'gemini-3.7-flash', 1000, 1000);
|
|
59
|
+
assert.ok(geminiCost > 0, `Gemini cost: ${geminiCost}`);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('Telemetry: formatCostUsd and formatTokenCount format numbers cleanly', () => {
|
|
63
|
+
assert.equal(formatCostUsd(0), '$0.00 (Local)');
|
|
64
|
+
assert.equal(formatCostUsd(0.00005), '<$0.0001');
|
|
65
|
+
assert.equal(formatCostUsd(0.0012), '$0.0012');
|
|
66
|
+
assert.equal(formatCostUsd(1.5), '$1.5000');
|
|
67
|
+
|
|
68
|
+
assert.equal(formatTokenCount(500), '500');
|
|
69
|
+
assert.equal(formatTokenCount(1500), '1,500');
|
|
70
|
+
assert.equal(formatTokenCount(2500000), '2,500,000');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('Profiles: BUILTIN_PROFILES has required production profiles', () => {
|
|
74
|
+
const expectedProfiles = ['default', 'fast', 'deep-reasoning', 'cloud', 'gemini'];
|
|
75
|
+
for (const name of expectedProfiles) {
|
|
76
|
+
assert.ok(BUILTIN_PROFILES[name], `Missing built-in profile: ${name}`);
|
|
77
|
+
assert.ok(BUILTIN_PROFILES[name].provider);
|
|
78
|
+
assert.ok(BUILTIN_PROFILES[name].model);
|
|
79
|
+
assert.ok(BUILTIN_PROFILES[name].description);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('Profiles: lifecycle methods support create, activate, query, and delete', () => {
|
|
84
|
+
const initialList = listProfiles();
|
|
85
|
+
assert.ok(initialList.length >= 5);
|
|
86
|
+
|
|
87
|
+
const active = getActiveProfile();
|
|
88
|
+
assert.ok(active && active.name);
|
|
89
|
+
|
|
90
|
+
const switched = setActiveProfile('fast');
|
|
91
|
+
assert.equal(switched.name, 'fast');
|
|
92
|
+
assert.equal(getActiveProfile().name, 'fast');
|
|
93
|
+
|
|
94
|
+
const custom = createOrUpdateProfile('unit-test-profile', {
|
|
95
|
+
provider: 'openai',
|
|
96
|
+
model: 'gpt-5.6-luna',
|
|
97
|
+
temperature: 0.5,
|
|
98
|
+
description: 'Temporary profile for unit tests'
|
|
99
|
+
});
|
|
100
|
+
assert.equal(custom.name, 'unit-test-profile');
|
|
101
|
+
assert.equal(custom.provider, 'openai');
|
|
102
|
+
assert.equal(custom.model, 'gpt-5.6-luna');
|
|
103
|
+
|
|
104
|
+
const retrieved = getProfile('unit-test-profile');
|
|
105
|
+
assert.equal(retrieved.name, 'unit-test-profile');
|
|
106
|
+
|
|
107
|
+
assert.throws(() => {
|
|
108
|
+
deleteProfile('default');
|
|
109
|
+
}, /Cannot delete built-in profile/);
|
|
110
|
+
|
|
111
|
+
const deleted = deleteProfile('unit-test-profile');
|
|
112
|
+
assert.equal(deleted, true);
|
|
113
|
+
assert.equal(getProfile('unit-test-profile'), null);
|
|
114
|
+
|
|
115
|
+
setActiveProfile('default');
|
|
116
|
+
assert.equal(getActiveProfile().name, 'default');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('CLI: parseCliArgs handles --profile flag', () => {
|
|
120
|
+
const opts1 = parseCliArgs(['--profile', 'fast']);
|
|
121
|
+
assert.equal(opts1.profile, 'fast');
|
|
122
|
+
|
|
123
|
+
const opts2 = parseCliArgs(['-q', 'what is in blossom', '--profile', 'deep-reasoning', '--output', 'json']);
|
|
124
|
+
assert.equal(opts2.profile, 'deep-reasoning');
|
|
125
|
+
assert.equal(opts2.output, 'json');
|
|
126
|
+
assert.equal(opts2.query, 'what is in blossom');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('CLI: headless JSON output contains token metrics and cost accounting', () => {
|
|
130
|
+
const res = spawnSync(process.execPath, [
|
|
131
|
+
cliPath,
|
|
132
|
+
'--url', 'http://127.0.0.1:9999/v1',
|
|
133
|
+
'-q', 'test query',
|
|
134
|
+
'--output', 'json',
|
|
135
|
+
'--profile', 'fast'
|
|
136
|
+
], { encoding: 'utf8' });
|
|
137
|
+
|
|
138
|
+
assert.equal(res.status, 2);
|
|
139
|
+
const payload = JSON.parse(res.stdout);
|
|
140
|
+
assert.equal(payload.version, PACKAGE_VERSION);
|
|
141
|
+
assert.equal(payload.status, 'error');
|
|
142
|
+
assert.ok(payload.metrics, 'Metrics object must be present');
|
|
143
|
+
assert.ok(payload.metrics.tokens !== undefined, 'Tokens object must be present in metrics');
|
|
144
|
+
assert.ok(typeof payload.metrics.costUsd === 'number', 'costUsd must be a number in metrics');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('Commands: /profile and /config execute cleanly in routeCommand', async () => {
|
|
148
|
+
const logs = [];
|
|
149
|
+
const fakeContext = {
|
|
150
|
+
sub: 'list',
|
|
151
|
+
extra: '',
|
|
152
|
+
parts: ['/profile', 'list'],
|
|
153
|
+
agent: null,
|
|
154
|
+
client: {
|
|
155
|
+
provider: 'lmstudio',
|
|
156
|
+
providerName: 'LM Studio (Local)',
|
|
157
|
+
model: 'google/gemma-4-e4b',
|
|
158
|
+
baseUrl: 'http://localhost:1234/v1'
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const oldLog = console.log;
|
|
163
|
+
try {
|
|
164
|
+
console.log = (msg) => logs.push(msg);
|
|
165
|
+
|
|
166
|
+
await routeCommand('/profile', fakeContext);
|
|
167
|
+
assert.ok(logs.length > 0);
|
|
168
|
+
|
|
169
|
+
logs.length = 0;
|
|
170
|
+
await routeCommand('/profile', { ...fakeContext, sub: 'show', extra: 'fast', parts: ['/profile', 'show', 'fast'] });
|
|
171
|
+
assert.ok(logs.length > 0);
|
|
172
|
+
|
|
173
|
+
logs.length = 0;
|
|
174
|
+
await routeCommand('/config', { ...fakeContext, sub: '', extra: '', parts: ['/config'] });
|
|
175
|
+
assert.ok(logs.length > 0);
|
|
176
|
+
} finally {
|
|
177
|
+
console.log = oldLog;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import {
|
|
6
|
+
createTerminalImageGallery,
|
|
7
|
+
renderJpegFile,
|
|
8
|
+
supportsTerminalImages,
|
|
9
|
+
} from '../terminalImage.js';
|
|
10
|
+
import { getStringDisplayWidth, stripAnsi } from '../ui.js';
|
|
11
|
+
|
|
12
|
+
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const imageDirectory = path.resolve(testDirectory, '../../../src/assets/images');
|
|
14
|
+
const squareImage = path.join(imageDirectory, '0040jbadly8icq3if7twwj60qy0qy0uc02.jpg');
|
|
15
|
+
|
|
16
|
+
test('renders a JPEG as bounded true-color quadrant rows', () => {
|
|
17
|
+
const lines = renderJpegFile(squareImage, { columns: 30, rows: 20, maxWidth: 20, maxRows: 6 });
|
|
18
|
+
|
|
19
|
+
assert.equal(lines.length, 6);
|
|
20
|
+
assert.match(lines[0], /\x1b\[38;2;\d+;\d+;\d+m/);
|
|
21
|
+
assert.match(lines[0], /\x1b\[48;2;\d+;\d+;\d+m/);
|
|
22
|
+
assert.match(stripAnsi(lines[0]), /^ [ ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█]+$/);
|
|
23
|
+
assert.ok(getStringDisplayWidth(lines[0]) <= 22);
|
|
24
|
+
assert.match(stripAnsi(lines.join('')), /[▘▝▖▗▚▞▛▜▙▟]/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('default gallery rendering uses most of a standard TUI viewport', () => {
|
|
28
|
+
const lines = renderJpegFile(squareImage, { columns: 80, rows: 24 });
|
|
29
|
+
|
|
30
|
+
assert.equal(lines.length, 17);
|
|
31
|
+
assert.equal(getStringDisplayWidth(lines[0]), 36);
|
|
32
|
+
assert.ok(new Set(lines).size > 12, 'photo detail should survive downsampling');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('terminal image detection respects TTY, NO_COLOR, and explicit overrides', () => {
|
|
36
|
+
assert.equal(supportsTerminalImages({ env: {}, isTTY: true }), true);
|
|
37
|
+
assert.equal(supportsTerminalImages({ env: {}, isTTY: false }), false);
|
|
38
|
+
assert.equal(supportsTerminalImages({ env: { NO_COLOR: '1' }, isTTY: true }), false);
|
|
39
|
+
assert.equal(supportsTerminalImages({ env: { KIKU_TUI_IMAGES: 'on' }, isTTY: false }), true);
|
|
40
|
+
assert.equal(supportsTerminalImages({ env: { KIKU_TUI_IMAGES: 'off' }, isTTY: true }), false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('gallery renders on its cadence, supports manual display, and cycles assets', () => {
|
|
44
|
+
const gallery = createTerminalImageGallery({
|
|
45
|
+
directory: imageDirectory,
|
|
46
|
+
frequency: 3,
|
|
47
|
+
env: { KIKU_TUI_IMAGES: 'on' },
|
|
48
|
+
isTTY: false,
|
|
49
|
+
getColumns: () => 40,
|
|
50
|
+
getRows: () => 24,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
assert.equal(gallery.enabled, true);
|
|
54
|
+
assert.equal(gallery.imageCount, 8);
|
|
55
|
+
assert.deepEqual(gallery.maybeRender(1), []);
|
|
56
|
+
assert.match(stripAnsi(gallery.maybeRender(3)[1]), /Gallery 1\/8/);
|
|
57
|
+
assert.match(stripAnsi(gallery.maybeRender(4, { force: true })[1]), /Gallery 2\/8/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('renderTwoImagesSideBySide and getWelcomeGalleryLines render 2 photos', async () => {
|
|
61
|
+
const { renderTwoImagesSideBySide, getWelcomeGalleryLines, discoverImages } = await import('../terminalImage.js');
|
|
62
|
+
const images = discoverImages(imageDirectory);
|
|
63
|
+
assert.ok(images.length >= 2);
|
|
64
|
+
|
|
65
|
+
const pair = renderTwoImagesSideBySide(images[0], images[1], { columns: 100, rows: 24, maxRows: 10 });
|
|
66
|
+
assert.ok(pair.length > 0);
|
|
67
|
+
assert.ok(pair.every(line => typeof line === 'string'));
|
|
68
|
+
|
|
69
|
+
const welcomeGallery = getWelcomeGalleryLines(2, {
|
|
70
|
+
directory: imageDirectory,
|
|
71
|
+
env: { KIKU_TUI_IMAGES: 'on' },
|
|
72
|
+
isTTY: false,
|
|
73
|
+
columns: 100,
|
|
74
|
+
rows: 30,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
assert.ok(welcomeGallery.length > 0);
|
|
78
|
+
assert.match(stripAnsi(welcomeGallery[1]), /JU JINGYI • 鞠婧祎 GALLERY/);
|
|
79
|
+
});
|
|
80
|
+
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
expandMarkdownLinks,
|
|
6
|
+
formatMarkdownLines,
|
|
7
|
+
formatSolidPanelLines,
|
|
8
|
+
isMarkdownTableSeparator,
|
|
9
|
+
parseMarkdownTableRow,
|
|
10
|
+
} from '../ui.js';
|
|
11
|
+
|
|
12
|
+
test('parses Markdown table rows without retaining outer pipes', () => {
|
|
13
|
+
assert.deepEqual(
|
|
14
|
+
parseMarkdownTableRow('| Brand | Role & Highlight | Media Link |'),
|
|
15
|
+
['Brand', 'Role & Highlight', 'Media Link']
|
|
16
|
+
);
|
|
17
|
+
assert.deepEqual(parseMarkdownTableRow('Brand | Category'), ['Brand', 'Category']);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('recognizes Markdown table alignment separators', () => {
|
|
21
|
+
assert.equal(isMarkdownTableSeparator([':---', '---:', ':---:']), true);
|
|
22
|
+
assert.equal(isMarkdownTableSeparator(['Brand', 'Category']), false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('expands Markdown links before terminal line wrapping', () => {
|
|
26
|
+
assert.equal(
|
|
27
|
+
expandMarkdownLinks('[Search Commercial](https://example.com/a-very-long-link)'),
|
|
28
|
+
'Search Commercial: https://example.com/a-very-long-link'
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('formatMarkdownLines converts markdown headers, bold, and tables to formatted terminal rows', () => {
|
|
33
|
+
const sample = `### Evidence Report: Ju Jingyi Brand Endorsements
|
|
34
|
+
|
|
35
|
+
**Summary:**
|
|
36
|
+
Ju Jingyi has secured multiple ambassador roles.
|
|
37
|
+
|
|
38
|
+
| Brand Name | Category | Role/Title |
|
|
39
|
+
| :--- | :--- | :--- |
|
|
40
|
+
| **PRSR** | Luxury Eyewear | Global Ambassador |
|
|
41
|
+
| **Lafang** | Haircare | Ambassador |`;
|
|
42
|
+
|
|
43
|
+
const rows = formatMarkdownLines(sample, 80);
|
|
44
|
+
assert.ok(rows.length > 0);
|
|
45
|
+
assert.ok(rows.some(r => r.includes('Evidence Report')));
|
|
46
|
+
assert.ok(rows.some(r => r.includes('┌') && r.includes('┐')));
|
|
47
|
+
assert.ok(rows.some(r => r.includes('PRSR')));
|
|
48
|
+
assert.ok(rows.some(r => r.includes('└') && r.includes('┘')));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('formatSolidPanelLines preserves ANSI-colored box table lines without destroying structure', () => {
|
|
52
|
+
const tableSample = `| Year | Award Title | Ceremony |
|
|
53
|
+
| :--- | :--- | :--- |
|
|
54
|
+
| 2024 | Popular Actress | Weibo TV |
|
|
55
|
+
| 2021 | Outstanding Actress | Tencent Video |`;
|
|
56
|
+
|
|
57
|
+
const tableRows = formatMarkdownLines(tableSample, 80);
|
|
58
|
+
const panelRows = formatSolidPanelLines({
|
|
59
|
+
title: 'Awards Panel',
|
|
60
|
+
lines: tableRows,
|
|
61
|
+
width: 80,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
assert.ok(panelRows.length > 0);
|
|
65
|
+
assert.ok(panelRows.some(r => r.includes('┌') && r.includes('┐')));
|
|
66
|
+
assert.ok(panelRows.some(r => r.includes('Popular Actress')));
|
|
67
|
+
assert.ok(panelRows.some(r => r.includes('Outstanding Actress')));
|
|
68
|
+
assert.ok(panelRows.some(r => r.includes('└') && r.includes('┘')));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('formatMarkdownLines handles markdown tables with URLs without column breakage', () => {
|
|
72
|
+
const tableWithUrls = `| Detail | Finding | Source URLs |
|
|
73
|
+
| :--- | :--- | :--- |
|
|
74
|
+
| SNH48 Debut | Joined SNH48 Team NII in November 2013 | [https://snh48.fandom.com/wiki/Ju_Jingyi](https://snh48.fandom.com/wiki/Ju_Jingyi) |
|
|
75
|
+
| Solo Debut | Established studio in December 2017 | [https://baike.baidu.com/item/Ju_Jingyi](https://baike.baidu.com/item/Ju_Jingyi) |`;
|
|
76
|
+
|
|
77
|
+
const rows = formatMarkdownLines(tableWithUrls, 120);
|
|
78
|
+
assert.ok(rows.length > 0);
|
|
79
|
+
|
|
80
|
+
assert.ok(rows.some(r => r.includes('┌') && r.includes('┐')));
|
|
81
|
+
assert.ok(rows.some(r => r.includes('SNH48 Debut')));
|
|
82
|
+
assert.ok(rows.some(r => r.includes('Solo Debut')));
|
|
83
|
+
assert.ok(rows.some(r => r.includes('└') && r.includes('┘')));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('formatMarkdownLines handles markdown tables with short separators and unequal columns', () => {
|
|
87
|
+
const unevenTable = `| Title | Year | Category |
|
|
88
|
+
| -- | :-: | -- |
|
|
89
|
+
| In Blossom | 2024 | Drama |
|
|
90
|
+
| Everyday | 2016 |
|
|
91
|
+
| Rain Love | 2024 | Single | Extra Info |`;
|
|
92
|
+
|
|
93
|
+
const rows = formatMarkdownLines(unevenTable, 100);
|
|
94
|
+
assert.ok(rows.length > 0);
|
|
95
|
+
assert.ok(rows.some(r => r.includes('┌') && r.includes('┐')));
|
|
96
|
+
assert.ok(rows.some(r => r.includes('In Blossom')));
|
|
97
|
+
assert.ok(rows.some(r => r.includes('Everyday')));
|
|
98
|
+
assert.ok(rows.some(r => r.includes('Rain Love')));
|
|
99
|
+
assert.ok(rows.some(r => r.includes('└') && r.includes('┘')));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('formatTerminalMarkdown formats headings h1-h5 and numbered list items', () => {
|
|
103
|
+
const markdownText = `#### Subheading 4
|
|
104
|
+
##### Subheading 5
|
|
105
|
+
1. First point
|
|
106
|
+
2. Second point`;
|
|
107
|
+
|
|
108
|
+
const rows = formatMarkdownLines(markdownText, 80);
|
|
109
|
+
assert.ok(rows.some(r => r.includes('Subheading 4')));
|
|
110
|
+
assert.ok(rows.some(r => r.includes('Subheading 5')));
|
|
111
|
+
assert.ok(rows.some(r => r.includes('1.') && r.includes('First point')));
|
|
112
|
+
assert.ok(rows.some(r => r.includes('2.') && r.includes('Second point')));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('formatMarkdownLines wraps long cell contents across multiple lines without truncating with ellipsis', () => {
|
|
116
|
+
const table = `| Detail | Finding | Source URL(s) | Uncertainty |
|
|
117
|
+
| :--- | :--- | :--- | :--- |
|
|
118
|
+
| SNH48 General Election Success | Elected No.4 (2014), No.2 (2015), No.1 (2016), No.1 (2017) | [Wikipedia](https://en.wikipedia.org/wiki/Ju_Jingyi) | Low |
|
|
119
|
+
| Solo Transition | Established personal studio on December 15, 2017 | [carrd.co](https://ju-jingyi.carrd.co/) | Low |`;
|
|
120
|
+
|
|
121
|
+
const rows = formatMarkdownLines(table, 90);
|
|
122
|
+
assert.ok(rows.length > 0);
|
|
123
|
+
|
|
124
|
+
const tableBorderRows = rows.filter(r => r.includes('│'));
|
|
125
|
+
assert.ok(tableBorderRows.length > 0);
|
|
126
|
+
assert.ok(!tableBorderRows.some(r => r.includes('…')));
|
|
127
|
+
|
|
128
|
+
assert.ok(rows.some(r => r.includes('Uncertainty')));
|
|
129
|
+
|
|
130
|
+
assert.ok(rows.some(r => r.includes('Elected No.4')));
|
|
131
|
+
assert.ok(rows.some(r => r.includes('(2017)')));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('cleanTableCell and formatMarkdownLines handle <br> tags cleanly without raw HTML tags', () => {
|
|
135
|
+
const tableWithBr = `| Service | Links |
|
|
136
|
+
| :--- | :--- |
|
|
137
|
+
| Portals | [carrd.co](https://ju-jingyi.carrd.co/)<br>[Wikipedia](https://en.wikipedia.org/wiki/Ju_Jingyi) |`;
|
|
138
|
+
|
|
139
|
+
const rows = formatMarkdownLines(tableWithBr, 80);
|
|
140
|
+
assert.ok(rows.length > 0);
|
|
141
|
+
assert.ok(!rows.some(r => r.includes('<br')));
|
|
142
|
+
assert.ok(!rows.some(r => r.includes('<b…')));
|
|
143
|
+
assert.ok(rows.some(r => r.includes('carrd.co')));
|
|
144
|
+
assert.ok(rows.some(r => r.includes('Wikipedia')));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('formatMarkdownLines does not corrupt bullet points with pipes into artificial tables', () => {
|
|
148
|
+
const markdownText = [
|
|
149
|
+
'Sources:',
|
|
150
|
+
' • DuckDuckGo - Ju Jingyi Drama Tops Charts: https://www.youtube.com/watch?v=lkFoSXBARkQ',
|
|
151
|
+
'* DuckDuckGo - Best Chinese Dramas of 2026 So Far | Updated Rankings: https://mijudrama.com/blog/best-chinese-dramas-2026-updated-rankings',
|
|
152
|
+
' • DuckDuckGo - Ju Jing Yi - DramaWiki: https://wiki.d-addicts.com/Ju_Jing_Yi',
|
|
153
|
+
].join('\n');
|
|
154
|
+
|
|
155
|
+
const rows = formatMarkdownLines(markdownText, 100);
|
|
156
|
+
assert.equal(rows.some(r => r.startsWith('|') && r.endsWith('|')), false, 'Bullet with pipe should not become a table');
|
|
157
|
+
assert.ok(rows.some(r => r.includes('Best Chinese Dramas of 2026 So Far | Updated Rankings')));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('formatTerminalMarkdown divider lines do not exceed maxWidth', () => {
|
|
161
|
+
const rows = formatMarkdownLines('---\n***\n___', 80);
|
|
162
|
+
for (const r of rows) {
|
|
163
|
+
const plain = r.replace(/\x1b\[[0-9;]*m/g, '');
|
|
164
|
+
assert.ok(plain.length <= 80, `Divider line should not exceed maxWidth (got ${plain.length})`);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
getGraphemeSegments,
|
|
6
|
+
getStringDisplayWidth,
|
|
7
|
+
getNewOutputScrollOffset,
|
|
8
|
+
fitStyledText,
|
|
9
|
+
getWelcomeHeroLines,
|
|
10
|
+
getTuiViewportLayout,
|
|
11
|
+
getMultilinePromptLayout,
|
|
12
|
+
nextGraphemeBoundary,
|
|
13
|
+
previousGraphemeBoundary,
|
|
14
|
+
sanitizeTerminalText,
|
|
15
|
+
sliceLineForDisplay,
|
|
16
|
+
} from '../ui.js';
|
|
17
|
+
|
|
18
|
+
test('TUI viewport never overlaps the status bar', () => {
|
|
19
|
+
for (const rows of [10, 24, 30, 60]) {
|
|
20
|
+
const layout = getTuiViewportLayout(rows);
|
|
21
|
+
const viewportLastRow = layout.viewportStartRow + layout.viewportHeight - 1;
|
|
22
|
+
|
|
23
|
+
assert.equal(viewportLastRow, layout.statusBarRow - 1);
|
|
24
|
+
assert.ok(viewportLastRow < layout.statusBarRow);
|
|
25
|
+
assert.ok(layout.statusBarRow < layout.promptRow);
|
|
26
|
+
assert.ok(layout.promptRow < layout.separatorRow);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('long output opens at its first page and preserves manual scroll position', () => {
|
|
31
|
+
const viewportHeight = getTuiViewportLayout(24).viewportHeight;
|
|
32
|
+
assert.equal(getNewOutputScrollOffset(viewportHeight - 1, 24, 0), 0);
|
|
33
|
+
assert.equal(getNewOutputScrollOffset(viewportHeight + 7, 24, 0), 7);
|
|
34
|
+
assert.equal(getNewOutputScrollOffset(5, 24, 9), 14);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('welcome hero keeps the logo without the removed marketing copy', () => {
|
|
38
|
+
const hero = getWelcomeHeroLines().join('\n');
|
|
39
|
+
|
|
40
|
+
assert.match(hero, /██╗/);
|
|
41
|
+
assert.doesNotMatch(hero, /Kiku AI Terminal Agent/);
|
|
42
|
+
assert.doesNotMatch(hero, /Intelligent Fan Portal/);
|
|
43
|
+
assert.doesNotMatch(hero, /SSOT • DAG • Dynamic Tools/);
|
|
44
|
+
assert.doesNotMatch(hero, /LM Studio \/ Cloud Providers/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('grapheme boundaries keep emoji and Thai combining marks intact', () => {
|
|
48
|
+
const value = 'A👩🏽💻ก้B';
|
|
49
|
+
const segments = getGraphemeSegments(value);
|
|
50
|
+
|
|
51
|
+
assert.deepEqual(segments.map(item => item.segment), ['A', '👩🏽💻', 'ก้', 'B']);
|
|
52
|
+
assert.equal(nextGraphemeBoundary(value, 1), 8);
|
|
53
|
+
assert.equal(previousGraphemeBoundary(value, 8), 1);
|
|
54
|
+
assert.equal(nextGraphemeBoundary(value, 8), 10);
|
|
55
|
+
assert.equal(previousGraphemeBoundary(value, 10), 8);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('untrusted terminal text cannot inject ANSI, OSC, or control sequences', () => {
|
|
59
|
+
const malicious = '\x1b[31mred\x1b[0m\x1b]0;owned\x07\nnext\titem';
|
|
60
|
+
const sanitized = sanitizeTerminalText(malicious);
|
|
61
|
+
assert.equal(sanitized, 'red next item');
|
|
62
|
+
assert.equal(sanitized.includes('\x1b'), false);
|
|
63
|
+
assert.equal(sanitized.includes('\x07'), false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('gray surface rows fit narrow terminals without breaking styled graphemes', () => {
|
|
67
|
+
const fitted = fitStyledText('\x1b[1mคำสั่ง 👩🏽💻 autocomplete\x1b[0m', 10);
|
|
68
|
+
|
|
69
|
+
assert.ok(getStringDisplayWidth(fitted) <= 10);
|
|
70
|
+
assert.ok(!fitted.includes('👩') || fitted.includes('👩🏽💻'));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('display width treats joined emoji and Thai marks as terminal graphemes', () => {
|
|
74
|
+
assert.equal(getStringDisplayWidth('👩🏽💻'), 2);
|
|
75
|
+
assert.equal(getStringDisplayWidth('ก้'), 1);
|
|
76
|
+
assert.equal(getStringDisplayWidth('你好'), 4);
|
|
77
|
+
assert.equal(getStringDisplayWidth('《鞠婧祎》'), 10);
|
|
78
|
+
assert.equal(getStringDisplayWidth('(年度最佳)'), 12);
|
|
79
|
+
assert.equal(getStringDisplayWidth('【花间令】'), 10);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('long-line viewport stays within width without splitting graphemes', () => {
|
|
83
|
+
const value = 'aaaaa👩🏽💻ก้bbbbbbbbbbbb';
|
|
84
|
+
const cursor = value.indexOf('ก');
|
|
85
|
+
const viewport = sliceLineForDisplay(value, cursor, 10);
|
|
86
|
+
|
|
87
|
+
assert.ok(getStringDisplayWidth(viewport.displayLine) <= 10);
|
|
88
|
+
assert.ok(!viewport.displayLine.includes('👩') || viewport.displayLine.includes('👩🏽💻'));
|
|
89
|
+
assert.ok(!viewport.displayLine.startsWith('้'));
|
|
90
|
+
assert.ok(viewport.cursorVisualOffset >= 0);
|
|
91
|
+
assert.ok(viewport.cursorVisualOffset <= 10);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('getMultilinePromptLayout wraps long text and positions cursor accurately', () => {
|
|
95
|
+
const single = getMultilinePromptLayout('hello world', 5, 20, 5);
|
|
96
|
+
assert.equal(single.visibleLines.length, 1);
|
|
97
|
+
assert.equal(single.visibleLines[0], 'hello world');
|
|
98
|
+
assert.equal(single.cursorRow, 0);
|
|
99
|
+
assert.equal(single.cursorCol, 5);
|
|
100
|
+
|
|
101
|
+
const multi = getMultilinePromptLayout('line 1\nline 2\nline 3', 10, 20, 5);
|
|
102
|
+
assert.equal(multi.visibleLines.length, 3);
|
|
103
|
+
assert.equal(multi.visibleLines[0], 'line 1');
|
|
104
|
+
assert.equal(multi.visibleLines[1], 'line 2');
|
|
105
|
+
assert.equal(multi.visibleLines[2], 'line 3');
|
|
106
|
+
assert.equal(multi.cursorRow, 1);
|
|
107
|
+
assert.equal(multi.cursorCol, 3);
|
|
108
|
+
|
|
109
|
+
const longText = 'Ju Jingyi 👩🏽💻 ก้ ' + 'a'.repeat(50);
|
|
110
|
+
const wrapped = getMultilinePromptLayout(longText, longText.length, 25, 4);
|
|
111
|
+
assert.ok(wrapped.visibleLines.length > 1);
|
|
112
|
+
assert.ok(wrapped.visibleLines.length <= 4);
|
|
113
|
+
for (const line of wrapped.visibleLines) {
|
|
114
|
+
assert.ok(getStringDisplayWidth(line) <= 25);
|
|
115
|
+
}
|
|
116
|
+
assert.ok(wrapped.cursorRow >= 0 && wrapped.cursorRow < wrapped.visibleLines.length);
|
|
117
|
+
assert.ok(wrapped.cursorCol >= 0 && wrapped.cursorCol <= 25);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('getTuiViewportLayout dynamically expands prompt rows without overlapping', () => {
|
|
121
|
+
for (const promptRows of [1, 2, 3, 5]) {
|
|
122
|
+
const layout = getTuiViewportLayout(24, promptRows);
|
|
123
|
+
const viewportLastRow = layout.viewportStartRow + layout.viewportHeight - 1;
|
|
124
|
+
|
|
125
|
+
assert.equal(viewportLastRow, layout.statusBarRow - 1);
|
|
126
|
+
assert.ok(viewportLastRow < layout.statusBarRow);
|
|
127
|
+
assert.ok(layout.statusBarRow < layout.promptStartRow);
|
|
128
|
+
assert.equal(layout.promptHeight, promptRows);
|
|
129
|
+
assert.equal(layout.promptRow, layout.promptStartRow);
|
|
130
|
+
assert.ok(layout.promptEndRow < 24);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
6
|
+
|
|
7
|
+
import { KikuAgent } from '../agentCore.js';
|
|
8
|
+
import { McpClientManager } from '../mcpClient.js';
|
|
9
|
+
|
|
10
|
+
function createMemoryStub() {
|
|
11
|
+
return {
|
|
12
|
+
_loaded: true,
|
|
13
|
+
facts: [],
|
|
14
|
+
async load() { },
|
|
15
|
+
trackTopicInterest() { },
|
|
16
|
+
buildMemoryContext() { return ''; },
|
|
17
|
+
buildUserSkillContext() { return ''; },
|
|
18
|
+
addFact() { },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('text-emitted tool calls synthesize protocol-valid tool_calls in history', async () => {
|
|
23
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'kiku-textproto-'));
|
|
24
|
+
const storageFile = path.join(tempDir, 'session.json');
|
|
25
|
+
const observedMessages = [];
|
|
26
|
+
let call = 0;
|
|
27
|
+
const client = {
|
|
28
|
+
provider: 'lmstudio',
|
|
29
|
+
providerName: 'LM Studio',
|
|
30
|
+
model: 'test-model',
|
|
31
|
+
async chat(options) {
|
|
32
|
+
call++;
|
|
33
|
+
observedMessages.push(options.messages.map(message => ({
|
|
34
|
+
role: message.role,
|
|
35
|
+
content: typeof message.content === 'string' ? message.content : null,
|
|
36
|
+
tool_calls: message.tool_calls || undefined,
|
|
37
|
+
tool_call_id: message.tool_call_id || undefined,
|
|
38
|
+
})));
|
|
39
|
+
if (call === 1) {
|
|
40
|
+
return {
|
|
41
|
+
role: 'assistant',
|
|
42
|
+
content: 'Checking the catalog.\n```json\n{"action": "get_quotes", "parameters": {"tag": "mindset"}}\n```',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return { role: 'assistant', content: 'คำคมที่ยืนยันแล้วจากคลังข้อมูลคือ...' };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const agent = new KikuAgent({
|
|
50
|
+
client,
|
|
51
|
+
memory: createMemoryStub(),
|
|
52
|
+
mcpManager: new McpClientManager({
|
|
53
|
+
autoLoad: false,
|
|
54
|
+
globalConfigPath: path.join(tempDir, 'mcp.json'),
|
|
55
|
+
}),
|
|
56
|
+
sessionId: 'textproto_test',
|
|
57
|
+
sessionStorageFile: storageFile,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
await agent.ready();
|
|
62
|
+
const answer = await agent.run('ขอคำคมของจวีจิ้งอีหน่อย');
|
|
63
|
+
|
|
64
|
+
assert.match(answer, /คำคมที่ยืนยันแล้ว/);
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
const secondCall = observedMessages[1];
|
|
68
|
+
const assistantWithCalls = secondCall.find(m => m.role === 'assistant' && m.tool_calls?.length > 0);
|
|
69
|
+
assert.ok(assistantWithCalls, 'assistant message with synthesized tool_calls missing');
|
|
70
|
+
assert.equal(assistantWithCalls.tool_calls[0].function.name, 'get_quotes');
|
|
71
|
+
|
|
72
|
+
const toolMessage = secondCall.find(m => m.role === 'tool');
|
|
73
|
+
assert.ok(toolMessage, 'tool result message missing');
|
|
74
|
+
assert.equal(toolMessage.tool_call_id, assistantWithCalls.tool_calls[0].id);
|
|
75
|
+
assert.match(toolMessage.content, /authoritative/);
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
assert.ok(!assistantWithCalls.content.includes('```'));
|
|
79
|
+
} finally {
|
|
80
|
+
await agent.mcpManager.disconnectAll().catch(() => { });
|
|
81
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('systemDirectives callbacks are injected as system messages without touching the user query', async () => {
|
|
86
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'kiku-directives-'));
|
|
87
|
+
const observedMessages = [];
|
|
88
|
+
const client = {
|
|
89
|
+
provider: 'lmstudio',
|
|
90
|
+
providerName: 'LM Studio',
|
|
91
|
+
model: 'test-model',
|
|
92
|
+
async chat(options) {
|
|
93
|
+
observedMessages.push(options.messages.map(message => ({ role: message.role, content: message.content })));
|
|
94
|
+
return { role: 'assistant', content: 'ตอบแล้วค่ะ' };
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const agent = new KikuAgent({
|
|
99
|
+
client,
|
|
100
|
+
memory: createMemoryStub(),
|
|
101
|
+
mcpManager: new McpClientManager({
|
|
102
|
+
autoLoad: false,
|
|
103
|
+
globalConfigPath: path.join(tempDir, 'mcp.json'),
|
|
104
|
+
}),
|
|
105
|
+
sessionId: 'directives_test',
|
|
106
|
+
sessionStorageFile: path.join(tempDir, 'session.json'),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
await agent.ready();
|
|
111
|
+
await agent.run('สวัสดีค่ะ', {
|
|
112
|
+
systemDirectives: ['[SYSTEM DIRECTIVE (Test): stay concise]'],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const lastCall = observedMessages.at(-1);
|
|
116
|
+
const directiveMessage = lastCall.find(m => m.role === 'system' && m.content.includes('[SYSTEM DIRECTIVE (Test): stay concise]'));
|
|
117
|
+
assert.ok(directiveMessage, 'directive must be injected as a system message');
|
|
118
|
+
|
|
119
|
+
const userMessage = lastCall.find(m => m.role === 'user');
|
|
120
|
+
assert.ok(userMessage);
|
|
121
|
+
assert.ok(!userMessage.content.includes('[SYSTEM DIRECTIVE (Test)'), 'user query must stay clean');
|
|
122
|
+
} finally {
|
|
123
|
+
await agent.mcpManager.disconnectAll().catch(() => { });
|
|
124
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
});
|