v0.17.1: 退出释放显存修正 + 备份完整性 + 核心逻辑测试补课 + 上下文逻辑收敛 + 记忆日志可读性
CI / verify (push) Successful in 1m2s

修复:
- 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:
2026-08-26 15:02:47 +08:00
parent 0b172d30c0
commit b66945c8a7
32 changed files with 2082 additions and 326 deletions
+234
View File
@@ -0,0 +1,234 @@
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);
});
});