chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,717 @@
/**
* 单元测试: 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<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
};
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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<void>();
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<void>(() => {}));
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");
});
});
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<string, string> | 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<string, string>;
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);
});
});