File size: 5,650 Bytes
c056ab3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 | /**
* Tool config ↔ catalog entry 互转与校验
* 运行: cd client/src && npx tsx tests/chat/toolConfig.test.ts
*/
import { TOOL_CATALOG } from '../../features/chat/toolCatalog';
import { resolveMockTool } from '../../features/chat/mockExecutor';
import {
buildToolConfigFromCheckedJson,
createBlankToolEntry,
entriesToToolConfig,
EMPTY_TOOL_CONFIG,
isToolConfigEmpty,
nextAvailableNewToolName,
normalizeToolConfig,
parseToolCatalogEntryJson,
toolConfigCacheKeyFields,
toolConfigFingerprint,
toolConfigToEntries,
} from '../../features/chat/toolConfig';
let passed = 0;
let failed = 0;
function assert(label: string, cond: boolean): void {
if (cond) {
passed++;
console.log(` ✓ ${label}`);
} else {
failed++;
console.log(` ✗ ${label}`);
}
}
const sampleEntry = TOOL_CATALOG[0]!;
const sampleJson = JSON.stringify(sampleEntry, null, 2);
const webSearchEntry = TOOL_CATALOG.find((e) => e.function.name === 'web_search')!;
console.log('toolConfig round-trip');
{
const config = entriesToToolConfig([sampleEntry]);
const back = toolConfigToEntries(config);
assert('entriesToToolConfig → toolConfigToEntries', back[0]!.function.name === sampleEntry.function.name);
assert(
'mock response preserved',
back[0]!.mock_results[0]!.response.temperature ===
sampleEntry.mock_results[0]!.response.temperature,
);
}
console.log('parseToolCatalogEntryJson');
{
const ok = parseToolCatalogEntryJson(sampleJson);
assert('valid entry parses', 'entry' in ok && ok.entry.function.name === sampleEntry.function.name);
const bad = parseToolCatalogEntryJson('{');
assert('invalid JSON rejected', 'error' in bad);
const webJson = JSON.stringify(webSearchEntry, null, 2);
const webParsed = parseToolCatalogEntryJson(webJson);
assert(
'web_search keeps all mock candidates',
'entry' in webParsed && webParsed.entry.mock_results.length === 2,
);
}
console.log('legacy tool config migration');
{
const legacy = {
tools_schema: [{ function: sampleEntry.function }],
mock_results: {
get_current_temperature: JSON.stringify(sampleEntry.mock_results[0]!.response),
},
};
const migrated = normalizeToolConfig(legacy);
assert('legacy migrates to entries', migrated?.entries.length === 1);
assert(
'legacy mock preserved',
migrated?.entries[0]!.mock_results[0]!.response.temperature ===
sampleEntry.mock_results[0]!.response.temperature,
);
}
console.log('resolveMockTool trigger_keyword');
{
const config = entriesToToolConfig([webSearchEntry]);
const ipo = resolveMockTool(config, 'web_search', { query: 'SpaceX IPO 最新消息' });
assert('IPO keyword matches', ipo?.includes('750亿美元') === true);
const boss = resolveMockTool(config, 'web_search', { query: 'SpaceX 老板是谁' });
assert('老板 keyword matches', boss?.includes('埃隆·马斯克') === true);
const miss = resolveMockTool(config, 'web_search', { query: 'SpaceX 火箭发射' });
assert('no match returns null', miss === null);
const weatherConfig = entriesToToolConfig([sampleEntry]);
const weather = resolveMockTool(weatherConfig, 'get_current_temperature', { location: '北京' });
assert('no-keyword tool uses first candidate', weather?.includes('25') === true);
}
console.log('resolveMockTool keyword fallback');
{
const entry = {
function: webSearchEntry.function,
mock_results: [
{ trigger_keyword: 'IPO', response: { content: 'ipo hit' } },
{ response: { content: 'default hit' } },
],
};
const config = entriesToToolConfig([entry]);
const matched = resolveMockTool(config, 'web_search', { query: 'IPO news' });
assert('keyword branch wins', matched === JSON.stringify({ content: 'ipo hit' }));
const fallback = resolveMockTool(config, 'web_search', { query: 'other topic' });
assert('non-keyword fallback', fallback === JSON.stringify({ content: 'default hit' }));
}
console.log('buildToolConfigFromCheckedJson');
{
const built = buildToolConfigFromCheckedJson([{ label: 't1', text: sampleJson }]);
assert('single tool builds', 'config' in built && built.config.entries.length === 1);
const dup = buildToolConfigFromCheckedJson([
{ label: 'a', text: sampleJson },
{ label: 'b', text: sampleJson },
]);
assert('duplicate name rejected', 'error' in dup);
const empty = buildToolConfigFromCheckedJson([]);
assert('empty selection → empty config', 'config' in empty && isToolConfigEmpty(empty.config));
}
console.log('EMPTY_TOOL_CONFIG');
assert('empty config has no entries', isToolConfigEmpty(EMPTY_TOOL_CONFIG));
console.log('createBlankToolEntry / nextAvailableNewToolName');
{
const blank = createBlankToolEntry('new_tool');
assert('blank entry has name', blank.function.name === 'new_tool');
assert(
'next name skips used',
nextAvailableNewToolName(['new_tool', 'new_tool_2']) === 'new_tool_3',
);
}
console.log('toolConfigCacheKeyFields');
{
const config = entriesToToolConfig([sampleEntry]);
assert('single-turn omits fingerprint', toolConfigCacheKeyFields(false, config).toolConfigFingerprint === undefined);
const multi = toolConfigCacheKeyFields(true, config);
assert(
'multi-turn includes fingerprint',
multi.toolConfigFingerprint === toolConfigFingerprint(config),
);
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
|