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