kiku-agent 1.3.1 → 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 +1 -1
- package/package.json +4 -12
- 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/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/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/supabase_schema.sql +63 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { extractTopLevelJsonObjects } from '../agentCore.js';
|
|
5
|
+
import { KikuAgent } from '../agentCore.js';
|
|
6
|
+
import { LLMClient } from '../llmClient.js';
|
|
7
|
+
import { executeTool } from '../tools.js';
|
|
8
|
+
|
|
9
|
+
function bareAgent() {
|
|
10
|
+
const agent = Object.create(KikuAgent.prototype);
|
|
11
|
+
agent.mcpManager = null;
|
|
12
|
+
agent.allowedToolNames = null;
|
|
13
|
+
return agent;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('extractTopLevelJsonObjects handles nested objects and braces inside strings', () => {
|
|
17
|
+
const { objects, unbalanced } = extractTopLevelJsonObjects(
|
|
18
|
+
'prefix {"action": "web_search", "parameters": {"query": "In Blossom {花间令} 2024"}} suffix'
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
assert.equal(unbalanced, null);
|
|
22
|
+
assert.equal(objects.length, 1);
|
|
23
|
+
const parsed = JSON.parse(objects[0].raw);
|
|
24
|
+
assert.equal(parsed.action, 'web_search');
|
|
25
|
+
assert.equal(parsed.parameters.query, 'In Blossom {花间令} 2024');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('extractTopLevelJsonObjects reports unterminated objects instead of truncating them', () => {
|
|
29
|
+
const { objects, unbalanced } = extractTopLevelJsonObjects('{"action": "web_search", "parameters": {"query": "truncated"');
|
|
30
|
+
|
|
31
|
+
assert.equal(objects.length, 0);
|
|
32
|
+
assert.ok(unbalanced.startsWith('{"action"'));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('extractTopLevelJsonObjects finds multiple sibling objects', () => {
|
|
36
|
+
const { objects } = extractTopLevelJsonObjects('{"a": {"b": 1}} gap {"c": 2}');
|
|
37
|
+
|
|
38
|
+
assert.equal(objects.length, 2);
|
|
39
|
+
assert.deepEqual(JSON.parse(objects[1].raw), { c: 2 });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('text tool call parsing accepts nested JSON in fenced blocks (previously cut in half)', () => {
|
|
43
|
+
const agent = bareAgent();
|
|
44
|
+
const content = [
|
|
45
|
+
'Let me check the catalog.',
|
|
46
|
+
'```json',
|
|
47
|
+
'{"action": "search_discography", "parameters": {"query": "FIND THE WAY", "type": "single"}}',
|
|
48
|
+
'```',
|
|
49
|
+
].join('\n');
|
|
50
|
+
|
|
51
|
+
const { calls, malformed } = agent._extractTextToolCalls(content);
|
|
52
|
+
assert.equal(malformed, 0);
|
|
53
|
+
assert.equal(calls.length, 1);
|
|
54
|
+
assert.equal(calls[0].function.name, 'search_discography');
|
|
55
|
+
assert.deepEqual(JSON.parse(calls[0].function.arguments), { query: 'FIND THE WAY', type: 'single' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('text tool call parsing still understands ReAct Action blocks with nested input', () => {
|
|
59
|
+
const agent = bareAgent();
|
|
60
|
+
const content = 'Action: get_work_detail\nAction Input: {"workIdOrTitle": "in-blossom", "options": {"withOst": true}}';
|
|
61
|
+
|
|
62
|
+
const { calls, malformed } = agent._extractTextToolCalls(content);
|
|
63
|
+
assert.equal(malformed, 0);
|
|
64
|
+
assert.equal(calls.length, 1);
|
|
65
|
+
assert.equal(calls[0].function.name, 'get_work_detail');
|
|
66
|
+
const args = JSON.parse(calls[0].function.arguments);
|
|
67
|
+
assert.equal(args.workIdOrTitle, 'in-blossom');
|
|
68
|
+
assert.equal(args.options.withOst, true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('non-JSON Action Input is salvaged as a query argument, not marked malformed', () => {
|
|
72
|
+
const agent = bareAgent();
|
|
73
|
+
const { calls, malformed } = agent._extractTextToolCalls('Action: web_search\nAction Input: Ju Jingyi 2026 drama');
|
|
74
|
+
|
|
75
|
+
assert.equal(malformed, 0);
|
|
76
|
+
assert.equal(calls.length, 1);
|
|
77
|
+
assert.deepEqual(JSON.parse(calls[0].function.arguments), { query: 'Ju Jingyi 2026 drama' });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('malformed tool-call blocks are counted so the loop can feed back instead of answering', () => {
|
|
81
|
+
const agent = bareAgent();
|
|
82
|
+
|
|
83
|
+
const truncated = agent._extractTextToolCalls(
|
|
84
|
+
'```json\n{"action": "web_search", "parameters": {"query": "unterminated\n```'
|
|
85
|
+
);
|
|
86
|
+
assert.equal(truncated.calls.length, 0);
|
|
87
|
+
assert.ok(truncated.malformed >= 1);
|
|
88
|
+
|
|
89
|
+
const trailingComma = agent._extractTextToolCalls(
|
|
90
|
+
'```json\n{"action": "web_search", "parameters": {"query": "x",}}\n```'
|
|
91
|
+
);
|
|
92
|
+
assert.equal(trailingComma.calls.length, 0);
|
|
93
|
+
assert.equal(trailingComma.malformed, 1);
|
|
94
|
+
|
|
95
|
+
const truncatedAction = agent._extractTextToolCalls(
|
|
96
|
+
'Action: web_search\nAction Input: {"query": "broken"'
|
|
97
|
+
);
|
|
98
|
+
assert.equal(truncatedAction.calls.length, 0);
|
|
99
|
+
assert.equal(truncatedAction.malformed, 1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('parseTextToolCalls keeps its historical return shape (calls only)', () => {
|
|
103
|
+
const agent = bareAgent();
|
|
104
|
+
const calls = agent.parseTextToolCalls(
|
|
105
|
+
'```json\n{"action": "get_awards", "parameters": {"year": 2024}}\n```'
|
|
106
|
+
);
|
|
107
|
+
assert.equal(calls.length, 1);
|
|
108
|
+
assert.equal(calls[0].function.name, 'get_awards');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('unknown tool names in text blocks are ignored without breaking parsing', () => {
|
|
112
|
+
const agent = bareAgent();
|
|
113
|
+
const { calls, malformed } = agent._extractTextToolCalls(
|
|
114
|
+
'```json\n{"action": "not_a_real_tool", "parameters": {}}\n```\n```json\n{"action": "get_quotes", "parameters": {}}\n```'
|
|
115
|
+
);
|
|
116
|
+
assert.equal(malformed, 0);
|
|
117
|
+
assert.equal(calls.length, 1);
|
|
118
|
+
assert.equal(calls[0].function.name, 'get_quotes');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('reasoner-class models report that native tools will be omitted', () => {
|
|
122
|
+
const reasoner = Object.create(LLMClient.prototype);
|
|
123
|
+
reasoner.model = 'deepseek-reasoner';
|
|
124
|
+
const normal = Object.create(LLMClient.prototype);
|
|
125
|
+
normal.model = 'gpt-4o';
|
|
126
|
+
|
|
127
|
+
assert.equal(reasoner.toolsWillBeOmitted(), true);
|
|
128
|
+
assert.equal(normal.toolsWillBeOmitted(), false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('static catalog tools are TTL-cached while the social feed stays fresh', async () => {
|
|
132
|
+
const awardsFirst = await executeTool('get_awards', {});
|
|
133
|
+
const awardsSecond = await executeTool('get_awards', {});
|
|
134
|
+
assert.equal(awardsSecond, awardsFirst);
|
|
135
|
+
|
|
136
|
+
const feedFirst = await executeTool('get_social_feed', { limit: 1 });
|
|
137
|
+
const feedSecond = await executeTool('get_social_feed', { limit: 1 });
|
|
138
|
+
assert.notEqual(feedSecond, feedFirst);
|
|
139
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { C } from '../ui.js';
|
|
5
|
+
import { THEME_NAMES, getThemeName, getThemePalette, setTheme } from '../theme.js';
|
|
6
|
+
|
|
7
|
+
test('theme catalog exposes several distinct choices', () => {
|
|
8
|
+
assert.deepEqual(THEME_NAMES, ['sakura', 'midnight', 'ocean', 'forest', 'sunset', 'monochrome']);
|
|
9
|
+
assert.equal(new Set(THEME_NAMES.map(name => getThemePalette(name).panel)).size, THEME_NAMES.length);
|
|
10
|
+
assert.equal(new Set(THEME_NAMES.map(name => getThemePalette(name).text)).size, THEME_NAMES.length);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('changing theme updates shared ANSI colors without restarting', () => {
|
|
14
|
+
const original = getThemeName();
|
|
15
|
+
const originalPrimary = C.sakura;
|
|
16
|
+
|
|
17
|
+
setTheme(original === 'midnight' ? 'forest' : 'midnight', { persist: false });
|
|
18
|
+
assert.notEqual(C.sakura, originalPrimary);
|
|
19
|
+
assert.match(C.bgPanel, /^\x1b\[48;2;/);
|
|
20
|
+
|
|
21
|
+
setTheme(original, { persist: false });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('unknown theme names are rejected', () => {
|
|
25
|
+
assert.equal(setTheme('not-a-theme', { persist: false }), null);
|
|
26
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { toolsSchema, toolHandlers } from '../tools.js';
|
|
5
|
+
import { TOOL_CLUSTERS } from '../toolRouter.js';
|
|
6
|
+
import { DEFAULT_RESEARCH_TOOLS } from '../subagent.js';
|
|
7
|
+
import { CATALOG_TOOLS, WEB_TOOLS, VERIFY_TOOLS } from '../research.js';
|
|
8
|
+
import { PUBLIC_REMOTE_TOOL_NAMES } from '../publicTools.js';
|
|
9
|
+
import { toolDefinitions } from '../../mcp/tools.js';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
const KNOWN_MCP_ALIASES = new Set(['get_profile', 'get_timeline', 'search_gallery']);
|
|
13
|
+
|
|
14
|
+
test('every schema tool has a handler and vice versa', () => {
|
|
15
|
+
const schemaNames = new Set(toolsSchema.map(t => t.function.name));
|
|
16
|
+
const handlerNames = new Set(Object.keys(toolHandlers));
|
|
17
|
+
|
|
18
|
+
const orphans = [...schemaNames].filter(n => !handlerNames.has(n));
|
|
19
|
+
const ghosts = [...handlerNames].filter(n => !schemaNames.has(n));
|
|
20
|
+
assert.deepEqual(orphans, [], 'schema entries without handlers');
|
|
21
|
+
assert.deepEqual(ghosts, [], 'handlers without schema entries');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('every hardcoded tool-name list references real tools', () => {
|
|
25
|
+
const schemaNames = new Set(toolsSchema.map(t => t.function.name));
|
|
26
|
+
const problems = [];
|
|
27
|
+
|
|
28
|
+
const checkList = (label, names) => {
|
|
29
|
+
for (const name of names) {
|
|
30
|
+
if (!schemaNames.has(name)) problems.push(`${label} -> unknown tool "${name}"`);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
for (const [cluster, names] of Object.entries(TOOL_CLUSTERS)) {
|
|
35
|
+
checkList(`TOOL_CLUSTERS.${cluster}`, names);
|
|
36
|
+
}
|
|
37
|
+
checkList('DEFAULT_RESEARCH_TOOLS', DEFAULT_RESEARCH_TOOLS);
|
|
38
|
+
checkList('research CATALOG_TOOLS', CATALOG_TOOLS);
|
|
39
|
+
checkList('research WEB_TOOLS', WEB_TOOLS);
|
|
40
|
+
checkList('research VERIFY_TOOLS', VERIFY_TOOLS);
|
|
41
|
+
checkList('PUBLIC_REMOTE_TOOL_NAMES', PUBLIC_REMOTE_TOOL_NAMES);
|
|
42
|
+
|
|
43
|
+
assert.deepEqual(problems, [], 'stale tool names in hardcoded lists');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('mcp tool definitions reference real tools (plus the documented aliases)', () => {
|
|
47
|
+
const schemaNames = new Set(toolsSchema.map(t => t.function.name));
|
|
48
|
+
const unknown = toolDefinitions
|
|
49
|
+
.map(t => t.name)
|
|
50
|
+
.filter(name => !schemaNames.has(name) && !KNOWN_MCP_ALIASES.has(name));
|
|
51
|
+
|
|
52
|
+
assert.deepEqual(unknown, [], 'mcp definitions referencing non-existent tools');
|
|
53
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { DynamicToolRouter } from '../toolRouter.js';
|
|
4
|
+
import { resolveQueryEntities, buildCatalogIndex, detectPromotionIntent } from '../catalogEntityResolver.js';
|
|
5
|
+
import { createCatalogMatcher, executeTool } from '../tools.js';
|
|
6
|
+
import { KikuAgent } from '../agentCore.js';
|
|
7
|
+
import { McpClientManager } from '../mcpClient.js';
|
|
8
|
+
|
|
9
|
+
const MULTI_INTENT_QUERY = 'เปิดเพลงล่าสุดของจวีจิ้งอี และดูคลิปละครใหม่หน่อย ล่าสุดมีข่าวไหม';
|
|
10
|
+
|
|
11
|
+
test('router keeps playback tools visible when multiple intents share the 15-tool cap', () => {
|
|
12
|
+
const router = new DynamicToolRouter();
|
|
13
|
+
const routed = router.routeToolsForQuery(MULTI_INTENT_QUERY, { includeMcp: true });
|
|
14
|
+
const names = new Set(routed.map(t => t.function?.name));
|
|
15
|
+
|
|
16
|
+
assert.ok(routed.length <= 15, `Expected cap of 15, got ${routed.length}`);
|
|
17
|
+
assert.ok(names.has('play_music'), 'play_music must survive the cap on a play request');
|
|
18
|
+
assert.ok(names.has('search_discography'), 'search_discography must survive the cap');
|
|
19
|
+
assert.ok(names.has('search_works'), 'second intent essentials must follow the first intent before generic tools');
|
|
20
|
+
assert.ok(names.has('get_character_roles'), 'WORKS essentials must be included before the cap cuts them');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('router no longer routes PROFILE intent on substring collisions', () => {
|
|
24
|
+
const router = new DynamicToolRouter();
|
|
25
|
+
|
|
26
|
+
const winter = router.detectIntents('Tell me about her winter concert');
|
|
27
|
+
const winterTypes = winter.map(p => p.type);
|
|
28
|
+
assert.ok(winterTypes.includes('MEDIA'), 'concert should route MEDIA');
|
|
29
|
+
assert.ok(!winterTypes.includes('PROFILE_AWARDS_BRANDS'), '"win" inside "winter" must not route PROFILE');
|
|
30
|
+
|
|
31
|
+
const superlative = router.detectIntents('เพลงที่ยอดวิวสูงสุดของเธอคือเพลงไหน');
|
|
32
|
+
const superlativeTypes = superlative.map(p => p.type);
|
|
33
|
+
assert.ok(superlativeTypes.includes('MEDIA'));
|
|
34
|
+
assert.ok(!superlativeTypes.includes('PROFILE_AWARDS_BRANDS'), 'สูงสุด must not route PROFILE');
|
|
35
|
+
|
|
36
|
+
const happen = router.detectIntents('มีอะไรเกิดขึ้นกับงานนี้');
|
|
37
|
+
assert.ok(!happen.map(p => p.type).includes('PROFILE_AWARDS_BRANDS'), 'เกิดขึ้น must not route PROFILE');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('profile queries no longer leak SYSTEM_SKILL tools via "file" inside "profile"', () => {
|
|
41
|
+
const router = new DynamicToolRouter();
|
|
42
|
+
const intents = router.detectIntents('Show me her profile and fandom details').map(p => p.type);
|
|
43
|
+
assert.ok(intents.includes('PROFILE_AWARDS_BRANDS'));
|
|
44
|
+
assert.ok(!intents.includes('SYSTEM_SKILL'), '"file" inside "profile" must not route SYSTEM_SKILL');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('promotion intent no longer fires on generic words like "look" or "brand"', () => {
|
|
48
|
+
assert.equal(detectPromotionIntent('What does she look like?'), false);
|
|
49
|
+
assert.equal(detectPromotionIntent('a brand new song'), false);
|
|
50
|
+
assert.equal(detectPromotionIntent('her fashion ambassador campaign'), true);
|
|
51
|
+
assert.equal(detectPromotionIntent('รับโปรโมทแบรนด์'), true);
|
|
52
|
+
|
|
53
|
+
const lookRes = resolveQueryEntities('What does she look like?');
|
|
54
|
+
assert.equal(lookRes.isPromotion, false);
|
|
55
|
+
assert.equal(lookRes.hasBrand, false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('brand entities have no Thai name fallback polluted by category strings', () => {
|
|
59
|
+
const index = buildCatalogIndex();
|
|
60
|
+
const prsr = index.find(e => e.id === 'prsr');
|
|
61
|
+
assert.ok(prsr, 'prsr entity must exist');
|
|
62
|
+
assert.equal(prsr.nameTh, '');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('get_awards returns not_found instead of the full list when the query matches nothing', async () => {
|
|
66
|
+
const empty = await executeTool('get_awards', { query: 'quantum oscars 2099' });
|
|
67
|
+
assert.equal(empty.status, 'not_found');
|
|
68
|
+
assert.equal(empty.count, 0);
|
|
69
|
+
assert.deepEqual(empty.awards, []);
|
|
70
|
+
|
|
71
|
+
const all = await executeTool('get_awards', {});
|
|
72
|
+
assert.equal(all.status, 'success');
|
|
73
|
+
assert.ok(all.count > 0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('get_gallery_photos returns not_found instead of the full list when the query matches nothing', async () => {
|
|
77
|
+
const empty = await executeTool('get_gallery_photos', { query: 'quantum oscars 2099' });
|
|
78
|
+
assert.equal(empty.status, 'not_found');
|
|
79
|
+
assert.equal(empty.count, 0);
|
|
80
|
+
assert.deepEqual(empty.photos, []);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('stopword-only catalog queries are flagged as broad matches with an explicit note', () => {
|
|
84
|
+
const matcher = createCatalogMatcher('MV จวีจิ้งอี');
|
|
85
|
+
assert.equal(matcher.broad, true, 'stopword-only query must set the broad flag');
|
|
86
|
+
assert.equal(createCatalogMatcher('In Blossom').broad, undefined, 'real-term queries must not set the broad flag');
|
|
87
|
+
assert.equal(createCatalogMatcher('').broad, undefined, 'empty query is an intentional full listing, not broad');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('search_works appends a broad-query note so the model does not present latest entries as matches', async () => {
|
|
91
|
+
const broad = await executeTool('search_works', { query: 'MV จวีจิ้งอี' });
|
|
92
|
+
assert.equal(broad.status, 'success');
|
|
93
|
+
assert.ok(broad.message, 'broad query must attach an explanatory message');
|
|
94
|
+
assert.match(broad.message, /Broad query/);
|
|
95
|
+
|
|
96
|
+
const exact = await executeTool('search_works', { query: 'In Blossom' });
|
|
97
|
+
assert.equal(exact.status, 'success');
|
|
98
|
+
assert.equal(exact.message, undefined);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('text tool protocol parses against the active tool set, not the full schema', () => {
|
|
102
|
+
const agent = new KikuAgent({
|
|
103
|
+
mcpManager: new McpClientManager({ autoLoad: false }),
|
|
104
|
+
client: {
|
|
105
|
+
provider: 'lmstudio',
|
|
106
|
+
model: 'test-model',
|
|
107
|
+
async chat() { return { role: 'assistant', content: 'test response' }; },
|
|
108
|
+
async checkConnection() { return { connected: true, models: ['test-model'] }; }
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const content = 'Action: get_awards\nAction Input: {"query": "Oscar"}';
|
|
113
|
+
const unrestricted = agent._extractTextToolCalls(content);
|
|
114
|
+
assert.ok(unrestricted.calls.length === 1, 'without routing, the full schema applies');
|
|
115
|
+
|
|
116
|
+
agent._activeToolSchema = agent.getAllToolsSchema().filter(t => t.function.name === 'search_works');
|
|
117
|
+
const restricted = agent._extractTextToolCalls(content);
|
|
118
|
+
assert.equal(restricted.calls.length, 0, 'tools hidden by the router must not be callable via text protocol');
|
|
119
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { executeTool, formatCatalogTitle, prepareToolArguments, toolHandlers } from '../tools.js';
|
|
5
|
+
|
|
6
|
+
test('catalog titles do not repeat an alternate title already present', () => {
|
|
7
|
+
assert.equal(formatCatalogTitle('รักกลางสายฝน (Yu Ai / 雨爱)', '雨爱'), 'รักกลางสายฝน (Yu Ai / 雨爱)');
|
|
8
|
+
assert.equal(formatCatalogTitle('In Blossom', '花间令'), 'In Blossom (花间令)');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('discography search reports an empty result as not found', async () => {
|
|
12
|
+
const result = await toolHandlers.search_discography({
|
|
13
|
+
query: 'no-such-kiku-track-xyz',
|
|
14
|
+
type: 'ost',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
assert.equal(result.status, 'not_found');
|
|
18
|
+
assert.equal(result.count, 0);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('supported drama OST example returns catalog entries', async () => {
|
|
22
|
+
const result = await toolHandlers.search_discography({
|
|
23
|
+
query: 'The Blooms at Ruyi Pavilion',
|
|
24
|
+
type: 'ost',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
assert.equal(result.status, 'success');
|
|
28
|
+
assert.ok(result.count >= 1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('global search ignores generic OST wording and still finds the drama', async () => {
|
|
32
|
+
const result = await toolHandlers.global_search({ query: 'In Blossom OST songs' });
|
|
33
|
+
|
|
34
|
+
assert.equal(result.status, 'success');
|
|
35
|
+
assert.ok(result.results.works.some(work => work.title.includes('In Blossom')));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('tool runtime rejects malformed JSON instead of inventing a query argument', () => {
|
|
39
|
+
const prepared = prepareToolArguments('global_search', '{query: broken json}');
|
|
40
|
+
assert.equal(prepared.safe, false);
|
|
41
|
+
assert.match(prepared.error, /Invalid JSON arguments/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('tool runtime validates required arguments and removes unknown keys', () => {
|
|
45
|
+
assert.equal(prepareToolArguments('global_search', {}).safe, false);
|
|
46
|
+
const prepared = prepareToolArguments('global_search', { query: 'In Blossom', unexpected: true });
|
|
47
|
+
assert.equal(prepared.safe, true);
|
|
48
|
+
assert.deepEqual(prepared.args, { query: 'In Blossom' });
|
|
49
|
+
assert.match(prepared.warnings.join(' '), /unexpected/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('tool runtime normalizes empty successful collections to not_found', async () => {
|
|
53
|
+
const result = await executeTool('search_works', { year: '1900' });
|
|
54
|
+
assert.equal(result.status, 'not_found');
|
|
55
|
+
assert.equal(result.count, 0);
|
|
56
|
+
assert.match(result.validationWarnings.join(' '), /converted to number/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('search_works maps category synonyms onto real catalog values (no false not_found)', async () => {
|
|
60
|
+
|
|
61
|
+
const series = await executeTool('search_works', { category: 'series', featuredOnly: true }, { noCache: true });
|
|
62
|
+
assert.equal(series.status, 'success');
|
|
63
|
+
assert.ok(series.count > 0);
|
|
64
|
+
assert.ok(series.works.every(w => ['historical-drama', 'modern-drama'].includes(w.category)));
|
|
65
|
+
|
|
66
|
+
const movie = await executeTool('search_works', { category: 'movie' }, { noCache: true });
|
|
67
|
+
assert.equal(movie.status, 'success');
|
|
68
|
+
assert.ok(movie.count >= 1);
|
|
69
|
+
assert.ok(movie.works.every(w => w.category === 'movie'));
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
const junk = await executeTool('search_works', { category: 'documentary' }, { noCache: true });
|
|
73
|
+
assert.equal(junk.status, 'not_found');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('manage_skill tool executes learn, list, disable, and remove actions', async () => {
|
|
77
|
+
const memory = {
|
|
78
|
+
preferences: { skillsEnabled: true, userSkills: [] },
|
|
79
|
+
addUserSkill(inst) {
|
|
80
|
+
const s = { id: 'sk_test', instruction: inst };
|
|
81
|
+
this.preferences.userSkills.push(s);
|
|
82
|
+
return s;
|
|
83
|
+
},
|
|
84
|
+
removeUserSkill(id) {
|
|
85
|
+
const idx = this.preferences.userSkills.findIndex(s => s.id === id);
|
|
86
|
+
if (idx >= 0) {
|
|
87
|
+
this.preferences.userSkills.splice(idx, 1);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
},
|
|
92
|
+
clearUserSkills() { this.preferences.userSkills = []; },
|
|
93
|
+
setPreference(k, v) { this.preferences[k] = v; },
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const learnRes = await executeTool('manage_skill', { action: 'learn', instruction: 'ตอบสั้นกระชับ' }, { memory });
|
|
97
|
+
assert.equal(learnRes.status, 'success');
|
|
98
|
+
assert.equal(learnRes.skill.instruction, 'ตอบสั้นกระชับ');
|
|
99
|
+
|
|
100
|
+
const listRes = await executeTool('manage_skill', { action: 'list' }, { memory });
|
|
101
|
+
assert.equal(listRes.status, 'success');
|
|
102
|
+
assert.equal(listRes.skills.length, 1);
|
|
103
|
+
|
|
104
|
+
const disableRes = await executeTool('manage_skill', { action: 'disable' }, { memory });
|
|
105
|
+
assert.equal(disableRes.status, 'success');
|
|
106
|
+
assert.equal(memory.preferences.skillsEnabled, false);
|
|
107
|
+
|
|
108
|
+
const removeRes = await executeTool('manage_skill', { action: 'remove', skillId: 'sk_test' }, { memory });
|
|
109
|
+
assert.equal(removeRes.status, 'success');
|
|
110
|
+
assert.equal(memory.preferences.userSkills.length, 0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('run_subagent tool routes through agent context when available', async () => {
|
|
114
|
+
const mockAgent = {
|
|
115
|
+
async runSubagent(task) {
|
|
116
|
+
return { answer: `Report for ${task}`, toolCalls: [{ name: 'search_works' }], iterations: 1 };
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const res = await executeTool('run_subagent', { task: 'Verify 2026 drama' }, { agent: mockAgent });
|
|
121
|
+
assert.equal(res.status, 'success');
|
|
122
|
+
assert.equal(res.task, 'Verify 2026 drama');
|
|
123
|
+
assert.match(res.answer, /Report for/);
|
|
124
|
+
assert.equal(res.toolCalls.length, 1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('run_deep_research and verify_claim route through agent research orchestrator', async () => {
|
|
128
|
+
let verifyModeCalled = false;
|
|
129
|
+
const mockAgent = {
|
|
130
|
+
async runResearch(query, options = {}) {
|
|
131
|
+
if (options.mode === 'verify') verifyModeCalled = true;
|
|
132
|
+
return { answer: `Synthesized for ${query}`, sources: ['https://example.com/source1'], reports: [] };
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const researchRes = await executeTool('run_deep_research', { question: 'All 2026 plans' }, { agent: mockAgent });
|
|
137
|
+
assert.equal(researchRes.status, 'success');
|
|
138
|
+
assert.equal(researchRes.sources.length, 1);
|
|
139
|
+
|
|
140
|
+
const verifyRes = await executeTool('verify_claim', { claim: 'Is Qian Xiang releasing in 2026' }, { agent: mockAgent });
|
|
141
|
+
assert.equal(verifyRes.status, 'success');
|
|
142
|
+
assert.equal(verifyModeCalled, true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('globalToolCache stores and returns cached tool execution results', async () => {
|
|
146
|
+
const { globalToolCache, ToolCache } = await import('../tools.js');
|
|
147
|
+
const cache = new ToolCache(10, 5000);
|
|
148
|
+
cache.set('test_key', { data: 123 });
|
|
149
|
+
assert.deepEqual(cache.get('test_key'), { data: 123 });
|
|
150
|
+
assert.equal(cache.get('non_existent'), null);
|
|
151
|
+
|
|
152
|
+
const firstCall = await executeTool('get_awards', {});
|
|
153
|
+
assert.equal(firstCall.status, 'success');
|
|
154
|
+
|
|
155
|
+
const secondCall = await executeTool('get_awards', {});
|
|
156
|
+
assert.equal(secondCall.status, 'success');
|
|
157
|
+
assert.deepEqual(firstCall.awards, secondCall.awards);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('get_brand_endorsements finds Versace and global brand ambassador roles', async () => {
|
|
161
|
+
const versaceRes = await executeTool('get_brand_endorsements', { query: 'Versace', isGlobal: true });
|
|
162
|
+
assert.equal(versaceRes.status, 'success');
|
|
163
|
+
assert.ok(versaceRes.count >= 1);
|
|
164
|
+
assert.ok(versaceRes.brands.some(b => b.brand.toLowerCase().includes('versace')));
|
|
165
|
+
|
|
166
|
+
const prsrRes = await executeTool('get_brand_endorsements', { query: 'PRSR' });
|
|
167
|
+
assert.equal(prsrRes.status, 'success');
|
|
168
|
+
assert.ok(prsrRes.count >= 1);
|
|
169
|
+
assert.ok(prsrRes.brands.some(b => b.brand.includes('PRSR')));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
test('createCatalogMatcher uses scoring fuzzy matching for multi-word and misspelled queries', async () => {
|
|
174
|
+
const { createCatalogMatcher } = await import('../tools.js');
|
|
175
|
+
const matcher = createCatalogMatcher('เพลง Ost Ju Jingyi 2024 Yunxi');
|
|
176
|
+
assert.equal(matcher('ตำนานหยุนซี Legend of Yunxi', 'Yunxi'), true);
|
|
177
|
+
|
|
178
|
+
const res = await executeTool('search_works', { query: 'เพลง Ost Ju Jingyi 2024 Yunxi' });
|
|
179
|
+
assert.equal(res.status, 'success');
|
|
180
|
+
assert.ok(res.count > 0);
|
|
181
|
+
assert.ok(res.works.some(w => (w.titleTh || '').includes('หยุนซี') || (w.titleEn || '').toLowerCase().includes('yunxi')));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('empty or not_found tool results are not stored in globalToolCache', async () => {
|
|
185
|
+
const { globalToolCache } = await import('../tools.js');
|
|
186
|
+
const query = 'totally_fake_drama_xyz_never_exists_123';
|
|
187
|
+
const notFound = await executeTool('search_works', { query });
|
|
188
|
+
assert.equal(notFound.status, 'not_found');
|
|
189
|
+
|
|
190
|
+
const cacheKey = `search_works:${JSON.stringify({ query })}`;
|
|
191
|
+
assert.equal(globalToolCache.get(cacheKey), null);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('get_system_stats returns dynamically computed activeYears and metadata', async () => {
|
|
195
|
+
const statsRes = await executeTool('get_system_stats', {});
|
|
196
|
+
assert.equal(statsRes.status, 'success');
|
|
197
|
+
assert.equal(statsRes.sourceType, 'local_database');
|
|
198
|
+
assert.ok(statsRes.dataFreshness);
|
|
199
|
+
assert.match(statsRes.stats.activeYears, /2013 - Present/);
|
|
200
|
+
assert.ok(Array.isArray(statsRes.stats.featured2026Works));
|
|
201
|
+
assert.ok(statsRes.stats.featured2026Works.length > 0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('tool results distinguish direct links from search URLs with isDirectLink', async () => {
|
|
205
|
+
const worksRes = await executeTool('search_works', { query: 'In Blossom' });
|
|
206
|
+
assert.equal(worksRes.status, 'success');
|
|
207
|
+
const work = worksRes.works[0];
|
|
208
|
+
assert.equal(work.youtubeTrailerIsDirectLink, false);
|
|
209
|
+
if (work.themeSong) {
|
|
210
|
+
assert.equal(typeof work.themeSong.isDirectLink, 'boolean');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const discoRes = await executeTool('search_discography', { query: 'In Blossom' });
|
|
214
|
+
assert.equal(discoRes.status, 'success');
|
|
215
|
+
assert.equal(typeof discoRes.songs[0].isDirectLink, 'boolean');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('generic artist-name queries return catalog data instead of false not_found', async () => {
|
|
219
|
+
for (const tool of ['search_works', 'search_discography', 'get_character_roles', 'get_music_videos', 'get_brand_endorsements', 'global_search']) {
|
|
220
|
+
const res = await executeTool(tool, { query: 'Ju Jingyi' }, { noCache: true });
|
|
221
|
+
assert.equal(res.status, 'success', `${tool} should not not_found a generic artist query`);
|
|
222
|
+
assert.ok((res.count ?? 0) > 0, `${tool} should return items for a generic artist query`);
|
|
223
|
+
}
|
|
224
|
+
const gallery = await executeTool('get_gallery_photos', { query: 'Ju Jingyi' }, { noCache: true });
|
|
225
|
+
assert.equal(gallery.status, 'success');
|
|
226
|
+
assert.ok(gallery.count > 0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('latest-intent queries fall back to the newest-first catalog list', async () => {
|
|
230
|
+
const works = await executeTool('search_works', { query: 'เรื่องล่าสุด' }, { noCache: true });
|
|
231
|
+
assert.equal(works.status, 'success');
|
|
232
|
+
assert.ok(works.count > 0);
|
|
233
|
+
|
|
234
|
+
const songs = await executeTool('search_discography', { query: 'เพลงล่าสุด' }, { noCache: true });
|
|
235
|
+
assert.equal(songs.status, 'success');
|
|
236
|
+
assert.ok(songs.count > 0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('get_social_feed aggregates douyin and weibo cards', async () => {
|
|
240
|
+
const res = await executeTool('get_social_feed', { limit: 4 }, { noCache: true });
|
|
241
|
+
assert.equal(res.status, 'success');
|
|
242
|
+
assert.ok(res.count > 0);
|
|
243
|
+
assert.ok(res.posts.every(p => p.platform === 'douyin' || p.platform === 'weibo'));
|
|
244
|
+
assert.ok(res.posts[0].content && res.posts[0].content.length > 10);
|
|
245
|
+
|
|
246
|
+
const weibo = await executeTool('get_social_feed', { platform: 'weibo', limit: 2 }, { noCache: true });
|
|
247
|
+
assert.equal(weibo.status, 'success');
|
|
248
|
+
assert.ok(weibo.posts.every(p => p.platform === 'weibo'));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('get_work_detail resolves dash/underscore/space id variants and fuzzy titles', async () => {
|
|
252
|
+
for (const variant of ['in-blossom', 'in_blossom', 'in blossom']) {
|
|
253
|
+
const res = await executeTool('get_work_detail', { workIdOrTitle: variant }, { noCache: true });
|
|
254
|
+
assert.equal(res.status, 'success', `id variant "${variant}" should resolve`);
|
|
255
|
+
assert.match(res.work.id, /in-blossom/);
|
|
256
|
+
}
|
|
257
|
+
const missing = await executeTool('get_work_detail', { workIdOrTitle: 'no-such-work-xyz' }, { noCache: true });
|
|
258
|
+
assert.equal(missing.status, 'not_found');
|
|
259
|
+
});
|