/** * 单元测试: AcpEngine — 取消链路优化 * * 覆盖内容: * - abortSession 先 reject 再等待 ACP cancel * - abortSession 超时后仍完成清理 * - terminating 状态下拒绝新 prompt * - terminating 状态下抑制 message/tool 更新 */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import * as path from "path"; import * as dependencies from "@main/services/system/dependencies"; vi.mock("electron-log", () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, })); vi.mock("../../memory", () => ({ memoryService: { isInitialized: vi.fn(() => false), init: vi.fn().mockResolvedValue(undefined), ensureMemoryReadyForSession: vi.fn().mockResolvedValue(undefined), onSessionEnd: vi.fn().mockResolvedValue(undefined), }, })); vi.mock("../../utils/processTree", () => ({ killProcessTree: vi.fn(), killProcessTreeGraceful: vi.fn(), })); vi.mock("../../system/processRegistry", () => ({ processRegistry: { unregister: vi.fn(), }, })); vi.mock("@main/services/packages/guiAgentServer", () => ({ getGuiAgentServerUrl: vi.fn(() => null), })); vi.mock("@main/services/packages/windowsMcp", () => ({ getWindowsMcpUrl: vi.fn(() => null), })); vi.mock("@main/services/system/dependencies", () => ({ getResourcesPath: vi.fn(() => "/mock/resources"), getAppEnv: vi.fn(() => ({ PATH: "/mock/path" })), getBundledGitBashPath: vi.fn(() => null), })); vi.mock("@main/services/sandbox/policy", () => ({ getSandboxPolicy: vi.fn(() => ({ enabled: false, backend: "auto", mode: "compat", autoFallback: "startup-only", windowsMode: "workspace-write", })), resolveSandboxType: vi.fn(async () => ({ type: "none", degraded: false, })), getBundledLinuxBwrapPath: vi.fn(() => null), getBundledWindowsSandboxHelperPath: vi.fn(() => null), })); import { AcpEngine } from "./acpEngine"; import * as acpClient from "./acpClient"; type Deferred = { promise: Promise; resolve: (value: T) => void; reject: (reason?: unknown) => void; }; function createDeferred(): Deferred { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } function setupEngine(engineType: "claude-code" | "qimingcode" = "qimingcode") { const engine = new AcpEngine(engineType); const sessionId = "session-test-001"; const session = { id: sessionId, acpSessionId: sessionId, createdAt: Date.now(), status: "active", } as any; (engine as any).config = { engine: engineType, workspaceDir: "/tmp" } as any; (engine as any).acpConnection = { cancel: vi.fn(), prompt: vi.fn(), } as any; (engine as any).sessions.set(sessionId, session); return { engine, sessionId, session, acpConnection: (engine as any).acpConnection as { cancel: any; prompt: any; }, }; } function setupEngineForCreateSession( engineType: "claude-code" | "qimingcode" = "qimingcode", ) { const engine = new AcpEngine(engineType); const newSession = vi.fn().mockResolvedValue({ sessionId: "acp-session-1" }); (engine as any).config = { engine: engineType, workspaceDir: "/workspace/project", mcpServers: { "gui-agent": { url: "http://127.0.0.1:9876/mcp", type: "http", }, "safe-tool": { command: "node", args: ["tool.js"], }, }, } as any; (engine as any).acpConnection = { newSession, prompt: vi.fn(), cancel: vi.fn(), } as any; return { engine, newSession }; } describe("AcpEngine.abortSession", () => { beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.useRealTimers(); }); it("先 reject 本地 prompt,再等待 ACP cancel", async () => { const { engine, sessionId, session, acpConnection } = setupEngine(); const reject = vi.fn(); (engine as any).activePromptSessions.add(sessionId); (engine as any).activePromptRejects.set(sessionId, reject); const deferred = createDeferred(); acpConnection.cancel.mockReturnValueOnce(deferred.promise); const abortPromise = engine.abortSession(sessionId); expect(reject).toHaveBeenCalledTimes(1); expect((engine as any).activePromptSessions.has(sessionId)).toBe(false); expect(session.status).toBe("terminating"); deferred.resolve(); await expect(abortPromise).resolves.toBe(true); expect(session.status).toBe("idle"); }); it("ACP cancel 超时后仍完成清理", async () => { const { engine, sessionId, session, acpConnection } = setupEngine(); const reject = vi.fn(); (engine as any).activePromptSessions.add(sessionId); (engine as any).activePromptRejects.set(sessionId, reject); acpConnection.cancel.mockReturnValueOnce(new Promise(() => {})); vi.useFakeTimers(); const abortPromise = engine.abortSession(sessionId); await vi.advanceTimersByTimeAsync(15_001); await expect(abortPromise).resolves.toBe(true); expect(session.status).toBe("idle"); }); }); describe("AcpEngine.prompt", () => { it("terminating 状态拒绝新 prompt", async () => { const { engine, sessionId, session, acpConnection } = setupEngine(); session.status = "terminating"; await expect( engine.prompt(sessionId, [{ type: "text", text: "hi" }]), ).rejects.toThrow("terminating"); expect(acpConnection.prompt).not.toHaveBeenCalled(); }); it("qimingcode 默认透传 mcpInit 非阻塞元信息", async () => { const { engine, sessionId, acpConnection } = setupEngine("qimingcode"); acpConnection.prompt.mockResolvedValueOnce({ stopReason: "end_turn" }); await engine.prompt(sessionId, [{ type: "text", text: "hi" }], { messageID: "rid-meta-001", }); expect(acpConnection.prompt).toHaveBeenCalledTimes(1); expect(acpConnection.prompt).toHaveBeenCalledWith({ sessionId, prompt: [{ type: "text", text: "hi" }], _meta: { requestId: "rid-meta-001", request_id: "rid-meta-001", mcpInitPolicy: "non_blocking", mcpInitTimeoutMs: 500, }, }); }); it("claude-code 不透传 qimingcode 专属 mcpInit 元信息", async () => { const { engine, sessionId, acpConnection } = setupEngine("claude-code"); acpConnection.prompt.mockResolvedValueOnce({ stopReason: "end_turn" }); await engine.prompt(sessionId, [{ type: "text", text: "hi" }], { messageID: "rid-meta-002", }); expect(acpConnection.prompt).toHaveBeenCalledTimes(1); expect(acpConnection.prompt).toHaveBeenCalledWith({ sessionId, prompt: [{ type: "text", text: "hi" }], _meta: { requestId: "rid-meta-002", request_id: "rid-meta-002", }, }); }); it("qimingcode 在 MCP 断连窗口内自动重试一次", async () => { const { engine, sessionId, acpConnection } = setupEngine("qimingcode"); acpConnection.prompt .mockRejectedValueOnce( new Error("SSE stream disconnected: TypeError: terminated"), ) .mockResolvedValueOnce({ stopReason: "end_turn" }); vi.useFakeTimers(); const promptPromise = engine.prompt(sessionId, [ { type: "text", text: "hi" }, ]); await vi.advanceTimersByTimeAsync(1_200); await expect(promptPromise).resolves.toBeDefined(); expect(acpConnection.prompt).toHaveBeenCalledTimes(2); }); it("claude-code 保持原逻辑,不执行 MCP 自动重试", async () => { const { engine, sessionId, acpConnection } = setupEngine("claude-code"); acpConnection.prompt.mockRejectedValueOnce( new Error("SSE stream disconnected: TypeError: terminated"), ); await expect( engine.prompt(sessionId, [{ type: "text", text: "hi" }]), ).resolves.toBeDefined(); expect(acpConnection.prompt).toHaveBeenCalledTimes(1); }); it("qimingcode MCP 断连失败时上报 mcp_reconnecting", async () => { const { engine, sessionId, acpConnection } = setupEngine("qimingcode"); const onPromptEnd = vi.fn(); engine.on("computer:promptEnd", onPromptEnd); acpConnection.prompt.mockRejectedValue( new Error("SSE stream disconnected: TypeError: terminated"), ); vi.useFakeTimers(); const promptPromise = engine.prompt(sessionId, [ { type: "text", text: "hi" }, ]); await vi.advanceTimersByTimeAsync(1_200); await promptPromise; expect(acpConnection.prompt).toHaveBeenCalledTimes(2); expect(onPromptEnd).toHaveBeenCalled(); const event = onPromptEnd.mock.calls.at(-1)?.[0]; expect(event.reason).toBe("mcp_reconnecting"); }); it("qimingcode prompt 长时间无响应时结束会话等待", async () => { const { engine, sessionId, session, acpConnection } = setupEngine("qimingcode"); const onPromptEnd = vi.fn(); const onSessionError = vi.fn(); engine.on("computer:promptEnd", onPromptEnd); engine.on("session.error", onSessionError); acpConnection.prompt.mockReturnValueOnce(new Promise(() => {})); vi.useFakeTimers(); const promptPromise = engine.prompt( sessionId, [{ type: "text", text: "hi" }], { messageID: "rid-timeout-001" }, ); await vi.advanceTimersByTimeAsync(600_001); await expect(promptPromise).resolves.toBeDefined(); expect(onPromptEnd).toHaveBeenCalledTimes(1); expect(onPromptEnd.mock.calls[0][0]).toMatchObject({ sessionId, reason: "error", }); expect(onPromptEnd.mock.calls[0][0].description).toContain( "ACP prompt timed out", ); expect(onSessionError).toHaveBeenCalledTimes(1); expect(session.status).toBe("idle"); expect((engine as any).activePromptSessions.has(sessionId)).toBe(false); expect((engine as any).activePromptRejects.has(sessionId)).toBe(false); }); }); describe("AcpEngine.handleAcpSessionUpdate", () => { it("terminating 状态抑制 message/tool 更新", () => { const { engine, sessionId, session } = setupEngine(); session.status = "terminating"; const onMessage = vi.fn(); const onProgress = vi.fn(); engine.on("message.part.updated", onMessage); engine.on("computer:progress", onProgress); (engine as any).handleAcpSessionUpdate(sessionId, { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "hello" }, }); expect(onMessage).not.toHaveBeenCalled(); expect(onProgress).not.toHaveBeenCalled(); }); }); describe("AcpEngine.createSession", () => { it("沙箱启用时应移除 gui-agent MCP(互斥)", async () => { const { engine, newSession } = setupEngineForCreateSession("qimingcode"); (engine as any).storedSandboxConfig = { enabled: true, type: "windows-sandbox", projectWorkspaceDir: "/workspace/project", }; await engine.createSession({ mcpServers: { "another-tool": { command: "node", args: ["another.js"], }, }, }); expect(newSession).toHaveBeenCalledTimes(1); const sent = newSession.mock.calls[0][0] as { mcpServers: Array<{ name: string }>; }; expect(sent.mcpServers.map((m) => m.name)).toEqual([ "safe-tool", "another-tool", ]); }); it("沙箱关闭时应保留 gui-agent MCP", async () => { const { engine, newSession } = setupEngineForCreateSession("qimingcode"); (engine as any).storedSandboxConfig = null; await engine.createSession(); expect(newSession).toHaveBeenCalledTimes(1); const sent = newSession.mock.calls[0][0] as { mcpServers: Array<{ name: string }>; }; expect(sent.mcpServers.map((m) => m.name)).toContain("gui-agent"); }); it("compat 下若 sandboxed-fs 脚本缺失,不应阻断其他 MCP 加载", async () => { const { engine, newSession } = setupEngineForCreateSession("claude-code"); const resourcesSpy = vi .spyOn(dependencies, "getResourcesPath") .mockReturnValue("/__missing_resources__"); (engine as any).storedSandboxConfig = { enabled: true, type: "windows-sandbox", mode: "compat", projectWorkspaceDir: "/workspace/project", windowsSandboxHelperPath: "C:\\tools\\qiming-sandbox-helper.exe", }; try { await engine.createSession(); } finally { resourcesSpy.mockRestore(); } expect(newSession).toHaveBeenCalledTimes(1); const sent = newSession.mock.calls[0][0] as { mcpServers: Array<{ name: string }>; _meta?: { claudeCode?: { options?: { disallowedTools?: string[] } }; }; }; const names = sent.mcpServers.map((m) => m.name); expect(names).not.toContain("sandboxed-bash"); expect(names).not.toContain("sandboxed-fs"); const disallowed = sent._meta?.claudeCode?.options?.disallowedTools || []; expect(disallowed).not.toContain("Bash"); expect(disallowed).not.toContain("Write"); expect(disallowed).not.toContain("Edit"); expect(disallowed).not.toContain("NotebookEdit"); }); it("compat 模式下 sandboxed-fs 注入与工具禁用应生效", async () => { const { engine, newSession } = setupEngineForCreateSession("claude-code"); const resourcesSpy = vi .spyOn(dependencies, "getResourcesPath") .mockReturnValue(path.join(process.cwd(), "resources")); (engine as any).storedSandboxConfig = { enabled: true, type: "windows-sandbox", mode: "compat", projectWorkspaceDir: "/workspace/project", windowsSandboxHelperPath: "C:\\tools\\qiming-sandbox-helper.exe", }; try { await engine.createSession(); } finally { resourcesSpy.mockRestore(); } expect(newSession).toHaveBeenCalledTimes(1); const sent = newSession.mock.calls[0][0] as { mcpServers: Array<{ name: string; env?: Array<{ name: string; value: string }>; }>; _meta?: { claudeCode?: { options?: { disallowedTools?: string[] } }; }; }; const fsServer = sent.mcpServers.find((m) => m.name === "sandboxed-fs"); expect(fsServer).toBeDefined(); const modeVar = fsServer?.env?.find( (kv) => kv.name === "QIMING_SANDBOX_MODE", ); expect(modeVar?.value).toBe("compat"); const disallowed = sent._meta?.claudeCode?.options?.disallowedTools || []; expect(disallowed).toContain("Write"); expect(disallowed).toContain("Edit"); expect(disallowed).toContain("NotebookEdit"); }); }); describe("AcpEngine.handlePermissionRequest(strict)", () => { it("strict 下 workspace 内写入仅放行 allow_once", async () => { const { engine, sessionId } = setupEngine("qimingcode"); (engine as any).config = { engine: "qimingcode", workspaceDir: "/tmp/ws" }; (engine as any).storedSandboxConfig = { enabled: true, mode: "strict", projectWorkspaceDir: "/tmp/ws", }; (engine as any).isolatedHome = "/tmp/iso-home"; const result = await (engine as any).handlePermissionRequest({ sessionId, toolCall: { toolCallId: "tc-strict-1", kind: "edit", title: "Edit", rawInput: { file_path: "/tmp/ws/a.txt" }, }, options: [ { optionId: "allow-always", kind: "allow_always", name: "allow always", }, { optionId: "allow-once", kind: "allow_once", name: "allow once", }, ], }); expect(result).toEqual({ outcome: { outcome: "selected", optionId: "allow-once" }, }); }); it("strict 下 workspace/temp/appData 外写入应拒绝", async () => { const { engine, sessionId } = setupEngine("qimingcode"); (engine as any).config = { engine: "qimingcode", workspaceDir: "/tmp/ws" }; (engine as any).storedSandboxConfig = { enabled: true, mode: "strict", projectWorkspaceDir: "/tmp/ws", }; (engine as any).isolatedHome = "/tmp/iso-home"; const result = await (engine as any).handlePermissionRequest({ sessionId, toolCall: { toolCallId: "tc-strict-2", kind: "write", title: "Write", rawInput: { file_path: "/etc/passwd" }, }, options: [ { optionId: "allow-always", kind: "allow_always", name: "allow always", }, { optionId: "allow-once", kind: "allow_once", name: "allow once", }, ], }); expect(result).toEqual({ outcome: { outcome: "cancelled" } }); }); }); describe("AcpEngine.init", () => { afterEach(() => { vi.restoreAllMocks(); }); it("warmup 进程也应注入 MCP 配置,避免复用后工具列表为空", async () => { const engine = new AcpEngine("qimingcode"); let capturedEnv: Record | undefined; const mockConnection = { initialize: vi.fn().mockResolvedValue({ protocolVersion: "1.0.0" }), } as any; const mockProcess = { pid: 12345, on: vi.fn(), stdout: { removeAllListeners: vi.fn() }, stderr: { removeAllListeners: vi.fn() }, stdin: { removeAllListeners: vi.fn() }, removeAllListeners: vi.fn(), kill: vi.fn(), } as any; vi.spyOn(acpClient, "resolveAcpBinary").mockReturnValue({ binPath: "qimingcode", binArgs: ["acp"], isNative: false, }); vi.spyOn(acpClient, "createAcpConnection").mockImplementation( async (cfg: any) => { capturedEnv = cfg.env as Record; return { connection: mockConnection, process: mockProcess, isolatedHome: null, cleanup: vi.fn(), } as any; }, ); vi.spyOn(acpClient, "loadAcpSdk").mockResolvedValue({ PROTOCOL_VERSION: "1.0.0", } as any); const ok = await engine.init({ engine: "qimingcode", workspaceDir: "/tmp", env: { QIMING_AGENT_WARMUP: "1" }, mcpServers: { "chrome-devtools": { command: "node", args: ["proxy.js", "--config-file", "/tmp/mcp.json"], env: {}, }, }, } as any); expect(ok).toBe(true); expect(capturedEnv?.OPENCODE_CONFIG_CONTENT).toBeTruthy(); const injected = JSON.parse(capturedEnv!.OPENCODE_CONFIG_CONTENT!); expect(injected.mcp).toBeDefined(); expect(injected.mcp["chrome-devtools"]).toBeDefined(); expect(injected.permission.question).toBe("deny"); await engine.destroy(); }); }); describe("AcpEngine.chat", () => { it("qimingcode: 将 request_id 透传并附带 mcpInit 默认策略", async () => { const { engine, sessionId, session } = setupEngine(); session.projectId = "project-test-001"; const promptAsyncSpy = vi .spyOn(engine, "promptAsync") .mockResolvedValue(undefined); const result = await engine.chat({ user_id: "user-1", project_id: "project-test-001", session_id: sessionId, request_id: "rid-chat-001", prompt: "hello trace", } as any); expect(result.success).toBe(true); expect(promptAsyncSpy).toHaveBeenCalledWith( sessionId, [{ type: "text", text: "hello trace" }], { messageID: "rid-chat-001", mcpInitPolicy: "non_blocking", mcpInitTimeoutMs: 500, }, ); }); it("claude-code: chat 保持原逻辑仅透传 messageID", async () => { const { engine, sessionId, session } = setupEngine("claude-code"); session.projectId = "project-test-001"; const promptAsyncSpy = vi .spyOn(engine, "promptAsync") .mockResolvedValue(undefined); const result = await engine.chat({ user_id: "user-1", project_id: "project-test-001", session_id: sessionId, request_id: "rid-chat-claude-001", prompt: "hello trace", } as any); expect(result.success).toBe(true); expect(promptAsyncSpy).toHaveBeenCalledWith( sessionId, [{ type: "text", text: "hello trace" }], { messageID: "rid-chat-claude-001" }, ); }); it("claude-code: compat + 新会话 + context_servers 时等待 MCP warmup 后再发首条 prompt", async () => { const engine = new AcpEngine("claude-code"); (engine as any).config = { engine: "claude-code", workspaceDir: "/tmp/workspace", mcpServers: { whois: { command: "node", args: ["whois.js"] }, time: { command: "node", args: ["time.js"] }, }, }; (engine as any).acpConnection = {} as any; (engine as any).storedSandboxConfig = { enabled: true, mode: "compat", type: "macos-seatbelt", projectWorkspaceDir: "/tmp/workspace", }; vi.spyOn(engine, "createSession").mockImplementation(async () => { (engine as any).sessions.set("new-session-compat", { id: "new-session-compat", acpSessionId: "new-session-compat", createdAt: Date.now(), status: "idle", mcpServerCount: 2, }); return { id: "new-session-compat", title: "project-compat", time: { created: Date.now() }, } as any; }); const promptAsyncSpy = vi .spyOn(engine, "promptAsync") .mockResolvedValue(undefined); vi.useFakeTimers(); const chatPromise = engine.chat({ user_id: "user-1", project_id: "project-compat", request_id: "rid-chat-compat-001", prompt: "hello compat", agent_config: { context_servers: { whois: { enabled: true }, time: { enabled: true }, }, }, } as any); await vi.advanceTimersByTimeAsync(1199); expect(promptAsyncSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); await expect(chatPromise).resolves.toMatchObject({ success: true }); expect(promptAsyncSpy).toHaveBeenCalledWith( "new-session-compat", [{ type: "text", text: "hello compat" }], { messageID: "rid-chat-compat-001" }, ); }); }); /** listSessionsDetailed:会话 title 透传到列表(L1 数据断言) */ describe("AcpEngine.listSessionsDetailed", () => { it("返回的会话列表应包含 createSession 时传入的 title", () => { const { engine, sessionId, session } = setupEngine(); const expectedTitle = "我的会话标题"; (session as any).title = expectedTitle; const list = engine.listSessionsDetailed(); expect(list).toHaveLength(1); expect(list[0].id).toBe(sessionId); expect(list[0].title).toBe(expectedTitle); }); });