/** * AgentEngineManager 回归测试(v0.5.2) * * 背景:v0.4.0 P2-10 引入每会话懒创建引擎后,createEngine 未调用 setTools, * 且启动期 setToolsAll 调用时 engines Map 为空(全是 no-op)→ 新引擎 tools=[] * → LLM 请求不带工具定义 → 模型无法发起 tool_call,只能凭历史记忆"口头说要 * 调工具"并瞎编结果。v0.5.2 在 createEngine 中从 registry 拉取工具修复。 * * 本测试直接断言"传给 adapter.sendStream 的请求携带工具"——这是唯一能 * 拦住该类回归的测试层级(引擎单测的 mock adapter 无条件吐 tool_call * 事件,会掩盖请求契约缺陷)。 */ import { describe, it, expect, vi } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); import { AgentEngineManager } from '../agent-engine-manager.service'; import type { IMetonaProviderAdapter, MetonaRequest, MetonaResponse, MetonaStreamEvent, } from '../../harness/types'; import { MetonaStreamEventType } from '../../harness/types'; import { ToolRegistry } from '../../harness/tools/registry'; import type { IMetonaTool } from '../../harness/types/metona-tool'; import { MetonaToolCategory, MetonaRiskLevel } from '../../harness/types'; /** 记录请求的 mock adapter — runStream 完成后检查 requests 数组 */ 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, }; } function makeTool(name: string): IMetonaTool { return { definition: { name, description: `${name} (test fixture)`, parameters: { type: 'object', properties: {}, required: [] }, category: MetonaToolCategory.FILESYSTEM, riskLevel: MetonaRiskLevel.SAFE, requiresPermission: false, timeoutMs: 5_000, }, async execute() { return { ok: true }; }, }; } const userMessage = { role: 'user' as const, content: 'hello', timestamp: Date.now() }; const systemPrompt = { roleDefinition: '', outputConstraints: '', safetyGuidelines: '' }; describe('AgentEngineManager — 懒创建引擎的工具传递(v0.5.2 回归修复)', () => { it('新会话首次 sendMessage 时,LLM 请求必须携带 registry 中的工具定义', async () => { const requests: MetonaRequest[] = []; const adapter = createRecordingAdapter(requests); const registry = new ToolRegistry(); registry.registerBuiltin(makeTool('read_file')); registry.registerBuiltin(makeTool('run_command')); // 模拟真实启动流程:manager 创建时 registry 已有工具, // 但引擎在首次 sendMessage 才懒创建(setToolsAll 此时是 no-op) const manager = new AgentEngineManager({ buildAdapter: () => adapter, baseConfig: {}, toolRegistry: registry, }); const engine = manager.getEngine('sess-lazy-1'); await engine.runStream(userMessage, 'sess-lazy-1', [], systemPrompt); // 核心断言:请求携带工具定义(回归点 — 修复前为 undefined) expect(requests).toHaveLength(1); expect(requests[0].tools).toBeDefined(); expect(requests[0].tools!.map((t) => t.name)).toContain('read_file'); expect(requests[0].tools!.map((t) => t.name)).toContain('run_command'); }); it('已禁用的工具不出现在懒创建引擎的请求中', async () => { const requests: MetonaRequest[] = []; const adapter = createRecordingAdapter(requests); const registry = new ToolRegistry(); registry.registerBuiltin(makeTool('read_file')); registry.registerBuiltin(makeTool('web_browser')); registry.setToolEnabled('web_browser', false); const manager = new AgentEngineManager({ buildAdapter: () => adapter, baseConfig: {}, toolRegistry: registry, }); const engine = manager.getEngine('sess-lazy-2'); await engine.runStream(userMessage, 'sess-lazy-2', [], systemPrompt); expect(requests[0].tools).toBeDefined(); expect(requests[0].tools!.map((t) => t.name)).toContain('read_file'); expect(requests[0].tools!.map((t) => t.name)).not.toContain('web_browser'); }); it('setToolsAll 仍可热更新已存在的引擎(工具开关变更场景)', async () => { const requests: MetonaRequest[] = []; const adapter = createRecordingAdapter(requests); const registry = new ToolRegistry(); registry.registerBuiltin(makeTool('read_file')); const manager = new AgentEngineManager({ buildAdapter: () => adapter, baseConfig: {}, toolRegistry: registry, }); const engine = manager.getEngine('sess-hot'); await engine.runStream(userMessage, 'sess-hot', [], systemPrompt); expect(requests[0].tools!.map((t) => t.name)).toContain('read_file'); // 热更新:新注册工具 + setToolsAll 同步 registry.registerBuiltin(makeTool('new_tool')); manager.setToolsAll(registry.listTools()); requests.length = 0; await engine.runStream(userMessage, 'sess-hot', [], systemPrompt); expect(requests[0].tools!.map((t) => t.name)).toContain('new_tool'); }); it('无 toolRegistry 时请求不带 tools(纯对话配置,行为不回归)', async () => { const requests: MetonaRequest[] = []; const adapter = createRecordingAdapter(requests); const manager = new AgentEngineManager({ buildAdapter: () => adapter, baseConfig: {}, }); const engine = manager.getEngine('sess-no-tools'); await engine.runStream(userMessage, 'sess-no-tools', [], systemPrompt); expect(requests[0].tools).toBeUndefined(); }); });