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,196 @@
|
||||
/**
|
||||
* Provider maxTokens 上限钳制契约测试(v0.5.3)
|
||||
*
|
||||
* 背景:引擎默认 maxTokens=63488(engine.ts DEFAULT_CONFIG),超过部分模型
|
||||
* 上限时 API 直接 400 —— OpenAI gpt-4o(16384)/gpt-4.1(32768)、Anthropic
|
||||
* opus/haiku(32000)、MiMo standard(32768) 曾不可用。v0.5.3 各 adapter 按
|
||||
* MODEL_INFO.maxOutputTokens 钳制。
|
||||
*
|
||||
* 测试策略(v0.5.2 教训):mock fetch 记录真实请求体并断言契约 ——
|
||||
* 不 mock adapter 内部方法,验证"发出的 HTTP 请求体"这个最终事实。
|
||||
*/
|
||||
|
||||
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() },
|
||||
}));
|
||||
|
||||
import { DeepSeekAdapter } from '../deepseek.adapter';
|
||||
import { AgnesAdapter } from '../agnes-ai.adapter';
|
||||
import { MimoAdapter } from '../mimo.adapter';
|
||||
import { OpenAIAdapter } from '../openai.adapter';
|
||||
import { AnthropicAdapter } from '../anthropic.adapter';
|
||||
import type { MetonaRequest } from '../../types';
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
/** 引擎默认形态的请求(maxTokens=63488,与 engine DEFAULT_CONFIG 一致) */
|
||||
function makeRequest(overrides: Partial<MetonaRequest> = {}): MetonaRequest {
|
||||
return {
|
||||
meta: {
|
||||
sessionId: 's',
|
||||
iteration: 1,
|
||||
requestId: 'r',
|
||||
timestamp: Date.now(),
|
||||
agentVersion: '1',
|
||||
},
|
||||
systemPrompt: { roleDefinition: '', outputConstraints: '', safetyGuidelines: '' },
|
||||
messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
|
||||
params: {
|
||||
maxTokens: 63_488,
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
thinkingEnabled: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** OpenAI 兼容系成功响应 */
|
||||
function openAIResponse(): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ choices: [{ message: { content: 'ok' } }], usage: {} }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
/** Anthropic 成功响应 */
|
||||
function anthropicResponse(): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ content: [{ type: 'text', text: 'ok' }], usage: {} }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
/** 取 mock fetch 收到的请求体 */
|
||||
function requestBody(): Record<string, unknown> {
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
return JSON.parse(init.body as string) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => {
|
||||
it('DeepSeek v4 pro(上限 384K):63488 未超限,原样传递', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new DeepSeekAdapter({
|
||||
provider: 'deepseek',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'sk',
|
||||
defaultModel: 'deepseek-v4-pro',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('Agnes flash(上限 65536):63488 未超限,原样传递', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new AgnesAdapter({
|
||||
provider: 'agnes',
|
||||
baseURL: 'https://api.agnes.com/v1',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'agnes-2.0-flash',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('MiMo standard(上限 32768):钳制到 32768(原为 400 错误场景)', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new MimoAdapter({
|
||||
provider: 'mimo',
|
||||
baseURL: 'https://api.mimo.com/v1',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'mimo-v2.5',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_completion_tokens).toBe(32_768);
|
||||
});
|
||||
|
||||
it('MiMo pro(上限 131072):63488 未超限,原样传递', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new MimoAdapter({
|
||||
provider: 'mimo',
|
||||
baseURL: 'https://api.mimo.com/v1',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'mimo-v2.5-pro',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_completion_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('OpenAI gpt-4o(上限 16384):钳制到 16384(原为 400 错误场景)', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new OpenAIAdapter({
|
||||
provider: 'openai',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'gpt-4o',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_tokens).toBe(16_384);
|
||||
});
|
||||
|
||||
it('OpenAI o3-mini(上限 100K):63488 未超限,推理模型字段名正确', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const adapter = new OpenAIAdapter({
|
||||
provider: 'openai',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'o3-mini',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_completion_tokens).toBe(63_488);
|
||||
expect(requestBody().max_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Anthropic opus(上限 32000):钳制到 32000(原为 400 错误场景)', async () => {
|
||||
mockFetch.mockResolvedValue(anthropicResponse());
|
||||
const adapter = new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
baseURL: 'https://api.anthropic.com',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'claude-opus-4-1',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_tokens).toBe(32_000);
|
||||
});
|
||||
|
||||
it('Anthropic sonnet(上限 64000):63488 未超限', async () => {
|
||||
mockFetch.mockResolvedValue(anthropicResponse());
|
||||
const adapter = new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
baseURL: 'https://api.anthropic.com',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'claude-sonnet-4-5',
|
||||
});
|
||||
await adapter.send(makeRequest());
|
||||
expect(requestBody().max_tokens).toBe(63_488);
|
||||
});
|
||||
|
||||
it('未配置 maxTokens 时各 adapter 使用安全默认值(不超过模型上限)', async () => {
|
||||
mockFetch.mockResolvedValue(openAIResponse());
|
||||
const request = makeRequest({
|
||||
params: { temperature: 0, stream: false } as MetonaRequest['params'],
|
||||
});
|
||||
|
||||
// MiMo standard + thinking 默认 → 兜底 32768(= 上限,安全)
|
||||
const mimo = new MimoAdapter({
|
||||
provider: 'mimo',
|
||||
baseURL: 'https://api.mimo.com/v1',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'mimo-v2.5',
|
||||
});
|
||||
await mimo.send(request);
|
||||
expect(requestBody().max_completion_tokens).toBe(32_768);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user