fix: v0.5.3 模型能力完整性修复 — MCP 工具动态同步 + maxTokens 模型上限钳制
背景: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:
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* MCP 工具动态变更 × 引擎同步测试(v0.5.3)
|
||||
*
|
||||
* 背景:MCP 工具在运行中增删(添加/断开/启停 server)后,已存在引擎的
|
||||
* 工具列表不会自动更新 —— setToolsAll 只在启动期与工具开关时被调用。
|
||||
* 后果:已打开会话拿不到新 MCP 工具,或持有已断开 server 的失效工具定义。
|
||||
* v0.5.3 修复:MCPManager.setOnToolsChanged 回调,连接/断开后同步全部引擎。
|
||||
*
|
||||
* 测试策略:mock MCP SDK(Client/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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user