修复: - main.ts 退出释放模型显存改用 getSetting(serverUrl),不再硬编码 127.0.0.1:11434(避免非默认地址时释放请求打到错误端口) - 备份导出/导入并入 localStorage 持久化状态(会话摘要、度量历史、轨迹降级缓存、主题),版本升级到 v2,实现完整备份 - 工具数量改为 getEnabledToolDefinitions().length 动态计算,删除写死"32 个"的硬编码 - 记忆日志区分操作来源:memory:write 透传 reason,标注"新增记忆/替换/删除/清空/TTL 衰减清理/访问统计写回(无新条目)",避免"写了但看不到新记忆"的困惑 可维护性: - 上下文压力逻辑收敛到统一 calculateContextStats,删除 getContextPressureLevel / getTrendAwareCompressThreshold 的重复实现 - 消除 validateToolArgs 同名碰撞(agent-engine 本地版改名 validateToolArgsQuick) - 子代理工具集改用 getEnabledToolDefinitions() 基线,跟随全局启用开关与 Plan 模式 - 抽取 html-utils.ts 纯函数模块(实体解码/HTML→文本/HTML→Markdown/拦截页检测/相关性评分),tool-handlers-system 净减约 190 行重复代码 - 统一静态导入(savePlanTracker/setPlanModeActive/collectDiagnostics/addWrittenFile) - console.* 使用处补充豁免说明(启动/退出/刷盘阶段无渲染进程可推送日志) - run_command 工具描述改为反映可配置执行模式 测试: - 新增 7 个测试文件 + 扩展 2 个,共 273 个测试(原 34 → 273) - 覆盖 agent-engine / agent-safety / context-manager / tool-registry / result-formatter / tool-parsing / memory-service / crypto / build-context / html-utils / utils / tool-handlers-fs - 全部通过 npm run typecheck && npm test && npm run build
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
validateToolArgs,
|
||||
coerceToolArgs,
|
||||
truncateToolResult,
|
||||
suggestToolFix,
|
||||
validateToolSecurity,
|
||||
getRelevantToolDefinitions,
|
||||
getEnabledToolDefinitions,
|
||||
formatToolName,
|
||||
getToolIcon,
|
||||
} from '../src/renderer/services/tool-registry.js';
|
||||
import type { ToolResult } from '../src/renderer/types.js';
|
||||
|
||||
describe('validateToolArgs', () => {
|
||||
it('read_file 缺少 path 报错', () => {
|
||||
const errors = validateToolArgs('read_file', {});
|
||||
expect(errors.some(e => e.includes('path'))).toBe(true);
|
||||
});
|
||||
|
||||
it('read_file 合法参数不报错', () => {
|
||||
expect(validateToolArgs('read_file', { path: 'a.txt' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('web_search 缺少 query 报错', () => {
|
||||
const errors = validateToolArgs('web_search', {});
|
||||
expect(errors.some(e => e.includes('query'))).toBe(true);
|
||||
});
|
||||
|
||||
it('枚举值校验:git action 非法', () => {
|
||||
const errors = validateToolArgs('git', { action: 'frobnicate' });
|
||||
expect(errors.some(e => e.includes('不在允许范围'))).toBe(true);
|
||||
});
|
||||
|
||||
it('类型校验:max_results 应为整数', () => {
|
||||
const errors = validateToolArgs('web_search', { query: 'x', max_results: 'not-a-number' });
|
||||
expect(errors.some(e => e.includes('应为整数'))).toBe(true);
|
||||
});
|
||||
|
||||
it('未知工具跳过校验(MCP 工具)', () => {
|
||||
expect(validateToolArgs('mcp_unknown__foo', {})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coerceToolArgs', () => {
|
||||
it('字符串数字转整数', () => {
|
||||
expect(coerceToolArgs('read_file', { start_line: '5' }).start_line).toBe(5);
|
||||
});
|
||||
|
||||
it('字符串布尔转布尔', () => {
|
||||
expect(coerceToolArgs('web_fetch', { mobile_ua: 'true' }).mobile_ua).toBe(true);
|
||||
expect(coerceToolArgs('web_fetch', { mobile_ua: 'false' }).mobile_ua).toBe(false);
|
||||
});
|
||||
|
||||
it('逗号分隔字符串转数组', () => {
|
||||
expect(coerceToolArgs('search_files', { file_extensions: '.ts,.js' }).file_extensions).toEqual(['.ts', '.js']);
|
||||
});
|
||||
|
||||
it('JSON 字符串转数组', () => {
|
||||
expect(coerceToolArgs('search_files', { file_extensions: '[".ts"]' }).file_extensions).toEqual(['.ts']);
|
||||
});
|
||||
|
||||
it('保持未知参数原样', () => {
|
||||
expect(coerceToolArgs('read_file', { weird: 'value' }).weird).toBe('value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateToolResult', () => {
|
||||
it('小结果原样返回', () => {
|
||||
const r: ToolResult = { success: true, content: 'short' };
|
||||
expect(truncateToolResult(r, 'read_file')).toBe(r);
|
||||
});
|
||||
|
||||
it('大字符串字段截断保留头尾', () => {
|
||||
// content 属于截断字段;需让整体 JSON 超过 100KB 才会触发截断
|
||||
const big = 'a'.repeat(120000);
|
||||
const out = truncateToolResult({ success: true, content: big }, 'read_file');
|
||||
expect((out as Record<string, unknown>).content).toContain('已截断');
|
||||
});
|
||||
|
||||
it('字段截断后仍超限时暴力截断为 preview', () => {
|
||||
// 多个非截断字段的大值使总和远超 100KB,触发 preview 兜底
|
||||
const r: ToolResult = { success: true, a: 'x'.repeat(60000), b: 'y'.repeat(60000) };
|
||||
const out = truncateToolResult(r, 'read_file');
|
||||
expect(typeof (out as Record<string, unknown>).preview).toBe('string');
|
||||
expect((out as Record<string, unknown>)._omitted_chars).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestToolFix', () => {
|
||||
it('文件未找到建议检查路径', () => {
|
||||
const s = suggestToolFix('read_file', { path: '/nope' }, 'ENOENT: no such file');
|
||||
expect(s).toContain('路径');
|
||||
});
|
||||
|
||||
it('权限拒绝建议检查权限', () => {
|
||||
const s = suggestToolFix('read_file', {}, 'EACCES: permission denied');
|
||||
expect(s).toContain('权限');
|
||||
});
|
||||
|
||||
it('网络错误建议检查网络', () => {
|
||||
const s = suggestToolFix('web_fetch', { url: 'http://x' }, 'ECONNREFUSED');
|
||||
expect(s).toContain('网络');
|
||||
});
|
||||
|
||||
it('通用错误返回空串', () => {
|
||||
expect(suggestToolFix('read_file', {}, 'something else')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToolSecurity', () => {
|
||||
it('本地文件工具拒绝 URL 路径', () => {
|
||||
const r = validateToolSecurity('read_file', { path: 'http://example.com/x' });
|
||||
expect(r).toBeTruthy();
|
||||
expect(r).toContain('web_fetch');
|
||||
});
|
||||
|
||||
it('拒绝 file:// 协议', () => {
|
||||
const r = validateToolSecurity('web_fetch', { url: 'file:///etc/passwd' });
|
||||
expect(r).toContain('file://');
|
||||
});
|
||||
|
||||
it('路径遍历检测', () => {
|
||||
const r = validateToolSecurity('read_file', { path: '../../../../etc/passwd' });
|
||||
expect(r).toContain('路径遍历');
|
||||
});
|
||||
|
||||
it('命令注入检测', () => {
|
||||
const r = validateToolSecurity('run_command', { command: 'echo a; rm -rf /' });
|
||||
expect(r).toContain('注入');
|
||||
});
|
||||
|
||||
it('正常参数返回 null', () => {
|
||||
expect(validateToolSecurity('read_file', { path: 'a.txt' })).toBeNull();
|
||||
});
|
||||
|
||||
it('read_multiple_files paths 数组含 URL 拒绝', () => {
|
||||
const r = validateToolSecurity('read_multiple_files', { paths: ['http://x/a', '/local/b'] });
|
||||
expect(r).toContain('URL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRelevantToolDefinitions', () => {
|
||||
it('短查询返回全部已启用工具', () => {
|
||||
const tools = getRelevantToolDefinitions('hi');
|
||||
expect(tools).toHaveLength(getEnabledToolDefinitions().length);
|
||||
});
|
||||
|
||||
it('空查询返回全部已启用工具', () => {
|
||||
expect(getRelevantToolDefinitions('')).toHaveLength(getEnabledToolDefinitions().length);
|
||||
});
|
||||
|
||||
it('包含核心工具', () => {
|
||||
const names = getRelevantToolDefinitions('请读取这个文件并搜索内容').map(t => t.function.name);
|
||||
expect(names).toContain('read_file');
|
||||
expect(names).toContain('search_files');
|
||||
});
|
||||
|
||||
it('匹配到足够多时不返回全部(含 web 相关)', () => {
|
||||
const names = getRelevantToolDefinitions('帮我搜索网页并抓取内容').map(t => t.function.name);
|
||||
expect(names).toContain('web_search');
|
||||
expect(names).toContain('web_fetch');
|
||||
});
|
||||
|
||||
it('过滤后过少时回退到全部', () => {
|
||||
// 极小匹配场景 → 保留核心 + 至少 60% 规则,回退为全部
|
||||
const tools = getRelevantToolDefinitions('随便问点什么奇怪的内容呢');
|
||||
expect(tools.length).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatToolName / getToolIcon', () => {
|
||||
it('已知工具返回中文名', () => {
|
||||
expect(formatToolName('read_file')).toBe('读取文件');
|
||||
expect(formatToolName('web_search')).toBe('联网搜索');
|
||||
});
|
||||
|
||||
it('未知工具返回原名字', () => {
|
||||
expect(formatToolName('mcp_unknown')).toBe('mcp_unknown');
|
||||
});
|
||||
|
||||
it('已知工具返回图标', () => {
|
||||
expect(getToolIcon('read_file')).toBe('📄');
|
||||
});
|
||||
|
||||
it('未知工具返回默认图标', () => {
|
||||
expect(getToolIcon('mcp_unknown')).toBe('🔧');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user