/** * 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);