718 lines
21 KiB
TypeScript
718 lines
21 KiB
TypeScript
/**
|
||
* 单元测试: 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);
|
||
});
|
||
});
|