Files
thzxx e4d693c1c7
CI / 全量测试 (Electron ABI) (push) Failing after 5m21s
CI / 产物编译验证 (push) Successful in 10m6s
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m42s
fix: v0.5.2 紧急修复 — 工具调用完全失效(引擎懒创建不带工具)+ DeepSeek 余额解析错误
P0 工具调用失效(用户实测反馈:Agnes/DeepSeek 均无法调用工具):
- 根因:AgentEngineManager.createEngine 未调用 setTools。引擎是懒创建的
  (首次 sendMessage 时 getEngine),启动期的 setToolsAll 调用时 engines
  Map 为空(全是 no-op)→ 新引擎 this.tools=[] → LLM 请求不带 tools →
  模型无法发起 tool_call。症状与用户反馈完全吻合:模型口头说要调工具
  (模仿历史消息中的工具调用模式),实际不调,凭记忆瞎编结果。
- 引入点:v0.4.0 P2-10 每会话引擎重构(v0.3.x 全局单引擎时代 setTools
  直接作用于唯一引擎,无此问题)。
- 修复:createEngine 从 toolRegistry 拉取当前启用工具(registry 是启用
  状态的唯一事实源,MCP 后注册/工具开关场景均一致)。

P1 DeepSeek 余额显示错误(用户实测反馈:显示的不是真实余额):
- 根因:DeepSeek 官方 /user/balance 实际返回 balance_infos 数组格式,
  此前按扁平字段解析(data.total_balance)→ 恒为 undefined → 界面恒显示 0。
- 修复:优先解析 balance_infos[0],回退扁平格式(网关兼容);URL 规范化
  (剥离尾斜杠与 /v1 前缀 — 余额端点在根路径,chat 端点两种写法都合法)。

测试(215 → 224 用例):
- 新增 AgentEngineManager 回归测试 ×4:懒创建引擎的 LLM 请求必须携带
  registry 工具定义(本次事故的直接拦截测试)/ 禁用工具不出现 / setToolsAll
  热更新 / 无 registry 时行为不回归
- 新增 DeepSeek 余额解析测试 ×5:官方数组格式 / 扁平回退 / URL 规范化 /
  非 2xx / 网络异常
- 补强引擎链路测试:mock adapter 记录请求并断言 tools 契约 — 此前 mock
  无条件吐 tool_call 事件,掩盖了"请求未携带工具定义"的缺陷(复检盲区
  的直接教训:mock 必须断言请求契约,否则测试是道具)

验证: lint 0 / typecheck 双工程 0 / test:electron 224 全过 / build 成功
2026-08-21 22:11:11 +08:00

181 lines
6.6 KiB
TypeScript
Raw Permalink 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.
/**
* 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<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,
};
}
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();
});
});