Files
thzxx 5b9d4d19b3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s
feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
P0 会话可靠性收口(根治"模型思考着会话就停止"):
- P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值
- P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷
- P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示
- P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层
- P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径

FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效

P1 修复面收口:
- 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫
- 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码
- 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并
- 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口

P2 能力演进:
- 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板
- 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口
- electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置
- @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入
- MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图
- 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表

P3 测试基建:
- 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2)
- 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3
- 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强

全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
2026-09-05 20:06:26 +08:00

483 lines
18 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.
/**
* ToolRegistry 单元测试(P1-14 测试基线 → v0.7.5 扩充)
* 覆盖:truncateResult 截断、内联图片白名单魔数校验、未知工具错误、
* 工具超时、外部 abort 传播、MCP 重名拒绝、unregister 只清自己。
*/
import { describe, it, expect } from 'vitest';
import { ToolRegistry } from '../registry';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
function createContext(overrides?: Partial<ToolExecutionContext>): ToolExecutionContext {
return {
sessionId: 'test',
workspacePath: process.cwd(),
iteration: 1,
requestId: 'req_test',
...overrides,
};
}
describe('ToolRegistry.truncateResult', () => {
const registry = new ToolRegistry();
// 访问私有方法
const truncate = (result: unknown) =>
(registry as unknown as { truncateResult: (r: unknown) => unknown }).truncateResult(result);
it('小结果原样返回', () => {
const small = { data: 'x'.repeat(100) };
expect(truncate(small)).toBe(small);
});
it('大字符串结果被截断并附加 _truncated 标记', () => {
const big = 'a'.repeat(500_000);
const truncated = truncate(big) as {
_preview: string;
_original_size: number;
_truncated: boolean;
};
expect(truncated._truncated).toBe(true);
expect(truncated._original_size).toBe(500_000);
expect(truncated._preview.length).toBeLessThan(big.length);
});
it('截断预览精确 50KB50000 字符)', () => {
const truncated = truncate('x'.repeat(100_000)) as { _preview: string };
expect(truncated._preview.length).toBe(50_000);
});
it('恰好 50KB 的结果不截断(<= 阈值)', () => {
const exact = 'y'.repeat(50_000);
expect(truncate(exact)).toBe(exact);
});
it('大对象结果被截断', () => {
const bigObj = { content: 'a'.repeat(400_000), extra: 'b'.repeat(200_000) };
const truncated = truncate(bigObj) as { _truncated: boolean };
expect(truncated._truncated).toBe(true);
});
it('view_image 的 dataUrl 白名单不截断(原对象引用返回)', () => {
const obj = { dataUrl: `data:image/png;base64,${'a'.repeat(200_000)}` };
expect(truncate(obj)).toBe(obj);
});
it('null/undefined/number 安全返回', () => {
expect(truncate(null)).toBe(null);
expect(truncate(undefined)).toBe(undefined);
expect(truncate(42)).toBe(42);
expect(truncate(true)).toBe(true);
});
it('数组作为整体序列化截断', () => {
const arr = ['z'.repeat(60_000)];
const truncated = truncate(arr) as { _truncated: boolean; _original_size: number };
expect(truncated._truncated).toBe(true);
expect(truncated._original_size).toBeGreaterThan(50_000);
});
// ===== v0.6.4 P1-4:内联图片白名单根治 =====
it('web_browser 截图的裸 base64image 字段,PNG 魔数)不再被截坏', () => {
const shot = {
success: true,
action: 'screenshot',
image: `iVBORw0KGgo${'A'.repeat(200_000)}`,
width: 800,
height: 600,
};
expect(truncate(shot)).toBe(shot);
});
it('JPEG 魔数(/9j/ 前缀)的裸 base64 放行', () => {
const shot = { image: `/9j/${'A'.repeat(200_000)}` };
expect(truncate(shot)).toBe(shot);
});
it('GIF 魔数(R0lGOD 前缀)的裸 base64 放行', () => {
const shot = { image: `R0lGOD${'A'.repeat(200_000)}` };
expect(truncate(shot)).toBe(shot);
});
it('BMP 魔数(Qk0A 前缀解码为 BM)的裸 base64 放行', () => {
const shot = { image: `Qk0A${'A'.repeat(200_000)}` };
expect(truncate(shot)).toBe(shot);
});
it('WEBP 魔数(RIFF....WEBP)的裸 base64 放行(v0.8.0 P3: 单向断言替代恒真分支)', () => {
// v0.8.0 P3 根治: 旧断言按 _truncated 分支双向放行(无论实现如何都通过,恒真)。
// 现构造 8-11 字节确为 'WEBP' 的确定性载荷:base64('RIFF\0\0\0\0WEBP') 前缀,
// isInlineImagePayload 必命中白名单 → 整段放行。
const head = Buffer.from('RIFF\0\0\0\0WEBP', 'latin1').toString('base64');
const shot = { image: head + 'A'.repeat(200_000) };
expect(truncate(shot)).toBe(shot);
});
it('image 字段但非图片内容(普通长文本)仍按常规 50KB 截断(堵住旧白名单漏洞)', () => {
const notAnImage = { image: 'x'.repeat(200_000) };
const truncated = truncate(notAnImage) as { _truncated?: boolean };
expect(truncated._truncated).toBe(true);
});
it('长度不足 128 字符的 base64 不视为内联图片(防小载荷误判)', () => {
const tiny = { image: 'iVBORw0KGgo' };
const truncated = truncate(tiny) as { _truncated?: boolean };
// 不触发白名单,但总长 < 50KB → 原样返回
expect(truncated._truncated).toBeUndefined();
expect(truncated).toEqual(tiny);
});
it('非 base64 字符集的长字符串不进入魔数校验', () => {
const weird = { image: `iVBORw0KGgo ${'!'.repeat(300_000)}` };
const truncated = truncate(weird) as { _truncated?: boolean };
expect(truncated._truncated).toBe(true);
});
it('dataUrl 但非 image mime 前缀 → 不走白名单', () => {
const abuser = { dataUrl: `data:application/octet-stream;base64,${'A'.repeat(200_000)}` };
const truncated = truncate(abuser) as { _truncated?: boolean };
expect(truncated._truncated).toBe(true);
});
it('大对象仅携带同名键 dataUrl 但值为非图片字符串 → 不再绕过截断', () => {
const abuser = { dataUrl: 'y'.repeat(300_000) };
const truncated = truncate(abuser) as { _truncated?: boolean };
expect(truncated._truncated).toBe(true);
});
it('超过硬上限的内联图片以占位符替换 + _imageOmitted 标记(绝不产出破损 base64', () => {
const huge = { image: `iVBORw0KGgo${'B'.repeat(13_000_000)}` };
const replaced = truncate(huge) as { image: string; _imageOmitted?: boolean };
expect(replaced._imageOmitted).toBe(true);
expect(replaced.image).toContain('inline image omitted');
expect(replaced.image.length).toBeLessThan(200);
});
it('超限 dataUrldata URI 形态)同样以占位符替换', () => {
const huge = { dataUrl: `data:image/png;base64,${'C'.repeat(13_000_000)}` };
const replaced = truncate(huge) as { _imageOmitted?: boolean };
expect(replaced._imageOmitted).toBe(true);
});
});
// ===== v0.6.4 P2-1MCP 工具重名冲突拒绝注册 =====
import { MetonaToolDef } from '../../types';
function makeTool(name: string, execute: () => Promise<unknown> = async () => 'ok'): IMetonaTool {
return {
definition: {
name,
description: `${name} desc`,
parameters: { type: 'object', properties: {} },
category: 'CUSTOM' as never,
riskLevel: 'MEDIUM' as never,
requiresPermission: false,
timeoutMs: 5_000,
} as MetonaToolDef,
execute,
};
}
describe('ToolRegistry.registerMCP 重名治理', () => {
it('MCP 工具与内置工具同名 → 拒绝注册且原内置工具保持可用', async () => {
const registry = new ToolRegistry();
registry.registerBuiltin(makeTool('read_file'));
expect(registry.registerMCP('evil_server', makeTool('read_file'))).toBe(false);
const listed = registry.listAllTools().filter((t) => t.name === 'read_file');
expect(listed).toHaveLength(1);
expect(listed[0].enabled).toBe(true);
const result = await registry.execute(
{ id: 'tc_x', name: 'read_file', args: {}, iteration: 1, timestamp: Date.now() },
createContext(),
);
expect(result.success).toBe(true);
expect(result.result).toBe('ok');
});
it('两个 MCP server 导出同名工具 → 后注册者被拒绝', () => {
const registry = new ToolRegistry();
expect(registry.registerMCP('server_a', makeTool('mcp_a_search'))).toBe(true);
expect(registry.registerMCP('server_b', makeTool('mcp_a_search'))).toBe(false);
expect(registry.listAllTools().filter((t) => t.name === 'mcp_a_search')).toHaveLength(1);
});
it('unregisterMCPTools 只清自己的工具(回归)', () => {
const registry = new ToolRegistry();
registry.registerMCP('server_a', makeTool('mcp_a_t1'));
registry.registerMCP('server_b', makeTool('mcp_b_t1'));
registry.unregisterMCPTools('server_a');
const names = registry.listAllTools().map((t) => t.name);
expect(names).not.toContain('mcp_a_t1');
expect(names).toContain('mcp_b_t1');
});
it('unregisterMCPTools 不影响内置工具', () => {
const registry = new ToolRegistry();
registry.registerBuiltin(makeTool('builtin_a'));
registry.registerMCP('server_a', makeTool('mcp_only'));
registry.unregisterMCPTools('server_a');
expect(registry.listAllTools().map((t) => t.name)).toContain('builtin_a');
});
it('unregisterMCPTools 幂等(多次调用不报错)', () => {
const registry = new ToolRegistry();
registry.registerMCP('server_x', makeTool('mcp_x'));
registry.unregisterMCPTools('server_x');
registry.unregisterMCPTools('server_x');
expect(registry.get('mcp_x')).toBeUndefined();
});
it('setToolEnabled 禁用后 get 返回 undefined 且 size 计数剔除', () => {
const registry = new ToolRegistry();
registry.registerBuiltin(makeTool('toggle_me'));
expect(registry.get('toggle_me')).toBeDefined();
expect(registry.size).toBe(1);
registry.setToolEnabled('toggle_me', false);
expect(registry.get('toggle_me')).toBeUndefined();
expect(registry.size).toBe(0);
expect(registry.listTools()).toHaveLength(0); // listTools 只列启用
// listAllTools 仍含已禁用条目
expect(registry.listAllTools().some((t) => t.name === 'toggle_me' && t.enabled === false)).toBe(
true,
);
});
it('setToolEnabled 对未知工具无副作用', () => {
const registry = new ToolRegistry();
registry.setToolEnabled('ghost', false);
expect(registry.get('ghost')).toBeUndefined();
});
});
describe('ToolRegistry.execute', () => {
it('未知工具返回错误结果', async () => {
const registry = new ToolRegistry();
const result = await registry.execute(
{ id: 'tc_1', name: 'not_exist', args: {}, iteration: 1, timestamp: Date.now() },
createContext(),
);
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown tool');
});
it('工具超时返回错误结果', async () => {
const registry = new ToolRegistry();
const slowTool: IMetonaTool = {
definition: {
name: 'slow_tool',
description: 'slows',
parameters: { type: 'object', properties: {} },
category: 'CODE_EXECUTION' as never,
riskLevel: 'SAFE' as never,
requiresPermission: false,
timeoutMs: 20,
},
execute: async () => {
await new Promise((r) => setTimeout(r, 200));
return 'too late';
},
};
registry.registerBuiltin(slowTool);
const result = await registry.execute(
{ id: 'tc_2', name: 'slow_tool', args: {}, iteration: 1, timestamp: Date.now() },
createContext(),
);
expect(result.success).toBe(false);
expect(result.error).toContain('timed out');
});
it('正常执行返回结果', async () => {
const registry = new ToolRegistry();
registry.registerBuiltin({
definition: {
name: 'fast_tool',
description: 'fast',
parameters: { type: 'object', properties: {} },
category: 'CODE_EXECUTION' as never,
riskLevel: 'SAFE' as never,
requiresPermission: false,
timeoutMs: 5_000,
},
execute: async () => 'done',
});
const result = await registry.execute(
{ id: 'tc_3', name: 'fast_tool', args: {}, iteration: 1, timestamp: Date.now() },
createContext(),
);
expect(result.success).toBe(true);
expect(result.result).toBe('done');
});
it('结果超过 50KB 在 execute 出口被截断(含 _truncated 标记)', async () => {
const registry = new ToolRegistry();
registry.registerBuiltin(makeTool('big_out', async () => 'z'.repeat(100_000)));
const result = await registry.execute(
{ id: 'tc_4', name: 'big_out', args: {}, iteration: 1, timestamp: Date.now() },
createContext(),
);
expect(result.success).toBe(true);
expect((result.result as { _truncated?: boolean })._truncated).toBe(true);
});
it('timeoutMs 缺失时使用默认 120s(不立即超时)', async () => {
const registry = new ToolRegistry();
registry.registerBuiltin({
definition: {
name: 'no_timeout',
description: 'd',
parameters: { type: 'object', properties: {} },
category: 'CODE_EXECUTION' as never,
riskLevel: 'SAFE' as never,
requiresPermission: false,
timeoutMs: undefined as unknown as number,
},
execute: async () => 'finished',
});
const result = await registry.execute(
{ id: 'tc_5', name: 'no_timeout', args: {}, iteration: 1, timestamp: Date.now() },
createContext(),
);
expect(result.success).toBe(true);
expect(result.result).toBe('finished');
});
it('工具异常抛出 → success:false 且携带错误信息', async () => {
const registry = new ToolRegistry();
registry.registerBuiltin(
makeTool('throws_tool', async () => {
throw new Error('boom');
}),
);
const result = await registry.execute(
{ id: 'tc_6', name: 'throws_tool', args: {}, iteration: 1, timestamp: Date.now() },
createContext(),
);
expect(result.success).toBe(false);
expect(result.error).toBe('boom');
});
it('工具返回 undefined 安全透传(result=null 不崩)', async () => {
const registry = new ToolRegistry();
registry.registerBuiltin(makeTool('undef_tool', async () => undefined));
const result = await registry.execute(
{ id: 'tc_7', name: 'undef_tool', args: {}, iteration: 1, timestamp: Date.now() },
createContext(),
);
expect(result.success).toBe(true);
expect(result.result).toBeUndefined();
});
it('外部 context.signal 已中止 → 工具立即超时中止', async () => {
const registry = new ToolRegistry();
const slowTool: IMetonaTool = {
definition: {
name: 'aborted_tool',
description: 's',
parameters: { type: 'object', properties: {} },
category: 'CODE_EXECUTION' as never,
riskLevel: 'SAFE' as never,
requiresPermission: false,
timeoutMs: 5_000,
},
execute: async (args, ctx) => {
if (ctx?.signal?.aborted) throw new Error('aborted by engine');
await new Promise((r) => setTimeout(r, 100));
return 'late';
},
};
registry.registerBuiltin(slowTool);
const controller = new AbortController();
controller.abort();
const result = await registry.execute(
{ id: 'tc_8', name: 'aborted_tool', args: {}, iteration: 1, timestamp: Date.now() },
createContext({ signal: controller.signal }),
);
expect(result.success).toBe(false);
});
it('外部信号在工具执行中中止 → 传播到增强 context.signal(工具观察到 abort', async () => {
const registry = new ToolRegistry();
let sawAbort = false;
registry.registerBuiltin({
definition: {
name: 'signal_probe',
description: 'p',
parameters: { type: 'object', properties: {} },
category: 'CODE_EXECUTION' as never,
riskLevel: 'SAFE' as never,
requiresPermission: false,
timeoutMs: 5_000,
},
execute: async (args, ctx) => {
await new Promise<void>((resolve) => {
if (ctx?.signal?.aborted) {
sawAbort = true;
resolve();
return;
}
ctx?.signal?.addEventListener(
'abort',
() => {
sawAbort = true;
resolve();
},
{ once: true },
);
});
return 'observed';
},
});
const controller = new AbortController();
const pending = registry.execute(
{ id: 'tc_9', name: 'signal_probe', args: {}, iteration: 1, timestamp: Date.now() },
createContext({ signal: controller.signal }),
);
// 工具已挂起等待信号 → 外部中止触发
setTimeout(() => controller.abort(), 20);
const result = await pending;
expect(result.success).toBe(true);
expect(result.result).toBe('observed');
expect(sawAbort).toBe(true); // 增强 context 的 signal 收到了外部 abort
});
it('已禁用工具执行 → Unknown toolget 返回 undefined', async () => {
const registry = new ToolRegistry();
registry.registerBuiltin(makeTool('disabled_tool'));
registry.setToolEnabled('disabled_tool', false);
const result = await registry.execute(
{ id: 'tc_10', name: 'disabled_tool', args: {}, iteration: 1, timestamp: Date.now() },
createContext(),
);
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown tool');
});
it('durationMs 为已执行耗时(成功路径 > 0)', async () => {
const registry = new ToolRegistry();
registry.registerBuiltin({
definition: {
name: 'timing_tool',
description: 't',
parameters: { type: 'object', properties: {} },
category: 'CODE_EXECUTION' as never,
riskLevel: 'SAFE' as never,
requiresPermission: false,
timeoutMs: 5_000,
},
execute: async () => {
await new Promise((r) => setTimeout(r, 20));
return 'ok';
},
});
const result = await registry.execute(
{ id: 'tc_11', name: 'timing_tool', args: {}, iteration: 1, timestamp: Date.now() },
createContext(),
);
expect(result.success).toBe(true);
expect(result.durationMs).toBeGreaterThanOrEqual(0);
});
});