Files
thzxx b66945c8a7
CI / verify (push) Successful in 1m2s
v0.17.1: 退出释放显存修正 + 备份完整性 + 核心逻辑测试补课 + 上下文逻辑收敛 + 记忆日志可读性
修复:
- 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
2026-08-26 15:02:47 +08:00

235 lines
7.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach } from 'vitest';
import {
classifyError,
calculateBackoff,
validatePathSandbox,
checkCommandSafety,
smartTruncateByToolType,
addResultMetadata,
estimateResultTokens,
recordErrorPattern,
compactOldToolResult,
getErrorRecoverySuggestions,
formatErrorRecovery,
resetAllSafetyState,
storeToolResult,
} from '../src/renderer/services/agent-safety.js';
describe('classifyError', () => {
it('分类瞬态错误为可重试', () => {
const r = classifyError('Network timeout after 30s');
expect(r.class).toBe('transient');
expect(r.shouldRetry).toBe(true);
expect(r.maxRetries).toBeGreaterThan(0);
});
it('分类连接重置为瞬态', () => {
expect(classifyError('ECONNRESET').class).toBe('transient');
expect(classifyError('ETIMEDOUT').class).toBe('transient');
expect(classifyError('连接失败').class).toBe('transient');
});
it('分类永久错误为不可重试', () => {
const r = classifyError('ENOENT: no such file or directory');
expect(r.class).toBe('permanent');
expect(r.shouldRetry).toBe(false);
expect(r.maxRetries).toBe(0);
});
it('分类权限拒绝为永久', () => {
expect(classifyError('EACCES: permission denied').class).toBe('permanent');
});
it('分类安全错误为不可重试', () => {
const r = classifyError('安全警告: 检测到注入');
expect(r.class).toBe('security');
expect(r.shouldRetry).toBe(false);
});
it('分类未知错误允许一次重试', () => {
const r = classifyError('some unusual failure');
expect(r.class).toBe('unknown');
expect(r.shouldRetry).toBe(true);
expect(r.maxRetries).toBe(1);
});
});
describe('calculateBackoff', () => {
it('指数退避递增且上限 10s', () => {
expect(calculateBackoff(0, 1000)).toBe(1000);
expect(calculateBackoff(1, 1000)).toBe(2000);
expect(calculateBackoff(2, 1000)).toBe(4000);
expect(calculateBackoff(5, 1000)).toBe(10000); // 封顶
});
});
describe('validatePathSandbox', () => {
const ws = 'C:/Users/tester/workspace';
it('工作空间内路径放行', () => {
const r = validatePathSandbox('C:/Users/tester/workspace/src/file.ts', ws);
expect(r.valid).toBe(true);
});
it('空路径拒绝', () => {
expect(validatePathSandbox('', ws).valid).toBe(false);
});
it('绝对路径越界拒绝', () => {
const r = validatePathSandbox('C:/Users/tester/other/file.ts', ws);
expect(r.valid).toBe(false);
expect(r.reason).toContain('工作空间');
});
it('路径遍历超出工作空间拒绝', () => {
const r = validatePathSandbox('C:/Users/tester/workspace/../../etc/passwd', ws);
// 相对部分深于工作空间根应拒绝
expect(r.valid).toBe(false);
});
it('无工作空间时放行', () => {
expect(validatePathSandbox('/any/path', '').valid).toBe(true);
});
it('大小写不敏感匹配 Windows 工作空间', () => {
const r = validatePathSandbox('c:/users/tester/workspace/x.txt', ws);
expect(r.valid).toBe(true);
});
});
describe('checkCommandSafety', () => {
it('判定禁止命令', () => {
const r = checkCommandSafety('rm -rf /');
expect(r.safe).toBe(false);
expect(r.riskLevel).toBe('forbidden');
});
it('判定 fork 炸弹', () => {
const r = checkCommandSafety(':(){ :|:& };:');
expect(r.riskLevel).toBe('forbidden');
});
it('判定关机命令', () => {
expect(checkCommandSafety('shutdown -h now').riskLevel).toBe('forbidden');
});
it('判定高风险命令为 medium/high 但非 forbidden', () => {
const r = checkCommandSafety('git push --force');
expect(r.riskLevel).toBe('medium');
expect(r.safe).toBe(true); // medium 允许但需确认
});
it('普通命令安全', () => {
const r = checkCommandSafety('ls -la');
expect(r.safe).toBe(true);
expect(r.riskLevel).toBe('none');
});
});
describe('smartTruncateByToolType', () => {
const content = 'x'.repeat(1000);
it('不超限时原样返回', () => {
expect(smartTruncateByToolType('read_file', 'short', 5000)).toBe('short');
});
it('按头部策略截断(search_files)并标记省略量', () => {
const out = smartTruncateByToolType('search_files', content, 300);
expect(out.length).toBeLessThan(1000);
expect(out).toContain('R95截断');
expect(out.startsWith('xxx')).toBe(true);
});
it('按尾部策略截断(git', () => {
const out = smartTruncateByToolType('git', content, 300);
expect(out.endsWith('xxx')).toBe(true);
expect(out).toContain('R95截断');
});
it('默认 both 策略保留头尾', () => {
const out = smartTruncateByToolType('_default', content, 400);
expect(out.startsWith('xxx')).toBe(true);
expect(out.endsWith('xxx')).toBe(true);
});
});
describe('estimateResultTokens / addResultMetadata', () => {
it('估算中文与英文字符 token', () => {
expect(estimateResultTokens('你好')).toBeGreaterThan(0);
expect(estimateResultTokens('hello world')).toBeGreaterThan(0);
expect(estimateResultTokens('')).toBe(0);
});
it('大结果追加元数据标记', () => {
const big = '字'.repeat(1200);
const out = addResultMetadata(big);
expect(out).toContain('[元数据: ~');
});
it('小结果不追加元数据', () => {
expect(addResultMetadata('short')).toBe('short');
});
});
describe('recordErrorPattern', () => {
beforeEach(() => resetAllSafetyState());
it('首次出现不返回建议', () => {
expect(recordErrorPattern('read_file', 'ENOENT: no such file')).toBeUndefined();
});
it('同一错误出现 2 次返回建议', () => {
recordErrorPattern('read_file', 'ENOENT: no such file');
const hint = recordErrorPattern('read_file', 'ENOENT: no such file');
expect(hint).toContain('错误模式提示');
});
});
describe('compactOldToolResult', () => {
it('短结果原样返回', () => {
const msg = { role: 'tool' as const, content: 'short', tool_name: 'read_file' };
expect(compactOldToolResult(msg).content).toBe('short');
});
it('超长结果归档为引用', () => {
const msg = { role: 'tool' as const, content: 'x'.repeat(2000), tool_name: 'read_file' };
const out = compactOldToolResult(msg);
expect(out.content).toContain('[工具结果已归档');
expect(out.content).toContain('ref=');
});
it('已归档结果不重复处理', () => {
const msg = { role: 'tool' as const, content: '[工具结果已归档 ref=xxx]', tool_name: 'read_file' };
expect(compactOldToolResult(msg).content).toBe(msg.content);
});
});
describe('storeToolResult / 归档引用', () => {
beforeEach(() => resetAllSafetyState());
it('生成唯一引用 id 并可通过归档消息识别', () => {
const id = storeToolResult('web_fetch', 'full content here');
expect(id).toMatch(/^toolref_/);
});
});
describe('getErrorRecoverySuggestions / formatErrorRecovery', () => {
it('文件未找到给出检查路径建议', () => {
const s = getErrorRecoverySuggestions('read_file', 'ENOENT: no such file');
expect(s.suggestions.length).toBeGreaterThan(0);
});
it('格式化包含错误与建议条目', () => {
const s = getErrorRecoverySuggestions('run_command', 'command not found');
const formatted = formatErrorRecovery(s);
expect(formatted).toContain('错误恢复建议');
expect(formatted).toContain('run_command');
expect(formatted).toContain('which/where');
});
it('无匹配规则时提供通用建议', () => {
const s = getErrorRecoverySuggestions('unknown_tool', 'weird error');
expect(s.suggestions.length).toBeGreaterThan(0);
});
});