fix: v0.5.3 模型能力完整性修复 — MCP 工具动态同步 + maxTokens 模型上限钳制
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m21s
CI / 产物编译验证 (push) Successful in 9m59s

背景:v0.5.2 工具调用失效修复后,对模型全能力矩阵(工具/思考/多模态/流式/
压缩/摘要/记忆/故障转移/余额)做契约级核查,发现并修复两处同类时序/边界缺陷。

P1 MCP 工具运行中增删不同步引擎:
- 症状:运行中添加/启用 MCP server 后,已打开会话拿不到新工具;断开
  server 后已有引擎仍持有失效工具定义(模型发起调用才报 Unknown tool)
- 根因:setToolsAll 仅在启动期与工具开关时调用,MCP 连接/断开路径缺失
  (与 v0.5.2 修复的懒创建陷阱同类——"变更点 × 同步路径"未全覆盖)
- 修复:MCPManager 新增 setOnToolsChanged 回调,connectServer 注册完成 /
  disconnectServer 注销完成后触发;main.ts 注入回调同步全部已存在引擎。
  懒创建引擎由 createEngine 实时拉取(v0.5.2),三条路径(启动/懒创建/
  运行中变更)全覆盖。README"无需重启动态发现"的宣称至此真实成立。

P1 maxTokens 超模型上限直接 400:
- 症状:引擎默认 maxTokens=63488,OpenAI gpt-4o(16384)/gpt-4.1(32768)、
  Anthropic opus/haiku(32000)、MiMo standard(32768) 每次请求 400,等于不可用
- 修复:五个 adapter(DeepSeek/Agnes/MiMo/OpenAI/Anthropic)统一按
  MODEL_INFO.maxOutputTokens 钳制;MiMo 保留 thinking 兜底 32768 语义;
  Anthropic thinking budget 在钳制后的 max_tokens 内二分,自动跟随

测试(224 → 236 用例):
- 新增 maxTokens 钳制契约测试 ×9(max-tokens-clamp.test.ts):mock fetch
  记录真实请求体断言——超限钳制(MiMo standard/OpenAI gpt-4o/Anthropic
  opus)/ 未超限原样传递(DeepSeek/Agnes/MiMo pro/o3-mini/sonnet)/
  推理模型字段名 / 未配置默认值安全性
- 新增 MCP 动态同步端到端测试 ×3(mcp-tools-sync.test.ts):mock MCP SDK
  + 真实 MCPManager/ToolRegistry/AgentEngineManager——先建引擎再连
  server,断言同一会话请求的 tools 动态更新 / 断开后移除失效定义 /
  回调异常不阻断 MCP 主流程

能力矩阵核查结论(无回归确认): 工具调用主链路 ✓(v0.5.2)/ SubAgent
工具 ✓(delegate 实时 resolveTools)/ thinking 热更新 ✓(baseConfig 合并
路径无懒创建陷阱)/ 多模态当轮 ✓ / 压缩与孤立 tool 消息配对 ✓ / 摘要分层 ✓ /
记忆注入 ✓ / 故障转移 ✓ / 余额 ✓(v0.5.2)。已知设计限制:历史轮图片不
回传(attachments 仅存缩略图,图片只在发送当轮注入上下文)。

