Files
qiming/qimingclaw/crates/agent-electron-client/src/main/services/engines/unifiedAgent.test.ts

1232 lines
44 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.
/**
* 单元测试: UnifiedAgentService — MCP 配置变更检测 + 懒加载引擎生命周期
*
* 覆盖修复内容:
* 1. rawMcpServersEqual(): 静态辅助方法,同格式比较原始 MCP server 配置
* 2. detectConfigChange(): 跨格式误判修复验证(通过 rawMcpServersEqual 间接覆盖)
* 3. 懒加载引擎init 触发 loadAcpSdkgetOrCreateEngine 创建/复用destroy 清理引擎
*
* 背景:原实现将 proxy 包装格式currentConfig.mcpServers, command=mcp-proxy
* 与原始格式requestMcpServersEarly, command=uvx/uv/npx直接比较
* 导致配置未变化时仍触发引擎重建Windows 每次多花 13s
* 修复后,两侧均使用原始格式做同格式比较。
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
const sandboxPolicyState = {
current: {
enabled: true,
backend: "auto",
mode: "compat",
autoFallback: "startup-only",
windowsMode: "workspace-write",
},
};
// ───── 必需 Mocks防止导入链拉起 Electron / IPC 等重模块)──────────────────
vi.mock("electron-log", () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("electron", () => ({
app: {
getPath: vi.fn(() => "/mock/appdata"),
getAppPath: vi.fn(() => "/mock/app"),
isPackaged: false,
},
ipcMain: { on: vi.fn(), handle: vi.fn() },
}));
vi.mock("../system/dependencies", () => ({
default: {
getAppDataDir: vi.fn(() => "/mock/appdata"),
},
getAppEnv: vi.fn(() => ({
PATH: "/mock/path",
HOME: "/mock/home",
})),
getNodeBinPathWithFallback: vi.fn(() => "/mock/node"),
}));
vi.mock("../memory", () => ({
memoryService: {
isInitialized: vi.fn(() => false),
init: vi.fn().mockResolvedValue(undefined),
setSchedulerModelConfig: vi.fn(),
ensureMemoryReadyForSession: vi.fn().mockResolvedValue(undefined),
onSessionEnd: vi.fn().mockResolvedValue(undefined),
destroy: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock("./acp/acpClient", () => ({
loadAcpSdk: vi.fn(() => Promise.resolve({})),
}));
vi.mock("../sandbox/policyCache", () => ({
getCachedSandboxPolicy: vi.fn(() => sandboxPolicyState.current),
}));
vi.mock("./acp/acpEngine", () => {
const createMockEngine = (
config: { apiKey?: string; baseUrl?: string; model?: string } = {},
) => ({
currentConfig: { apiKey: "k", baseUrl: "u", model: "m", ...config },
init: vi.fn().mockResolvedValue(true),
updateConfig: vi.fn(),
removeAllListeners: vi.fn(),
destroy: vi.fn().mockResolvedValue(undefined),
isReady: true,
getActivePromptCount: vi.fn(() => 0),
engineName: "qimingcode" as const,
on: vi.fn(),
});
return {
AcpEngine: vi.fn().mockImplementation(() => createMockEngine()),
};
});
// ────────────────────────────────────────────────────────────────────────────
import { UnifiedAgentService } from "./unifiedAgent";
import {
filterBridgeEntries,
rawMcpServersEqual,
} from "../packages/mcpHelpers";
import type { McpServerEntry } from "../packages/mcp";
// ────────────────────────────────────────────────────────────────────────────
// 辅助:构建 stdio MCP server entry
// ────────────────────────────────────────────────────────────────────────────
function makeStdio(
command: string,
args: string[],
extra?: Partial<McpServerEntry>,
): McpServerEntry {
return { command, args, ...extra } as McpServerEntry;
}
// ────────────────────────────────────────────────────────────────────────────
// UnifiedAgentService.rawMcpServersEqual
// ────────────────────────────────────────────────────────────────────────────
describe("rawMcpServersEqual", () => {
const eq = rawMcpServersEqual;
describe("空值处理", () => {
it("b 为 undefined 时返回 false代表首次无历史快照", () => {
const a = { time: makeStdio("/uv", ["tool", "run", "mcp-time"]) };
expect(eq(a, undefined)).toBe(false);
});
it("两侧均为空对象时返回 true", () => {
expect(eq({}, {})).toBe(true);
});
it("a 为空、b 非空时返回 false服务器被删除", () => {
const b = { time: makeStdio("/uv", ["tool", "run", "mcp-time"]) };
expect(eq({}, b)).toBe(false);
});
it("a 非空、b 为空时返回 false服务器被新增", () => {
const a = { time: makeStdio("/uv", ["tool", "run", "mcp-time"]) };
expect(eq(a, {})).toBe(false);
});
});
describe("相同配置 → 不触发重建", () => {
it("单个 stdio servercommand/args 相同", () => {
const entry = makeStdio("/uv", ["tool", "run", "mcp-server-time"]);
expect(eq({ time: entry }, { time: { ...entry } })).toBe(true);
});
it("多个 serverkey 顺序不同但内容相同", () => {
const a = {
fetch: makeStdio("/uv", ["tool", "run", "mcp-server-fetch"]),
time: makeStdio("/uv", ["tool", "run", "mcp-server-time"]),
};
const b = {
time: makeStdio("/uv", ["tool", "run", "mcp-server-time"]),
fetch: makeStdio("/uv", ["tool", "run", "mcp-server-fetch"]),
};
expect(eq(a, b)).toBe(true);
});
it("env 字段不同时仍认为相等env 每次由 getAppEnv 重新注入)", () => {
const a = {
time: makeStdio("/uv", ["tool", "run", "mcp-time"], {
env: { TZ: "UTC" },
}),
};
const b = {
time: makeStdio("/uv", ["tool", "run", "mcp-time"], {
env: { TZ: "Asia/Shanghai" },
}),
};
expect(eq(a, b)).toBe(true);
});
it("allowTools 顺序不同时认为相等(排序后比较)", () => {
const a = {
time: makeStdio("/uv", ["run", "mcp-time"], {
allowTools: ["get_time", "convert_time"],
}),
};
const b = {
time: makeStdio("/uv", ["run", "mcp-time"], {
allowTools: ["convert_time", "get_time"],
}),
};
expect(eq(a, b)).toBe(true);
});
it("denyTools 顺序不同时认为相等(排序后比较)", () => {
const a = {
time: makeStdio("/uv", ["run", "mcp-time"], { denyTools: ["b", "a"] }),
};
const b = {
time: makeStdio("/uv", ["run", "mcp-time"], { denyTools: ["a", "b"] }),
};
expect(eq(a, b)).toBe(true);
});
});
describe("配置实际变化 → 应触发重建", () => {
it("command 变化时返回 false", () => {
const a = { fetch: makeStdio("/uv", ["tool", "run", "mcp-fetch"]) };
const b = { fetch: makeStdio("/uvx", ["mcp-fetch"]) };
expect(eq(a, b)).toBe(false);
});
it("args 变化时返回 false", () => {
const a = {
time: makeStdio("/uv", [
"tool",
"run",
"mcp-time",
"--local-timezone=UTC",
]),
};
const b = {
time: makeStdio("/uv", [
"tool",
"run",
"mcp-time",
"--local-timezone=Asia/Shanghai",
]),
};
expect(eq(a, b)).toBe(false);
});
it("新增 server 时返回 false", () => {
const a = {
time: makeStdio("/uv", ["tool", "run", "mcp-time"]),
fetch: makeStdio("/uv", ["tool", "run", "mcp-fetch"]),
};
const b = { time: makeStdio("/uv", ["tool", "run", "mcp-time"]) };
expect(eq(a, b)).toBe(false);
});
it("删除 server 时返回 false", () => {
const a = { time: makeStdio("/uv", ["tool", "run", "mcp-time"]) };
const b = {
time: makeStdio("/uv", ["tool", "run", "mcp-time"]),
fetch: makeStdio("/uv", ["tool", "run", "mcp-fetch"]),
};
expect(eq(a, b)).toBe(false);
});
it("allowTools 内容变化时返回 false", () => {
const a = {
time: makeStdio("/uv", ["run", "mcp-time"], {
allowTools: ["get_time"],
}),
};
const b = {
time: makeStdio("/uv", ["run", "mcp-time"], {
allowTools: ["convert_time"],
}),
};
expect(eq(a, b)).toBe(false);
});
it("denyTools 内容变化时返回 false", () => {
const a = {
srv: makeStdio("/uv", ["run", "srv"], { denyTools: ["op1"] }),
};
const b = {
srv: makeStdio("/uv", ["run", "srv"], { denyTools: ["op2"] }),
};
expect(eq(a, b)).toBe(false);
});
});
describe("远程 serverurl 类型)", () => {
it("相同 URL 时认为相等", () => {
const entry = { url: "https://mcp.example.com/sse" } as McpServerEntry;
expect(eq({ remote: entry }, { remote: { ...entry } })).toBe(true);
});
it("URL 变化时返回 false", () => {
const a = {
remote: { url: "https://mcp.example.com/v1" } as McpServerEntry,
};
const b = {
remote: { url: "https://mcp.example.com/v2" } as McpServerEntry,
};
expect(eq(a, b)).toBe(false);
});
});
describe("修复验证proxy 包装格式与原始格式对比", () => {
/**
* 这是原始 Bug 场景:
* - a (原始请求格式) = { time: { command: '/uv', args: ['tool','run','mcp-time'] } }
* - b (proxy 包装格式,旧实现中的 currentConfig.mcpServers) =
* { 'mcp-proxy': { command: 'mcp-proxy', args: ['--config-file', '/tmp/uuid.json'] } }
* 两者永远不等 → 每次都触发引擎重建。
*
* 修复后 b 也是原始格式,两者相等 → 不触发重建。
*/
it("原始格式与 proxy 包装格式不等(确认旧 Bug 场景确实会触发重建)", () => {
const rawEntry = makeStdio("/uv", ["tool", "run", "mcp-server-time"]);
const proxyEntry = makeStdio("mcp-proxy", [
"--config-file",
"/tmp/qiming-mcp-configs/mcp-config-abc.json",
]);
// 原始格式 vs proxy 包装格式 → 不等
expect(eq({ time: rawEntry }, { "mcp-proxy": proxyEntry })).toBe(false);
});
it("相同原始格式两次比较返回 true修复后不触发重建", () => {
const entry = makeStdio("/uv", ["tool", "run", "mcp-server-time"]);
// raw vs raw修复后的行为→ 相等
expect(eq({ time: entry }, { time: { ...entry } })).toBe(true);
});
});
});
// ────────────────────────────────────────────────────────────────────────────
// engineRawMcpServers 存储 & 清理
// ────────────────────────────────────────────────────────────────────────────
describe("UnifiedAgentService — engineRawMcpServers 生命周期", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("rawMcpServersEqual 是可调用的函数", () => {
expect(typeof rawMcpServersEqual).toBe("function");
});
it("filterBridgeEntries 是可调用的函数", () => {
expect(typeof filterBridgeEntries).toBe("function");
});
it("UnifiedAgentService 实例可以成功创建", () => {
const svc = new UnifiedAgentService();
expect(svc).toBeDefined();
});
});
// ────────────────────────────────────────────────────────────────────────────
// filterBridgeEntries
// ────────────────────────────────────────────────────────────────────────────
describe("filterBridgeEntries", () => {
const filter = filterBridgeEntries;
it("保留非 bridge 入口", () => {
const input = {
time: makeStdio("/uv", ["tool", "run", "mcp-time"]),
remote: { url: "https://mcp.example.com/sse" } as McpServerEntry,
};
const result = filter(input);
expect(Object.keys(result)).toEqual(["time", "remote"]);
});
it('过滤掉 command === "mcp-proxy" 的入口', () => {
const input = {
time: makeStdio("/uv", ["tool", "run", "mcp-time"]),
proxy: makeStdio("mcp-proxy", ["--config-file", "/tmp/cfg.json"]),
};
expect(Object.keys(filter(input))).toEqual(["time"]);
});
it("过滤掉 basename 为 mcp-proxy 的绝对路径入口", () => {
const proxyAbsPath = path.join(
"/home",
".qimingclaw",
"node_modules",
".bin",
"mcp-proxy",
);
const input = {
time: makeStdio("/uv", ["tool", "run", "mcp-time"]),
proxy: makeStdio(proxyAbsPath, ["--config-file", "/tmp/cfg.json"]),
};
expect(Object.keys(filter(input))).toEqual(["time"]);
});
it("全为 bridge 入口时返回空对象", () => {
const input = { proxy: makeStdio("mcp-proxy", []) };
expect(filter(input)).toEqual({});
});
it("空输入返回空对象", () => {
expect(filter({})).toEqual({});
});
});
// ────────────────────────────────────────────────────────────────────────────
// rawMcpServersEqual — 修复 continue → return false
// ────────────────────────────────────────────────────────────────────────────
describe("rawMcpServersEqual — 类型不一致时返回 false", () => {
const eq = rawMcpServersEqual;
it("同名 key 一方有 command 另一方没有(非 remote→ 返回 false修复 continue Bug", () => {
// ea 有 commandeb 有 url非 remote key 分支走到 command 检查时就是 false
// 通过构造一个既无 url 又无 command 的 entry 来触发这条路径
const withCmd = makeStdio("/uv", ["run", "mcp-time"]);
const noCmd = { args: ["run", "mcp-time"] } as unknown as McpServerEntry; // 无 command、无 url
const a = { time: withCmd };
const b = { time: noCmd };
expect(eq(a, b)).toBe(false); // 修复前会 continue 误判为 true
});
it("同名 key 两方都无 command 也无 url → 返回 false未知类型", () => {
const unknown = { args: [] } as unknown as McpServerEntry;
expect(eq({ srv: unknown }, { srv: unknown })).toBe(false);
});
});
// ────────────────────────────────────────────────────────────────────────────
// mcpChanged 逻辑:支持"清空 MCP"场景
// ────────────────────────────────────────────────────────────────────────────
describe("rawMcpServersEqual — 清空 MCP 场景", () => {
const eq = rawMcpServersEqual;
it("请求为空、存储非空 → 不等(用户清空了 MCP应触发重建", () => {
const stored = { time: makeStdio("/uv", ["run", "mcp-time"]) };
// rawMcpServersEqual({}, stored) → false → mcpChanged = true
expect(eq({}, stored)).toBe(false);
});
it("请求为空、存储也为空 → 相等(无变化)", () => {
// rawMcpServersEqual({}, {}) → true → mcpChanged = false
expect(eq({}, {})).toBe(true);
});
});
// ────────────────────────────────────────────────────────────────────────────
// ACP 引擎生命周期loadAcpSdk、getOrCreateEngine 复用与 destroy 清理
// ────────────────────────────────────────────────────────────────────────────
describe("UnifiedAgentService — 懒加载引擎生命周期", () => {
const baseConfig = {
engine: "qimingcode" as const,
workspaceDir: "/tmp",
apiKey: "k",
baseUrl: "u",
model: "m",
};
beforeEach(async () => {
vi.clearAllMocks();
});
it("init() 后 loadAcpSdk 被调用", async () => {
const { loadAcpSdk } = await import("./acp/acpClient");
const svc = new UnifiedAgentService();
await svc.init(baseConfig);
expect(loadAcpSdk).toHaveBeenCalled();
});
it("getOrCreateEngine 可按 project 创建引擎并记录配置", async () => {
const svc = new UnifiedAgentService();
await svc.init(baseConfig);
const effectiveConfig = {
...baseConfig,
mcpServers: { bridge: { url: "http://localhost:9999" } },
};
const engine = await svc.getOrCreateEngine(
"proj-warm-reuse",
effectiveConfig,
);
expect(engine).toBeDefined();
expect(engine.currentConfig?.apiKey).toBe("k");
expect(engine.currentConfig?.model).toBe("m");
expect(engine.currentConfig?.baseUrl).toBe("u");
expect((svc as any).engines.has("proj-warm-reuse")).toBe(true);
expect((svc as any).engineConfigs.get("proj-warm-reuse")).toEqual(
effectiveConfig,
);
});
it("getOrCreateEngine 对同一 project 复用现有 ready 引擎", async () => {
const svc = new UnifiedAgentService();
await svc.init(baseConfig);
const requestConfig = { ...baseConfig, model: "other-model" };
const engine1 = await svc.getOrCreateEngine(
"proj-same-auth",
requestConfig,
);
const engine2 = await svc.getOrCreateEngine("proj-same-auth", {
...requestConfig,
model: "another-model",
});
expect(engine1).toBe(engine2);
});
it("destroy() 清空引擎与配置缓存且不抛", async () => {
const svc = new UnifiedAgentService();
await svc.init(baseConfig);
await svc.getOrCreateEngine("proj-destroy", baseConfig);
await expect(svc.destroy()).resolves.toBeUndefined();
expect((svc as any).engines.size).toBe(0);
expect((svc as any).engineConfigs.size).toBe(0);
expect((svc as any).engineRawMcpServers.size).toBe(0);
});
});
// ────────────────────────────────────────────────────────────────────────────
// listAllSessionsDetailed活跃/就绪引擎过滤回归
// ────────────────────────────────────────────────────────────────────────────
describe("UnifiedAgentService.listAllSessionsDetailed — 仅返回 ready 引擎会话", () => {
it("should filter non-ready engines", () => {
// 使用 as any 访问私有 engines 属性进行单测
// 生产代码通过 public 方法访问,测试直接操作内部状态以隔离测试
const svc = new UnifiedAgentService() as any;
const readySessions = [
{
id: "ses-ready-1",
title: "t1",
engineType: "qimingcode",
projectId: "proj-ready",
status: "active",
createdAt: 1,
lastActivity: 2,
},
];
const deadSessions = [
{
id: "ses-dead-1",
title: "t-dead",
engineType: "claude-code",
projectId: "proj-dead",
status: "active",
createdAt: 10,
lastActivity: 11,
},
];
svc.engines = new Map<string, any>([
[
"proj-ready",
{
isReady: true,
engineName: "qimingcode",
listSessionsDetailed: () => readySessions,
},
],
[
"proj-dead",
{
isReady: false,
engineName: "claude-code",
listSessionsDetailed: () => deadSessions,
},
],
]);
const result = svc.listAllSessionsDetailed();
expect(result).toEqual(readySessions);
});
});
// ────────────────────────────────────────────────────────────────────────────
// warmupEngineqimingcode 热启动预创建
// ────────────────────────────────────────────────────────────────────────────
describe("UnifiedAgentService — warmupEngine 热启动", () => {
const baseConfig = {
engine: "qimingcode" as const,
workspaceDir: "/tmp",
apiKey: "k",
baseUrl: "u",
model: "m",
};
beforeEach(() => {
vi.clearAllMocks();
sandboxPolicyState.current = {
enabled: true,
backend: "auto",
mode: "compat",
autoFallback: "startup-only",
windowsMode: "workspace-write",
};
});
it("init() 后 engines 中存在 __warmup__ 占位", async () => {
const svc = new UnifiedAgentService() as any;
await svc.init(baseConfig);
// AcpEngine mock 的 init 是同步 resolve所以 warmup 立即完成
expect(svc.engines.has("__warmup__")).toBe(true);
expect(svc.engines.get("__warmup__").isReady).toBe(true);
});
it("engine 为 claude-code 时仍触发 qimingcode warmup", async () => {
const claudeConfig = { ...baseConfig, engine: "claude-code" as const };
const svc = new UnifiedAgentService() as any;
await svc.init(claudeConfig);
// warmup 始终预热 qimingcode不管 init 时的 engineType
expect(svc.engines.has("__warmup__")).toBe(true);
expect(svc.engines.get("__warmup__").isReady).toBe(true);
});
it("getOrCreateEngine 复用 warmup 引擎并 re-key", async () => {
const svc = new UnifiedAgentService();
await svc.init(baseConfig);
// warmup 引擎已就绪
expect((svc as any).engines.has("__warmup__")).toBe(true);
const engine = await svc.getOrCreateEngine("proj-test", baseConfig);
expect(engine).toBeDefined();
// warmup 被消费后会立即补仓
expect((svc as any).engines.has("__warmup__")).toBe(true);
expect((svc as any).engines.has("proj-test")).toBe(true);
});
it("warmup 运行时配置缺失时不复用,回退冷启动", async () => {
const svc = new UnifiedAgentService() as any;
await svc.init({
...baseConfig,
apiKey: undefined,
baseUrl: undefined,
model: undefined,
});
const warmupEngine = svc.engines.get("__warmup__");
expect(warmupEngine).toBeDefined();
const engine = await svc.getOrCreateEngine("proj-runtime-mismatch", {
...baseConfig,
apiKey: "runtime-key",
baseUrl: "https://runtime.example.com",
model: "runtime-model",
apiProtocol: "openai",
});
expect(engine).not.toBe(warmupEngine);
expect(warmupEngine.destroy).toHaveBeenCalled();
expect(svc.engines.has("__warmup__")).toBe(true);
expect(svc.engines.get("proj-runtime-mismatch")).toBe(engine);
});
it("warmup runtime config 缓存:首次 miss 后 refill 命中后续请求", async () => {
const svc = new UnifiedAgentService() as any;
// init 时 baseConfig 没有 runtime 配置
await svc.init({
...baseConfig,
apiKey: undefined,
baseUrl: undefined,
model: undefined,
});
const runtimeConfig = {
...baseConfig,
apiKey: "cached-key",
baseUrl: "https://cached.example.com",
model: "cached-model",
apiProtocol: "openai",
};
// 第一次请求runtime mismatch → 冷启动,缓存 runtime config
const engine1 = await svc.getOrCreateEngine("proj-cache-1", runtimeConfig);
expect(svc.engines.has("__warmup__")).toBe(true);
// refill warmup 应该带缓存的 runtime config第二个请求可命中
const refillWarmup = svc.engines.get("__warmup__");
expect(refillWarmup).toBeDefined();
const engine2 = await svc.getOrCreateEngine("proj-cache-2", runtimeConfig);
// refill warmup 应该被复用
expect(engine2).toBe(refillWarmup);
expect(svc.engines.has("__warmup__")).toBe(true);
expect(svc.engines.get("proj-cache-2")).toBe(refillWarmup);
});
it("warmup runtime config 缓存:配置变更时回退冷启动并更新缓存", async () => {
const svc = new UnifiedAgentService() as any;
await svc.init({
...baseConfig,
apiKey: undefined,
baseUrl: undefined,
model: undefined,
});
const runtimeConfigA = {
...baseConfig,
apiKey: "key-a",
baseUrl: "https://a.example.com",
model: "model-a",
apiProtocol: "openai",
};
const runtimeConfigB = {
...baseConfig,
apiKey: "key-b",
baseUrl: "https://b.example.com",
model: "model-b",
apiProtocol: "anthropic",
};
// 第一次请求:缓存 configA
await svc.getOrCreateEngine("proj-change-1", runtimeConfigA);
expect(svc.engines.has("__warmup__")).toBe(true);
const refillA = svc.engines.get("__warmup__");
// 第二次请求configB 与缓存不匹配 → 回退冷启动,更新缓存
const engine2 = await svc.getOrCreateEngine(
"proj-change-2",
runtimeConfigB,
);
expect(engine2).not.toBe(refillA);
expect(refillA.destroy).toHaveBeenCalled();
// refill 应该用 configB 创建
expect(svc.engines.has("__warmup__")).toBe(true);
const refillB = svc.engines.get("__warmup__");
// 第三次请求configB 命中 refill
const engine3 = await svc.getOrCreateEngine(
"proj-change-3",
runtimeConfigB,
);
expect(engine3).toBe(refillB);
});
it("sandbox policy 变更后,下一次会话立即放弃旧 warmup 复用", async () => {
const svc = new UnifiedAgentService() as any;
sandboxPolicyState.current = {
...sandboxPolicyState.current,
mode: "compat",
};
await svc.init(baseConfig);
const warmupEngine = svc.engines.get("__warmup__");
expect(warmupEngine).toBeDefined();
const warmupCfg = svc.engineConfigs.get("__warmup__");
expect(warmupCfg.env?.QIMING_AGENT_WARMUP_SANDBOX_POLICY_FP).toBeDefined();
sandboxPolicyState.current = {
...sandboxPolicyState.current,
mode: "strict",
};
const engine = await svc.getOrCreateEngine("proj-sandbox-policy-change", {
...baseConfig,
});
expect(engine).not.toBe(warmupEngine);
expect(warmupEngine.destroy).toHaveBeenCalled();
expect(svc.engines.has("__warmup__")).toBe(true);
expect(svc.engines.get("proj-sandbox-policy-change")).toBe(engine);
const refillCfg = svc.engineConfigs.get("__warmup__");
expect(refillCfg.env?.QIMING_AGENT_WARMUP_SANDBOX_POLICY_FP).toBeDefined();
expect(refillCfg.env?.QIMING_AGENT_WARMUP_SANDBOX_POLICY_FP).not.toBe(
warmupCfg.env?.QIMING_AGENT_WARMUP_SANDBOX_POLICY_FP,
);
});
it("warmup 缺少 MCP ready 标记时不复用,回退冷启动", async () => {
const mcpConfig = {
bridge: {
command: "/mock/node",
args: ["/mock/proxy.js", "--config-file", "/tmp/mcp-ready.json"],
env: { MCP_PROXY_LOG_FILE: "/tmp/warmup.log" },
},
};
const svc = new UnifiedAgentService() as any;
await svc.init({ ...baseConfig, mcpServers: mcpConfig });
const warmupEngine = svc.engines.get("__warmup__");
expect(warmupEngine).toBeDefined();
const warmupCfg = svc.engineConfigs.get("__warmup__");
expect(warmupCfg).toBeDefined();
expect(warmupCfg.env?.QIMING_AGENT_WARMUP_MCP_READY).toBe("1");
// 模拟老版本 warmup没有 MCP ready 标记
delete warmupCfg.env.QIMING_AGENT_WARMUP_MCP_READY;
const engine = await svc.getOrCreateEngine("proj-legacy-warmup", {
...baseConfig,
mcpServers: mcpConfig,
});
expect(engine).not.toBe(warmupEngine);
expect(warmupEngine.destroy).toHaveBeenCalled();
expect(svc.engines.has("__warmup__")).toBe(true);
expect(svc.engines.get("proj-legacy-warmup")).toBe(engine);
});
it("claude-code 请求保持原逻辑:不复用 qimingcode warmup", async () => {
const claudeConfig = { ...baseConfig, engine: "claude-code" as const };
const svc = new UnifiedAgentService() as any;
await svc.init(claudeConfig);
// init 阶段仍会创建 qimingcode warmup
expect(svc.engines.has("__warmup__")).toBe(true);
const warmupEngine = svc.engines.get("__warmup__");
const engine = await svc.getOrCreateEngine("proj-claude", claudeConfig);
expect(engine).toBeDefined();
expect(engine).not.toBe(warmupEngine);
// warmup 继续保留,不影响后续 qimingcode 命中
expect(svc.engines.has("__warmup__")).toBe(true);
expect(svc.engines.get("proj-claude")).toBe(engine);
});
it("qimingcode: 第一次命中 warmup 后会补仓,第二个新 project 也可继续命中", async () => {
const svc = new UnifiedAgentService() as any;
await svc.init(baseConfig);
const firstWarmup = svc.engines.get("__warmup__");
expect(firstWarmup).toBeDefined();
const firstEngine = await svc.getOrCreateEngine("proj-1", baseConfig);
expect(firstEngine).toBe(firstWarmup);
const secondWarmup = svc.engines.get("__warmup__");
expect(secondWarmup).toBeDefined();
expect(secondWarmup).not.toBe(firstWarmup);
const secondEngine = await svc.getOrCreateEngine("proj-2", baseConfig);
expect(secondEngine).toBe(secondWarmup);
expect(svc.engines.has("__warmup__")).toBe(true);
});
it("destroy() 清理 warmup 引擎", async () => {
const svc = new UnifiedAgentService();
await svc.init(baseConfig);
expect((svc as any).engines.has("__warmup__")).toBe(true);
await svc.destroy();
expect((svc as any).engines.size).toBe(0);
});
it("destroy() 会调用 warmup.dispose 清理 respawn 定时器", async () => {
const svc = new UnifiedAgentService() as any;
await svc.init(baseConfig);
const disposeSpy = vi.spyOn(svc.warmup, "dispose");
await svc.destroy();
expect(disposeSpy).toHaveBeenCalledTimes(1);
});
it("warmup MCP 与请求 MCP 同名但 args 不同 → 不复用,回退冷启动", async () => {
const { AcpEngine } = await import("./acp/acpEngine");
const warmupMcp = {
bridge: {
command: "/mock/node",
args: ["/mock/proxy.js", "--config-file", "/tmp/mcp-a.json"],
env: { MCP_PROXY_LOG_FILE: "/tmp/warmup.log" },
},
};
const requestMcp = {
bridge: {
command: "/mock/node",
args: ["/mock/proxy.js", "--config-file", "/tmp/mcp-b.json"],
env: { MCP_PROXY_LOG_FILE: "/tmp/project.log" },
},
};
const mockWarmupEngine = {
currentConfig: { apiKey: "k", baseUrl: "u", model: "m" },
init: vi.fn().mockResolvedValue(true),
updateConfig: vi.fn(),
removeAllListeners: vi.fn(),
destroy: vi.fn().mockResolvedValue(undefined),
isReady: true,
getActivePromptCount: vi.fn(() => 0),
engineName: "qimingcode" as const,
on: vi.fn(),
};
const mockNewEngine = {
currentConfig: { apiKey: "k", baseUrl: "u", model: "m" },
init: vi.fn().mockResolvedValue(true),
updateConfig: vi.fn(),
removeAllListeners: vi.fn(),
destroy: vi.fn().mockResolvedValue(undefined),
isReady: true,
getActivePromptCount: vi.fn(() => 0),
engineName: "qimingcode" as const,
on: vi.fn(),
};
let callCount = 0;
(AcpEngine as any).mockImplementation(() => {
callCount++;
return callCount === 1 ? mockWarmupEngine : mockNewEngine;
});
const svc = new UnifiedAgentService() as any;
await svc.init({ ...baseConfig, mcpServers: warmupMcp });
const engine = await svc.getOrCreateEngine("proj-mcp-mismatch", {
...baseConfig,
mcpServers: requestMcp,
});
expect(engine).toBe(mockNewEngine);
expect(mockWarmupEngine.destroy).toHaveBeenCalled();
expect(svc.engines.has("__warmup__")).toBe(true);
expect(svc.engines.get("proj-mcp-mismatch")).toBe(mockNewEngine);
});
it("仅 MCP_PROXY_LOG_FILE 不同仍复用 warmup", async () => {
const warmupMcp = {
bridge: {
command: "/mock/node",
args: ["/mock/proxy.js", "--config-file", "/tmp/mcp-same.json"],
env: { MCP_PROXY_LOG_FILE: "/tmp/warmup.log", FOO: "bar" },
},
};
const requestMcp = {
bridge: {
command: "/mock/node",
args: ["/mock/proxy.js", "--config-file", "/tmp/mcp-same.json"],
env: { MCP_PROXY_LOG_FILE: "/tmp/project.log", FOO: "bar" },
},
};
const svc = new UnifiedAgentService() as any;
await svc.init({ ...baseConfig, mcpServers: warmupMcp });
const warmupEngine = svc.engines.get("__warmup__");
const engine = await svc.getOrCreateEngine("proj-mcp-logonly", {
...baseConfig,
mcpServers: requestMcp,
});
expect(engine).toBe(warmupEngine);
expect(svc.engines.has("__warmup__")).toBe(true);
expect(svc.engines.get("proj-mcp-logonly")).toBe(warmupEngine);
});
it("mcp-proxy --config-file 路径不同但配置内容相同仍复用 warmup", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "warmup-mcp-"));
try {
const configAPath = path.join(tmpDir, "mcp-config-a.json");
const configBPath = path.join(tmpDir, "mcp-config-b.json");
const sameConfig = {
mcpServers: {
"chrome-devtools": {
url: "http://127.0.0.1:3344/mcp/chrome-devtools",
transport: "streamable-http",
},
},
};
fs.writeFileSync(configAPath, JSON.stringify(sameConfig), "utf8");
fs.writeFileSync(configBPath, JSON.stringify(sameConfig), "utf8");
const warmupMcp = {
bridge: {
command: "/mock/node",
args: ["/mock/proxy.js", "--config-file", configAPath],
env: { MCP_PROXY_LOG_FILE: "/tmp/warmup.log", FOO: "bar" },
},
};
const requestMcp = {
bridge: {
command: "/mock/node",
args: ["/mock/proxy.js", "--config-file", configBPath],
env: { MCP_PROXY_LOG_FILE: "/tmp/project.log", FOO: "bar" },
},
};
const svc = new UnifiedAgentService() as any;
await svc.init({ ...baseConfig, mcpServers: warmupMcp });
const warmupEngine = svc.engines.get("__warmup__");
const engine = await svc.getOrCreateEngine("proj-mcp-config-semantic", {
...baseConfig,
mcpServers: requestMcp,
});
expect(engine).toBe(warmupEngine);
expect(svc.engines.has("__warmup__")).toBe(true);
expect(svc.engines.get("proj-mcp-config-semantic")).toBe(warmupEngine);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("persistent MCP: warmup 为 proxy-stdio、请求为 bridge URL 时仍复用", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "warmup-mcp-"));
try {
const configPath = path.join(tmpDir, "mcp-config-chrome.json");
const persistentConfig = {
mcpServers: {
"chrome-devtools": {
command: "npx",
args: ["-y", "chrome-devtools-mcp@latest"],
persistent: true,
},
},
};
fs.writeFileSync(configPath, JSON.stringify(persistentConfig), "utf8");
const warmupMcp = {
"chrome-devtools": {
command: "/mock/node",
args: ["/mock/proxy.js", "--config-file", configPath],
env: { MCP_PROXY_LOG_FILE: "/tmp/warmup.log" },
},
};
const requestMcp = {
"chrome-devtools": {
url: "http://127.0.0.1:59504/mcp/chrome-devtools",
},
};
const svc = new UnifiedAgentService() as any;
await svc.init({ ...baseConfig, mcpServers: warmupMcp });
const warmupEngine = svc.engines.get("__warmup__");
const engine = await svc.getOrCreateEngine(
"proj-persistent-shape-switch",
{
...baseConfig,
mcpServers: requestMcp,
},
);
expect(engine).toBe(warmupEngine);
expect(svc.engines.has("__warmup__")).toBe(true);
expect(svc.engines.get("proj-persistent-shape-switch")).toBe(
warmupEngine,
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("persistent MCP: warmup/request 均为 proxy-stdioconfig-file: stdio vs bridge-url仍复用", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "warmup-mcp-"));
try {
const warmupConfigPath = path.join(tmpDir, "mcp-warmup.json");
const requestConfigPath = path.join(tmpDir, "mcp-request.json");
const warmupConfigFile = {
mcpServers: {
"chrome-devtools": {
command: "npx",
args: ["-y", "chrome-devtools-mcp@latest"],
persistent: true,
},
},
};
const requestConfigFile = {
mcpServers: {
"chrome-devtools": {
url: "http://127.0.0.1:59504/mcp/chrome-devtools",
},
},
};
fs.writeFileSync(
warmupConfigPath,
JSON.stringify(warmupConfigFile),
"utf8",
);
fs.writeFileSync(
requestConfigPath,
JSON.stringify(requestConfigFile),
"utf8",
);
const warmupMcp = {
"chrome-devtools": {
command: "/mock/node",
args: ["/mock/proxy.js", "--config-file", warmupConfigPath],
env: { MCP_PROXY_LOG_FILE: "/tmp/warmup.log" },
},
};
const requestMcp = {
"chrome-devtools": {
command: "/mock/node",
args: ["/mock/proxy.js", "--config-file", requestConfigPath],
env: { MCP_PROXY_LOG_FILE: "/tmp/request.log" },
},
};
const svc = new UnifiedAgentService() as any;
await svc.init({ ...baseConfig, mcpServers: warmupMcp });
const warmupEngine = svc.engines.get("__warmup__");
const engine = await svc.getOrCreateEngine(
"proj-persistent-proxy-vs-urlfile",
{
...baseConfig,
mcpServers: requestMcp,
},
);
expect(engine).toBe(warmupEngine);
expect(svc.engines.has("__warmup__")).toBe(true);
expect(svc.engines.get("proj-persistent-proxy-vs-urlfile")).toBe(
warmupEngine,
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("non-persistent MCP: proxy-stdio 与 bridge URL 不应被视为等价", async () => {
const { AcpEngine } = await import("./acp/acpEngine");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "warmup-mcp-"));
try {
const configPath = path.join(tmpDir, "mcp-config-non-persistent.json");
const nonPersistentConfig = {
mcpServers: {
"chrome-devtools": {
command: "npx",
args: ["-y", "chrome-devtools-mcp@latest"],
persistent: false,
},
},
};
fs.writeFileSync(configPath, JSON.stringify(nonPersistentConfig), "utf8");
const warmupMcp = {
"chrome-devtools": {
command: "/mock/node",
args: ["/mock/proxy.js", "--config-file", configPath],
env: { MCP_PROXY_LOG_FILE: "/tmp/warmup.log" },
},
};
const requestMcp = {
"chrome-devtools": {
url: "http://127.0.0.1:59504/mcp/chrome-devtools",
},
};
const mockWarmupEngine = {
currentConfig: { apiKey: "k", baseUrl: "u", model: "m" },
init: vi.fn().mockResolvedValue(true),
updateConfig: vi.fn(),
removeAllListeners: vi.fn(),
destroy: vi.fn().mockResolvedValue(undefined),
isReady: true,
getActivePromptCount: vi.fn(() => 0),
engineName: "qimingcode" as const,
on: vi.fn(),
};
const mockNewEngine = {
currentConfig: { apiKey: "k", baseUrl: "u", model: "m" },
init: vi.fn().mockResolvedValue(true),
updateConfig: vi.fn(),
removeAllListeners: vi.fn(),
destroy: vi.fn().mockResolvedValue(undefined),
isReady: true,
getActivePromptCount: vi.fn(() => 0),
engineName: "qimingcode" as const,
on: vi.fn(),
};
let callCount = 0;
(AcpEngine as any).mockImplementation(() => {
callCount++;
return callCount === 1 ? mockWarmupEngine : mockNewEngine;
});
const svc = new UnifiedAgentService() as any;
await svc.init({ ...baseConfig, mcpServers: warmupMcp });
const engine = await svc.getOrCreateEngine("proj-non-persistent", {
...baseConfig,
mcpServers: requestMcp,
});
expect(engine).toBe(mockNewEngine);
expect(mockWarmupEngine.destroy).toHaveBeenCalled();
expect(svc.engines.has("__warmup__")).toBe(true);
expect(svc.engines.get("proj-non-persistent")).toBe(mockNewEngine);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("warmup 未完成时请求到达 → tryReuse 发现未就绪 → 清理 → 创建新引擎", async () => {
const { AcpEngine } = await import("./acp/acpEngine");
// 创建一个 init 慢速 resolve 的 mock engine模拟 warmup 进行中)
let resolveInit: (ok: boolean) => void;
const slowInit = new Promise<boolean>((r) => {
resolveInit = r;
});
const mockWarmupEngine = {
currentConfig: { apiKey: "k", baseUrl: "u", model: "m" },
init: vi.fn(() => slowInit),
updateConfig: vi.fn(),
removeAllListeners: vi.fn(),
destroy: vi.fn().mockResolvedValue(undefined),
isReady: false, // warmup 尚未完成
getActivePromptCount: vi.fn(() => 0),
engineName: "qimingcode" as const,
on: vi.fn(),
};
// 后续创建的 engine 用正常 mock快速 resolve
const mockNewEngine = {
currentConfig: { apiKey: "k", baseUrl: "u", model: "m" },
init: vi.fn().mockResolvedValue(true),
updateConfig: vi.fn(),
removeAllListeners: vi.fn(),
destroy: vi.fn().mockResolvedValue(undefined),
isReady: true,
getActivePromptCount: vi.fn(() => 0),
engineName: "qimingcode" as const,
on: vi.fn(),
};
// 第一次 AcpEngine 构造返回慢 warmup 引擎,第二次返回正常引擎
let callCount = 0;
(AcpEngine as any).mockImplementation(() => {
callCount++;
return callCount === 1 ? mockWarmupEngine : mockNewEngine;
});
const svc = new UnifiedAgentService() as any;
await svc.init(baseConfig);
// warmup 引擎已占位但未就绪
expect(svc.engines.has("__warmup__")).toBe(true);
expect(svc.engines.get("__warmup__").isReady).toBe(false);
// 请求到达 → tryReuse 发现 warmup 未就绪 → 清理 → 创建新引擎
const engine = await svc.getOrCreateEngine("proj-late", baseConfig);
expect(engine).toBe(mockNewEngine);
// warmup 引擎被清理后会重新补仓
expect(svc.engines.has("__warmup__")).toBe(true);
// 新引擎已注册
expect(svc.engines.has("proj-late")).toBe(true);
// warmup engine 的 destroy 被调用
expect(mockWarmupEngine.destroy).toHaveBeenCalled();
});
});