diff --git a/README.md b/README.md index aec805d..a1d7cf9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version + Version License Electron React @@ -865,7 +865,7 @@ npm run format # Prettier 格式化 # ─── 测试 ───────────────────────────────── npm test # 运行单元测试 (Vitest, 系统 Node — audit 套件因 better-sqlite3 ABI 自动跳过) -npm run test:electron # 运行全量单元测试 (Electron Node ABI, 224 用例全执行, 含 SQLite 审计链哈希 + 引擎工具链集成) +npm run test:electron # 运行全量单元测试 (Electron Node ABI, 236 用例全执行, 含 SQLite 审计链哈希 + 引擎工具链集成) npm run test:watch # 测试监听模式 # ─── 构建 ───────────────────────────────── diff --git a/electron/harness/adapters/__tests__/max-tokens-clamp.test.ts b/electron/harness/adapters/__tests__/max-tokens-clamp.test.ts new file mode 100644 index 0000000..b17df34 --- /dev/null +++ b/electron/harness/adapters/__tests__/max-tokens-clamp.test.ts @@ -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 { + 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 { + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + return JSON.parse(init.body as string) as Record; +} + +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); + }); +}); diff --git a/electron/harness/adapters/agnes-ai.adapter.ts b/electron/harness/adapters/agnes-ai.adapter.ts index 597afe8..c0fca1f 100644 --- a/electron/harness/adapters/agnes-ai.adapter.ts +++ b/electron/harness/adapters/agnes-ai.adapter.ts @@ -48,21 +48,25 @@ export class AgnesAdapter extends BaseAdapter { const body = this.toNativeRequest(request, false); // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理 - const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.config.apiKey}`, - ...this.config.headers, + const response = await this.fetchWithTimeout( + `${this.config.baseURL}/chat/completions`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.apiKey}`, + ...this.config.headers, + }, + body: JSON.stringify(body), }, - body: JSON.stringify(body), - }, this.config.timeoutMs ?? 300_000); + this.config.timeoutMs ?? 300_000, + ); if (!response.ok) { await this.throwHttpError(response, 'Agnes AI API error'); } - const data = await response.json() as Record; + const data = (await response.json()) as Record; const parsed = parseOpenAICompatibleResponse(data); return { @@ -88,15 +92,19 @@ export class AgnesAdapter extends BaseAdapter { const body = this.toNativeRequest(request, true); // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理 - const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.config.apiKey}`, - ...this.config.headers, + const response = await this.fetchWithTimeout( + `${this.config.baseURL}/chat/completions`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.apiKey}`, + ...this.config.headers, + }, + body: JSON.stringify(body), }, - body: JSON.stringify(body), - }, this.config.timeoutMs ?? 300_000); + this.config.timeoutMs ?? 300_000, + ); if (!response.ok || !response.body) { await this.throwHttpError(response, 'Agnes AI stream error'); @@ -170,15 +178,22 @@ export class AgnesAdapter extends BaseAdapter { } if (imageCount > 0) { - const firstUrl = request.messages.find(m => m.images?.length)?.images?.[0]?.url ?? ''; - log.info(`[Agnes] Processing ${imageCount} image(s), first URL prefix: ${firstUrl.slice(0, 50)}`); + const firstUrl = request.messages.find((m) => m.images?.length)?.images?.[0]?.url ?? ''; + log.info( + `[Agnes] Processing ${imageCount} image(s), first URL prefix: ${firstUrl.slice(0, 50)}`, + ); } + // v0.5.3: max_tokens 按模型上限钳制(agnes-2.0-flash 上限 65536) + const modelInfo = AgnesAdapter.MODEL_INFO[this.config.defaultModel]; + const maxOutput = modelInfo?.maxOutputTokens ?? 65_536; + const maxTokens = Math.min(request.params.maxTokens ?? maxOutput, maxOutput); + const body: Record = { model: this.config.defaultModel, messages, temperature: request.params.temperature, - max_tokens: request.params.maxTokens ?? 65536, + max_tokens: maxTokens, stream, }; diff --git a/electron/harness/adapters/anthropic.adapter.ts b/electron/harness/adapters/anthropic.adapter.ts index 60dc813..09c15fc 100644 --- a/electron/harness/adapters/anthropic.adapter.ts +++ b/electron/harness/adapters/anthropic.adapter.ts @@ -72,17 +72,21 @@ export class AnthropicAdapter extends BaseAdapter { async send(request: MetonaRequest): Promise { const body = await this.toNativeRequest(request, false); - const response = await this.fetchWithTimeout(`${this.config.baseURL}/v1/messages`, { - method: 'POST', - headers: this.buildHeaders(), - body: JSON.stringify(body), - }, this.config.timeoutMs ?? 120_000); + const response = await this.fetchWithTimeout( + `${this.config.baseURL}/v1/messages`, + { + method: 'POST', + headers: this.buildHeaders(), + body: JSON.stringify(body), + }, + this.config.timeoutMs ?? 120_000, + ); if (!response.ok) { await this.throwHttpError(response, 'Anthropic API error'); } - const data = await response.json() as Record; + const data = (await response.json()) as Record; return this.toMetonaResponse(data, request.meta.requestId); } @@ -90,11 +94,15 @@ export class AnthropicAdapter extends BaseAdapter { async *sendStream(request: MetonaRequest): AsyncIterable { const body = await this.toNativeRequest(request, true); - const response = await this.fetchWithTimeout(`${this.config.baseURL}/v1/messages`, { - method: 'POST', - headers: this.buildHeaders(), - body: JSON.stringify(body), - }, this.config.timeoutMs ?? 300_000); + const response = await this.fetchWithTimeout( + `${this.config.baseURL}/v1/messages`, + { + method: 'POST', + headers: this.buildHeaders(), + body: JSON.stringify(body), + }, + this.config.timeoutMs ?? 300_000, + ); if (!response.ok || !response.body) { await this.throwHttpError(response, 'Anthropic stream error'); @@ -140,7 +148,11 @@ export class AnthropicAdapter extends BaseAdapter { if (delta?.type === 'text_delta' && typeof delta.text === 'string') { events.push({ type: MetonaStreamEventType.TEXT_DELTA, ...base(), delta: delta.text }); } else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') { - events.push({ type: MetonaStreamEventType.REASONING_DELTA, ...base(), delta: delta.thinking }); + events.push({ + type: MetonaStreamEventType.REASONING_DELTA, + ...base(), + delta: delta.thinking, + }); } else if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') { const block = toolBlocks.get(index); if (block) { @@ -189,7 +201,8 @@ export class AnthropicAdapter extends BaseAdapter { usage: { inputTokens: (this.lastInputTokens as number) ?? 0, outputTokens: (usage.output_tokens as number) ?? 0, - totalTokens: ((this.lastInputTokens as number) ?? 0) + ((usage.output_tokens as number) ?? 0), + totalTokens: + ((this.lastInputTokens as number) ?? 0) + ((usage.output_tokens as number) ?? 0), }, }); } @@ -250,7 +263,10 @@ export class AnthropicAdapter extends BaseAdapter { yield ev; } } catch (parseErr) { - log.warn(`[Anthropic] Failed to parse SSE line: ${(parseErr as Error).message}`, trimmed.slice(0, 200)); + log.warn( + `[Anthropic] Failed to parse SSE line: ${(parseErr as Error).message}`, + trimmed.slice(0, 200), + ); } } } @@ -291,7 +307,10 @@ export class AnthropicAdapter extends BaseAdapter { * 4. 连续同角色消息合并(API 要求严格交替) * 5. 首条消息必须为 user(历史以 assistant 开头时补占位) */ - private async toNativeRequest(request: MetonaRequest, stream: boolean): Promise> { + private async toNativeRequest( + request: MetonaRequest, + stream: boolean, + ): Promise> { // System Prompt 拼接(Anthropic 使用顶层 system 字段) const system = [ request.systemPrompt.roleDefinition, @@ -303,7 +322,10 @@ export class AnthropicAdapter extends BaseAdapter { .join('\n\n'); // 转换消息(非 system) - const converted: Array<{ role: 'user' | 'assistant'; content: Array> }> = []; + const converted: Array<{ + role: 'user' | 'assistant'; + content: Array>; + }> = []; for (const m of request.messages) { if (m.role === 'system') continue; @@ -316,7 +338,9 @@ export class AnthropicAdapter extends BaseAdapter { : JSON.stringify(m.toolResult.result); converted.push({ role: 'user', - content: [{ type: 'tool_result', tool_use_id: m.toolResult.toolCallId, content: contentStr }], + content: [ + { type: 'tool_result', tool_use_id: m.toolResult.toolCallId, content: contentStr }, + ], }); continue; } @@ -345,7 +369,8 @@ export class AnthropicAdapter extends BaseAdapter { } // 合并连续同角色消息(Anthropic 要求 user/assistant 交替) - const merged: Array<{ role: 'user' | 'assistant'; content: Array> }> = []; + const merged: Array<{ role: 'user' | 'assistant'; content: Array> }> = + []; for (const msg of converted) { const last = merged[merged.length - 1]; if (last && last.role === msg.role) { @@ -357,12 +382,20 @@ export class AnthropicAdapter extends BaseAdapter { // 首条消息必须为 user if (merged.length === 0 || merged[0].role !== 'user') { - merged.unshift({ role: 'user', content: [{ type: 'text', text: '[Conversation history follows]' }] }); + merged.unshift({ + role: 'user', + content: [{ type: 'text', text: '[Conversation history follows]' }], + }); } + // v0.5.3: max_tokens 按模型上限钳制(sonnet 64000 / opus 32000 / haiku 32000)— + // 引擎默认 63488 超过 opus/haiku 上限时 API 直接 400;thinking budget 已在此值内二分 + const anthropicMaxOutput = + AnthropicAdapter.MODEL_INFO[this.config.defaultModel]?.maxOutputTokens ?? 64_000; + const body: Record = { model: this.config.defaultModel, - max_tokens: request.params.maxTokens ?? 8192, + max_tokens: Math.min(request.params.maxTokens ?? 8192, anthropicMaxOutput), system, messages: merged, stream, @@ -379,7 +412,12 @@ export class AnthropicAdapter extends BaseAdapter { // Thinking 模式:budget_tokens(必须小于 max_tokens,此处钳制到一半) if (request.params.thinkingEnabled) { - const budgetMap: Record = { low: 1024, medium: 4096, high: 16384, max: 32768 }; + const budgetMap: Record = { + low: 1024, + medium: 4096, + high: 16384, + max: 32768, + }; const budget = Math.min( budgetMap[request.params.thinkingEffort ?? 'high'] ?? 16384, Math.floor((body.max_tokens as number) / 2), @@ -435,7 +473,8 @@ export class AnthropicAdapter extends BaseAdapter { for (const block of contentBlocks) { if (block.type === 'text') text += (block.text as string) ?? ''; - else if (block.type === 'thinking') reasoningContent = (block.thinking as string) ?? undefined; + else if (block.type === 'thinking') + reasoningContent = (block.thinking as string) ?? undefined; else if (block.type === 'tool_use') { let args: Record = {}; const rawInput = block.input; @@ -453,9 +492,11 @@ export class AnthropicAdapter extends BaseAdapter { const usage = (data.usage as Record) ?? {}; const stopReason = (data.stop_reason as string) ?? 'end_turn'; const finishReason: MetonaFinishReason = - stopReason === 'tool_use' ? MetonaFinishReason.TOOL_CALLS - : stopReason === 'max_tokens' ? MetonaFinishReason.LENGTH - : MetonaFinishReason.STOP; + stopReason === 'tool_use' + ? MetonaFinishReason.TOOL_CALLS + : stopReason === 'max_tokens' + ? MetonaFinishReason.LENGTH + : MetonaFinishReason.STOP; return { meta: { diff --git a/electron/harness/adapters/deepseek.adapter.ts b/electron/harness/adapters/deepseek.adapter.ts index 52961fc..dfaae77 100644 --- a/electron/harness/adapters/deepseek.adapter.ts +++ b/electron/harness/adapters/deepseek.adapter.ts @@ -239,11 +239,16 @@ export class DeepSeekAdapter extends BaseAdapter { const messages = buildOpenAICompatibleMessages(request); const tools = buildOpenAICompatibleTools(request.tools); + // v0.5.3: max_tokens 按模型上限钳制 — 引擎默认 63488 超过部分模型上限时 API 直接 400 + const modelInfo = DeepSeekAdapter.MODEL_INFO[this.config.defaultModel]; + const maxOutput = modelInfo?.maxOutputTokens ?? 384_000; + const maxTokens = Math.min(request.params.maxTokens ?? maxOutput, maxOutput); + const body: Record = { model: this.config.defaultModel, messages, temperature: request.params.temperature, - max_tokens: request.params.maxTokens, + max_tokens: maxTokens, stream, }; diff --git a/electron/harness/adapters/mimo.adapter.ts b/electron/harness/adapters/mimo.adapter.ts index 1da034b..adce04e 100644 --- a/electron/harness/adapters/mimo.adapter.ts +++ b/electron/harness/adapters/mimo.adapter.ts @@ -59,21 +59,25 @@ export class MimoAdapter extends BaseAdapter { const body = this.toNativeRequest(request, false); // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理 - const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.config.apiKey}`, - ...this.config.headers, + const response = await this.fetchWithTimeout( + `${this.config.baseURL}/chat/completions`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.apiKey}`, + ...this.config.headers, + }, + body: JSON.stringify(body), }, - body: JSON.stringify(body), - }, this.config.timeoutMs ?? 120_000); + this.config.timeoutMs ?? 120_000, + ); if (!response.ok) { await this.throwHttpError(response, 'MiMo API error'); } - const data = await response.json() as Record; + const data = (await response.json()) as Record; const parsed = parseOpenAICompatibleResponse(data); return { @@ -98,15 +102,19 @@ export class MimoAdapter extends BaseAdapter { const body = this.toNativeRequest(request, true); // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理 - const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.config.apiKey}`, - ...this.config.headers, + const response = await this.fetchWithTimeout( + `${this.config.baseURL}/chat/completions`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.apiKey}`, + ...this.config.headers, + }, + body: JSON.stringify(body), }, - body: JSON.stringify(body), - }, this.config.timeoutMs ?? 300_000); + this.config.timeoutMs ?? 300_000, + ); if (!response.ok || !response.body) { await this.throwHttpError(response, 'MiMo stream error'); @@ -187,16 +195,18 @@ export class MimoAdapter extends BaseAdapter { messages[i].content = contentParts; } + // MiMo 使用 max_completion_tokens(非 max_tokens) + // #41 修复: thinking 模式下未配置时兜底 32768(thinking 占用 token 配额,API 默认值过小会截断输出) + // v0.5.3: 按模型上限钳制(pro 131072 / standard 32768)— + // 引擎默认 63488 超过 standard 上限时 API 直接 400 + const mimoMaxOutput = + MimoAdapter.MODEL_INFO[this.config.defaultModel]?.maxOutputTokens ?? 131_072; + const mimoDefault = request.params.thinkingEnabled !== false ? 32_768 : mimoMaxOutput; + const body: Record = { model: this.config.defaultModel, messages, - // MiMo 使用 max_completion_tokens(非 max_tokens) - // #41 修复: thinking 模式下 max_completion_tokens 未配置时使用兜底默认值(32768) - // thinking 占用 token 配额,未配置时 API 默认值可能过小导致输出被截断 - // thinkingEnabled !== false 包含 true 和 undefined(MiMo 默认 enabled)两种情况 - // 审查修复: 兜底值从 63488 改为 32768,避免超出标准版上限导致 API 400 - max_completion_tokens: request.params.maxTokens - ?? (request.params.thinkingEnabled !== false ? 32768 : undefined), + max_completion_tokens: Math.min(request.params.maxTokens ?? mimoDefault, mimoMaxOutput), stream, }; diff --git a/electron/harness/adapters/openai.adapter.ts b/electron/harness/adapters/openai.adapter.ts index 6f8b5a9..5ce00ab 100644 --- a/electron/harness/adapters/openai.adapter.ts +++ b/electron/harness/adapters/openai.adapter.ts @@ -69,21 +69,25 @@ export class OpenAIAdapter extends BaseAdapter { async send(request: MetonaRequest): Promise { const body = this.toNativeRequest(request, false); - const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.config.apiKey}`, - ...this.config.headers, + const response = await this.fetchWithTimeout( + `${this.config.baseURL}/chat/completions`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.apiKey}`, + ...this.config.headers, + }, + body: JSON.stringify(body), }, - body: JSON.stringify(body), - }, this.config.timeoutMs ?? 120_000); + this.config.timeoutMs ?? 120_000, + ); if (!response.ok) { await this.throwHttpError(response, 'OpenAI API error'); } - const data = await response.json() as Record; + const data = (await response.json()) as Record; const parsed = parseOpenAICompatibleResponse(data); return { @@ -107,15 +111,19 @@ export class OpenAIAdapter extends BaseAdapter { async *sendStream(request: MetonaRequest): AsyncIterable { const body = this.toNativeRequest(request, true); - const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.config.apiKey}`, - ...this.config.headers, + const response = await this.fetchWithTimeout( + `${this.config.baseURL}/chat/completions`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.apiKey}`, + ...this.config.headers, + }, + body: JSON.stringify(body), }, - body: JSON.stringify(body), - }, this.config.timeoutMs ?? 300_000); + this.config.timeoutMs ?? 300_000, + ); if (!response.ok || !response.body) { await this.throwHttpError(response, 'OpenAI stream error'); @@ -139,7 +147,7 @@ export class OpenAIAdapter extends BaseAdapter { signal: AbortSignal.timeout(10_000), }); if (response.ok) { - const data = await response.json() as { data?: Array<{ id: string }> }; + const data = (await response.json()) as { data?: Array<{ id: string }> }; if (data.data?.length) { return data.data.map((m) => OpenAIAdapter.MODEL_INFO[m.id] ?? { id: m.id }); } @@ -208,15 +216,19 @@ export class OpenAIAdapter extends BaseAdapter { }; // Token 上限参数:o 系列/gpt-5 使用 max_completion_tokens - if (request.params.maxTokens) { + // v0.5.3: 按模型上限钳制(gpt-4o 16384 / gpt-4.1 32768 / o3-mini 100000)— + // 引擎默认 63488 超过 gpt-4o/gpt-4.1 上限时 API 直接 400 + const oaMaxOutput = OpenAIAdapter.MODEL_INFO[model]?.maxOutputTokens ?? 128_000; + const oaMaxTokens = Math.min( + request.params.maxTokens ?? (isReasoningModel ? 32_768 : oaMaxOutput), + oaMaxOutput, + ); + if (oaMaxTokens) { if (isReasoningModel) { - body.max_completion_tokens = request.params.maxTokens; + body.max_completion_tokens = oaMaxTokens; } else { - body.max_tokens = request.params.maxTokens; + body.max_tokens = oaMaxTokens; } - } else if (isReasoningModel) { - // 推理模型未配置时使用兜底值(thinking 占用 token 配额,默认值过小会被截断) - body.max_completion_tokens = 32_768; } if (stream) { @@ -229,7 +241,12 @@ export class OpenAIAdapter extends BaseAdapter { // Thinking 模式:推理模型映射 reasoning_effort;非推理模型忽略 if (request.params.thinkingEnabled && isReasoningModel) { - const effortMap: Record = { low: 'low', medium: 'medium', high: 'high', max: 'high' }; + const effortMap: Record = { + low: 'low', + medium: 'medium', + high: 'high', + max: 'high', + }; body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high'; } else if (!isReasoningModel) { // 非推理模型使用温度控制 diff --git a/electron/harness/tools/__tests__/mcp-tools-sync.test.ts b/electron/harness/tools/__tests__/mcp-tools-sync.test.ts new file mode 100644 index 0000000..f87fc08 --- /dev/null +++ b/electron/harness/tools/__tests__/mcp-tools-sync.test.ts @@ -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 => ({ + 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 { + 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); + }); +}); diff --git a/electron/main.ts b/electron/main.ts index 48c2663..3405952 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -405,8 +405,15 @@ async function initialize(): Promise { }; agentEngineManager.setFallbackAdapter(buildFallbackAdapter()); - // P1-11: MCP 初始化等待所有连接完成后再广播 tools:ready(修复工具未注册即广播的窗口) + // ===== P1-11: MCP 初始化等待所有连接完成后再广播 tools:ready(修复工具未注册即广播的窗口) ===== // v0.3.18: toolsReadyRef 供 tools:isReady 查询(解决事件竞态) + // v0.5.3: MCP 工具集合运行中变更(添加/断开/启停 server)→ 同步所有已存在引擎。 + // 此前仅启动期 setToolsAll;运行中增删后已打开会话的引擎拿不到新工具 + // (或持有失效定义,调用报 Unknown tool)。懒创建引擎由 createEngine + // 实时拉取(v0.5.2),此回调覆盖既有引擎。 + mcpManager.setOnToolsChanged(() => { + agentEngineManager.setToolsAll(toolRegistry.listTools()); + }); const toolsReadyRef: ToolsReadyRef = { ready: false, toolCount: 0 }; mcpManager .initialize() diff --git a/electron/services/mcp-manager.service.ts b/electron/services/mcp-manager.service.ts index fbbed18..ec24c85 100644 --- a/electron/services/mcp-manager.service.ts +++ b/electron/services/mcp-manager.service.ts @@ -219,12 +219,37 @@ class MCPToolAdapter implements IMetonaTool { export class MCPManager { private servers = new Map(); + /** + * 工具集合变更回调(v0.5.3) + * + * connectServer / disconnectServer 完成后触发,供调用方同步引擎工具列表。 + * 背景:setToolsAll 只作用于已存在的引擎;MCP 工具在运行中增删时, + * 已打开会话的引擎不会自动感知 — 不回调同步则已有引擎持有失效工具定义 + * (调用报 Unknown tool)或缺失新工具(与 README"无需重启"的宣称不符)。 + * 懒创建引擎由 createEngine 从 registry 实时拉取(v0.5.2),无需此回调。 + */ + private toolsChangedCallback: (() => void) | null = null; constructor( private getDB: () => Database.Database, private toolRegistry: ToolRegistry, ) {} + /** 注册工具集合变更回调(main.ts 在 AgentEngineManager 创建后注入) */ + setOnToolsChanged(callback: () => void): void { + this.toolsChangedCallback = callback; + } + + /** 工具集合变更后的统一通知(连接注册完成 / 断开注销完成) */ + private notifyToolsChanged(): void { + try { + this.toolsChangedCallback?.(); + } catch (err) { + // 回调失败不影响 MCP 主流程(仅引擎工具列表滞后,下轮 setToolsAll 兜底) + log.warn('[MCPManager] toolsChanged callback failed:', err); + } + } + /** * 初始化:从数据库加载已启用的 MCP Server 并连接 * @@ -342,6 +367,9 @@ export class MCPManager { this.toolRegistry.registerMCP(name, adapter); } + // v0.5.3: 工具集合已变化 — 通知调用方同步引擎工具列表(已存在引擎热更新) + this.notifyToolsChanged(); + // 更新状态 const state = this.servers.get(name)!; state.status = 'connected'; @@ -402,6 +430,9 @@ export class MCPManager { state.client = null; state.tools = []; + // v0.5.3: 工具集合已变化 — 通知调用方同步引擎(已有引擎需移除失效工具定义) + this.notifyToolsChanged(); + log.info(`MCP server "${name}" disconnected`); } diff --git a/package-lock.json b/package-lock.json index a6878c6..41c4501 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.5.1", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.5.1", + "version": "0.5.2", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index 73e0836..42677a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.5.2", + "version": "0.5.3", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team",