验证: lint 0 / typecheck 双工程 0 / test:electron 236 全过 / build 成功
This commit is contained in:
2026-08-21 22:36:52 +08:00
parent e4d693c1c7
commit 3a30e8f5b4
12 changed files with 669 additions and 101 deletions
@@ -0,0 +1,246 @@
/**
* MCP 工具动态变更 × 引擎同步测试(v0.5.3)
*
* 背景:MCP 工具在运行中增删(添加/断开/启停 server)后,已存在引擎的
* 工具列表不会自动更新 —— setToolsAll 只在启动期与工具开关时被调用。
* 后果:已打开会话拿不到新 MCP 工具,或持有已断开 server 的失效工具定义。
* v0.5.3 修复:MCPManager.setOnToolsChanged 回调,连接/断开后同步全部引擎。
*
* 测试策略:mock MCP SDKClient/StdioClientTransport),用真实的
* MCPManager + ToolRegistry + AgentEngineManager 端到端验证 ——
* 先创建会话引擎(复现"已打开会话"),再连接 MCP server,断言引擎请求
* 的 tools 集合动态更新。
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
// ===== Mock MCP SDK =====
const mockConnect = vi.fn(async () => undefined);
const mockListTools = vi.fn(async () => ({
tools: [
{
name: 'search_docs',
description: 'Search documentation',
inputSchema: { type: 'object', properties: { query: { type: 'string' } } },
},
],
}));
const mockClose = vi.fn(async () => undefined);
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
Client: class {
connect = mockConnect;
listTools = mockListTools;
close = mockClose;
callTool = vi.fn(async () => ({ content: [] }));
},
}));
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
StdioClientTransport: class {
// 测试不真实 spawn 子进程(真实 stdio 会拉起 npx 进程)
},
}));
import { MCPManager } from '../../../services/mcp-manager.service';
import { AgentEngineManager } from '../../../services/agent-engine-manager.service';
import { ToolRegistry } from '../registry';
import type {
IMetonaProviderAdapter,
MetonaRequest,
MetonaResponse,
MetonaStreamEvent,
} from '../../types';
import { MetonaStreamEventType } from '../../types';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
// connectServer 成功后会写 mcp_servers 表(last_connected)— 用真实 :memory: SQLite
let dbAvailable = true;
let Database: typeof import('better-sqlite3');
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
Database = require('better-sqlite3');
const probe = new Database(':memory:');
probe.close();
} catch {
dbAvailable = false;
}
/** 记录请求的 mock adapter */
function createRecordingAdapter(requests: MetonaRequest[]): IMetonaProviderAdapter {
return {
providerId: 'mock',
supportedModels: ['mock-model'],
supportsToolCalling: true,
supportsThinking: false,
getContextWindow: () => 1_000_000,
send: vi.fn(
async (): Promise<MetonaResponse> => ({
meta: { requestId: 'r', provider: 'mock', model: 'm', latencyMs: 1, timestamp: Date.now() },
content: 'ok',
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
finishReason: 'stop' as never,
}),
),
sendStream: vi.fn(async function* (req: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
requests.push(req);
yield {
type: MetonaStreamEventType.TEXT_DELTA,
requestId: 'r',
sessionId: req.meta.sessionId,
iteration: req.meta.iteration,
seq: 0,
timestamp: Date.now(),
delta: 'done',
};
yield {
type: MetonaStreamEventType.DONE,
requestId: 'r',
sessionId: req.meta.sessionId,
iteration: req.meta.iteration,
seq: 1,
timestamp: Date.now(),
};
}),
setAbortSignal: vi.fn(),
healthCheck: async () => true,
};
}
const userMessage = { role: 'user' as const, content: 'hello', timestamp: Date.now() };
const systemPrompt = { roleDefinition: '', outputConstraints: '', safetyGuidelines: '' };
describe.skipIf(!dbAvailable)('MCP 工具动态变更 × 引擎同步(v0.5.3)', () => {
let db: any;
let dir: string;
beforeEach(() => {
mockConnect.mockClear();
mockListTools.mockClear();
mockClose.mockClear();
dir = mkdtempSync(join(tmpdir(), 'metona-mcp-sync-'));
db = new Database(':memory:');
db.exec(`
CREATE TABLE mcp_servers (
id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE,
transport TEXT NOT NULL, command TEXT, args TEXT, url TEXT, headers TEXT,
enabled INTEGER NOT NULL DEFAULT 1, last_connected INTEGER, error_message TEXT,
created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0
);
`);
});
afterEach(() => {
try {
db?.close();
} catch {
/* ignore */
}
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
});
it('运行中连接 MCP server 后,已存在引擎的工具列表动态更新', async () => {
const requests: MetonaRequest[] = [];
const adapter = createRecordingAdapter(requests);
const registry = new ToolRegistry();
const manager = new AgentEngineManager({
buildAdapter: () => adapter,
baseConfig: {},
toolRegistry: registry,
});
const mcpManager = new MCPManager(() => db, registry);
// v0.5.3 的接线:MCP 工具变更 → 同步全部已存在引擎
mcpManager.setOnToolsChanged(() => {
manager.setToolsAll(registry.listTools());
});
// 1. 用户先打开会话(引擎已存在,此时无 MCP 工具)
const engine = manager.getEngine('sess-mcp');
await engine.runStream(userMessage, 'sess-mcp', [], systemPrompt);
expect(requests[0].tools ?? []).toHaveLength(0);
// 2. 运行中添加并连接 MCP server(复现 ipc/mcp.ts addServer 路径)
await mcpManager.connectServer({
id: 'mcp_test',
name: 'test-server',
transport: 'stdio',
command: 'npx',
args: ['-y', 'some-mcp-server'],
enabled: true,
});
// 3. 同一会话再发消息 —— 引擎请求携带新 MCP 工具(修复点:
// v0.5.3 之前此处仍为空,需新建会话才能拿到)
requests.length = 0;
await engine.runStream(userMessage, 'sess-mcp', [], systemPrompt);
const toolNames = (requests[0].tools ?? []).map((t) => t.name);
expect(toolNames).toContain('mcp_test-server_search_docs');
});
it('断开 MCP server 后,已存在引擎移除失效工具定义', async () => {
const requests: MetonaRequest[] = [];
const adapter = createRecordingAdapter(requests);
const registry = new ToolRegistry();
const manager = new AgentEngineManager({
buildAdapter: () => adapter,
baseConfig: {},
toolRegistry: registry,
});
const mcpManager = new MCPManager(() => db, registry);
mcpManager.setOnToolsChanged(() => {
manager.setToolsAll(registry.listTools());
});
// 连接 → 引擎拿到工具
await mcpManager.connectServer({
id: 'mcp_test2',
name: 'srv2',
transport: 'stdio',
command: 'npx',
args: [],
enabled: true,
});
const engine = manager.getEngine('sess-mcp2');
await engine.runStream(userMessage, 'sess-mcp2', [], systemPrompt);
expect((requests[0].tools ?? []).length).toBeGreaterThan(0);
// 断开 → 引擎的后续请求不再携带失效工具(修复点:此前引擎持有
// 失效定义,模型发起调用时才报 Unknown tool
await mcpManager.disconnectServer('srv2');
requests.length = 0;
await engine.runStream(userMessage, 'sess-mcp2', [], systemPrompt);
expect(requests[0].tools ?? []).toHaveLength(0);
});
it('回调抛异常不影响 MCP 连接主流程(防御性)', async () => {
const registry = new ToolRegistry();
const mcpManager = new MCPManager(() => db, registry);
mcpManager.setOnToolsChanged(() => {
throw new Error('callback exploded');
});
// 连接仍成功(回调失败仅记录日志)
await expect(
mcpManager.connectServer({
id: 'mcp_test3',
name: 'srv3',
transport: 'stdio',
command: 'npx',
args: [],
enabled: true,
}),
).resolves.toBeUndefined();
expect(registry.listTools().length).toBeGreaterThan(0);
});
});