Files
metona-ai-desktop/electron/harness/adapters/__tests__/max-tokens-clamp.test.ts
T
thzxx 3a30e8f5b4
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m21s
CI / 产物编译验证 (push) Successful in 9m59s
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 成功
2026-08-21 22:36:52 +08:00

197 lines
6.5 KiB
TypeScript
Raw 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.
/**
* Provider maxTokens 上限钳制契约测试(v0.5.3
*
* 背景:引擎默认 maxTokens=63488engine.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);
});
});