162 lines
12 KiB
TypeScript
162 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
import type { OpenCodeClient } from "@opencode/client";
|
|
import { config } from "../../src/config.js";
|
|
import { McpPreparationError, OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
|
import { LOCAL_QUERY_TOOL_IDS, PRODUCT_MCP_TOOL_IDS, QUERY_MCP_SERVER } from "../../src/runtime/product-mcp-tools.js";
|
|
import { mcpTimeoutDefaults } from "../../src/runtime/mcp-config.js";
|
|
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
|
|
|
const sessionId = "cold-location-readiness";
|
|
const directory = "/tmp/tjwater-mocked-cold-location/workspace";
|
|
const pluginTools = ["shell", "ask_user", "ask_task_user", "final_answer", "present_answer", "present_view", "memory_manager", "skill_manager", "publish_execution_plan", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todowrite", "send_task_message", "reply_agent_message"];
|
|
const originalEnabled = config.TJWATER_MCP_ENABLED;
|
|
beforeEach(() => {
|
|
config.TJWATER_MCP_ENABLED = true;
|
|
setRuntimeSessionContext({ sessionId, clientSessionId: sessionId, actorKey: "actor", projectKey: "project", projectId: "project-id", taskId: "task-id", traceId: "test", purpose: "task_execution" });
|
|
});
|
|
afterEach(() => { config.TJWATER_MCP_ENABLED = originalEnabled; removeRuntimeSessionContext(sessionId); });
|
|
|
|
type Status = "connected" | "pending" | "failed" | "needs_auth" | "disabled";
|
|
function setup(options: { cold?: boolean; status?: Status; error?: string; invalid?: boolean; stillFailed?: boolean; missingPlugin?: boolean; queryTools?: string[]; grants?: string[]; readFailure?: string } = {}) {
|
|
const calls: string[] = [];
|
|
let reads = 0, reconnected = false, readFailed = false;
|
|
const queryTools = options.queryTools ?? [...LOCAL_QUERY_TOOL_IDS, ...PRODUCT_MCP_TOOL_IDS];
|
|
const status = () => reconnected && !options.stillFailed ? "connected" : options.status ?? "connected";
|
|
const client = {
|
|
rpc: { call: async (input: { method: string; location: { directory: string } }, request?: { signal?: AbortSignal }) => {
|
|
request?.signal?.throwIfAborted(); expect(input.location.directory).toBe(directory); calls.push(input.method);
|
|
if (input.method === "mcp") return { output: { configuration_errors: options.invalid ? ["mcp_invalid"] : [], servers: [{ name: QUERY_MCP_SERVER, type: "remote", status: status(), configured_protocol: "2026-07-28", tools: queryTools }] } };
|
|
if (options.readFailure && !readFailed) { readFailed = true; throw new Error(options.readFailure); }
|
|
const missingContext = options.cold && reads++ === 0;
|
|
return { output: [...pluginTools, ...queryTools].filter((id) => !(missingContext && id === "mcp_tjwater_get_task_context") && !(options.missingPlugin && id === "publish_task_report"))
|
|
.map((id) => ({ id, direct: true, sandboxed: id === "shell" })) };
|
|
} },
|
|
mcp: {
|
|
list: async (input: { location: { directory: string } }) => { expect(input.location.directory).toBe(directory); calls.push("native-status"); return { data: [{ name: QUERY_MCP_SERVER, status: { status: status(), error: options.error } }] }; },
|
|
connect: async (input: { server: string; location: { directory: string } }) => { expect(input).toEqual({ server: QUERY_MCP_SERVER, location: { directory } }); calls.push("connect"); reconnected = true; }
|
|
},
|
|
session: { switchAgent: async () => { calls.push("agent"); }, switchModel: async () => {}, prompt: async () => { calls.push("prompt"); }, interrupt: async () => { calls.push("interrupt"); } }
|
|
} as unknown as OpenCodeClient;
|
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client, ensureSessionWorkspace: async () => directory, mcpReadonlyTools: () => options.grants ?? [...LOCAL_QUERY_TOOL_IDS, ...PRODUCT_MCP_TOOL_IDS] }) as OpencodeRuntimeAdapter;
|
|
return { runtime, calls };
|
|
}
|
|
|
|
describe("actual session MCP readiness before inference", () => {
|
|
it("waits for the cold workspace's executable catalogue before the first prompt", async () => {
|
|
const { runtime, calls } = setup({ cold: true }); await runtime.prompt(sessionId, "调查爆管");
|
|
expect(calls.filter((call) => call === "list")).toHaveLength(2); expect(calls).toContain("mcp");
|
|
expect(calls.indexOf("prompt")).toBeGreaterThan(calls.lastIndexOf("list")); expect(calls).not.toContain("connect");
|
|
});
|
|
it("reconnects a failed transport once and rechecks registry and status before inference", async () => {
|
|
const { runtime, calls } = setup({ status: "failed", error: "ECONNREFUSED" }); await runtime.prompt(sessionId, "调查爆管");
|
|
expect(calls.filter((call) => call === "connect")).toHaveLength(1); expect(calls.indexOf("prompt")).toBeGreaterThan(calls.lastIndexOf("mcp"));
|
|
expect(calls.lastIndexOf("list")).toBeGreaterThan(calls.indexOf("connect"));
|
|
});
|
|
it.each(["needs_auth", "disabled"] as Status[])("does not reconnect or start inference for %s", async (status) => {
|
|
const { runtime, calls } = setup({ status }); await expect(runtime.prompt(sessionId, "调查爆管")).rejects.toThrow("MCP");
|
|
expect(calls).not.toContain("connect"); expect(calls).not.toContain("prompt");
|
|
});
|
|
it("rejects configuration errors and missing trusted plugin tools before inference", async () => {
|
|
for (const options of [{ invalid: true }, { missingPlugin: true }]) {
|
|
const { runtime, calls } = setup(options); await expect(runtime.prompt(sessionId, "调查爆管")).rejects.toThrow("MCP");
|
|
expect(calls).not.toContain("connect"); expect(calls).not.toContain("prompt");
|
|
}
|
|
});
|
|
it("does not retry authentication failures or expose native connection details", async () => {
|
|
const { runtime, calls } = setup({ status: "failed", error: "HTTP 401 PRIVATE_CREDENTIAL" }); let message = "";
|
|
try { await runtime.prompt(sessionId, "调查爆管"); } catch (error) { message = (error as Error).message; }
|
|
expect(message).toContain("MCP"); expect(message).not.toContain("PRIVATE_CREDENTIAL"); expect(calls).not.toContain("connect"); expect(calls).not.toContain("prompt");
|
|
});
|
|
it("bounds recovery when the connection remains failed", async () => {
|
|
const { runtime, calls } = setup({ status: "failed", error: "connection closed", stillFailed: true });
|
|
await expect(runtime.prompt(sessionId, "调查爆管")).rejects.toThrow("MCP"); expect(calls.filter((call) => call === "connect")).toHaveLength(1); expect(calls).not.toContain("prompt");
|
|
});
|
|
it("checks resumed user input before it can start a native turn", async () => {
|
|
const { runtime, calls } = setup({ status: "needs_auth" });
|
|
await expect(runtime.enqueueUserInput(sessionId, { id: "input", text: "继续", delivery: "steer", status: "pending" })).rejects.toThrow("MCP");
|
|
expect(calls).not.toContain("prompt");
|
|
await runtime.enqueueUserInput(sessionId, { id: "queued", text: "稍后", delivery: "steer", status: "pending" }, { resume: false }); expect(calls.filter((call) => call === "prompt")).toHaveLength(1);
|
|
});
|
|
it("does not require project business tools or enable disabled MCP in a personal review", async () => {
|
|
setRuntimeSessionContext({ sessionId, clientSessionId: sessionId, actorKey: "actor", projectKey: "personal-actor", traceId: "test", purpose: "ordinary_chat", learningMode: "review" });
|
|
const { runtime, calls } = setup({ status: "disabled", queryTools: [] }); await runtime.prompt(sessionId, "仅检查资料"); expect(calls).toContain("prompt"); expect(calls).not.toContain("connect");
|
|
});
|
|
});
|
|
|
|
|
|
it("honors external cancellation while waiting and never sends a late native prompt", async () => {
|
|
const { runtime, calls } = setup({ status: "pending" });
|
|
const controller = new AbortController();
|
|
const pending = runtime.prompt(sessionId, "调查", undefined, undefined, controller.signal);
|
|
setTimeout(() => controller.abort(new Error("cancelled by user")), 10);
|
|
await expect(pending).rejects.toThrow("cancelled by user");
|
|
const count = calls.length;
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
expect(calls).not.toContain("prompt"); expect(calls).toHaveLength(count);
|
|
});
|
|
|
|
it("stopping a session cancels a pending readiness check before native admission", async () => {
|
|
const { runtime, calls } = setup({ status: "pending" });
|
|
const pending = runtime.prompt(sessionId, "调查");
|
|
const result = pending.then(() => null, (error: unknown) => error);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
await runtime.abortSession(sessionId);
|
|
expect((await result as Error).message).toContain("cancelled");
|
|
expect(calls).toContain("interrupt"); expect(calls).not.toContain("prompt");
|
|
});
|
|
|
|
it("reports a bounded MCP_NOT_READY deadline rather than admitting an incomplete catalogue", async () => {
|
|
const previous = { ...mcpTimeoutDefaults };
|
|
mcpTimeoutDefaults.startup = 5; mcpTimeoutDefaults.catalog = 5;
|
|
try {
|
|
const { runtime, calls } = setup({ status: "pending" });
|
|
let failure: unknown;
|
|
try { await runtime.prompt(sessionId, "调查"); } catch (error) { failure = error; }
|
|
expect(failure).toBeInstanceOf(McpPreparationError);
|
|
expect(failure).toMatchObject({ code: "MCP_NOT_READY" });
|
|
expect((failure as Error).message).toContain("超时");
|
|
expect(calls).not.toContain("prompt"); expect(calls).not.toContain("connect");
|
|
} finally { Object.assign(mcpTimeoutDefaults, previous); }
|
|
});
|
|
|
|
|
|
it("refuses background investigation when product MCP is disabled instead of asking the model to proceed", async () => {
|
|
config.TJWATER_MCP_ENABLED = false;
|
|
const { runtime, calls } = setup({ queryTools: [...LOCAL_QUERY_TOOL_IDS] });
|
|
await expect(runtime.prompt(sessionId, "调查")).rejects.toMatchObject({ code: "MCP_NOT_READY" });
|
|
expect(calls).not.toContain("prompt"); expect(calls).not.toContain("connect");
|
|
});
|
|
|
|
it("refuses a task whose mandatory context tool was explicitly denied without granting or reconnecting it", async () => {
|
|
const tools = [...LOCAL_QUERY_TOOL_IDS, ...PRODUCT_MCP_TOOL_IDS].filter((tool) => tool !== "mcp_tjwater_get_task_context");
|
|
const { runtime, calls } = setup({ queryTools: tools, grants: tools });
|
|
await expect(runtime.prompt(sessionId, "调查")).rejects.toMatchObject({ code: "MCP_NOT_READY" });
|
|
expect(calls).not.toContain("prompt"); expect(calls).not.toContain("connect");
|
|
});
|
|
|
|
it("keeps non-business chat available when project MCP is disabled", async () => {
|
|
config.TJWATER_MCP_ENABLED = false;
|
|
setRuntimeSessionContext({ sessionId, clientSessionId: sessionId, actorKey: "actor", projectKey: "personal-actor", traceId: "test", purpose: "ordinary_chat" });
|
|
const { runtime, calls } = setup({ queryTools: [...LOCAL_QUERY_TOOL_IDS] });
|
|
await runtime.prompt(sessionId, "你好");
|
|
expect(calls).toContain("prompt"); expect(calls).not.toContain("connect");
|
|
});
|
|
|
|
|
|
it("retries a transient catalogue read once without reconnecting a healthy MCP server", async () => {
|
|
const { runtime, calls } = setup({ readFailure: "fetch failed" });
|
|
await runtime.prompt(sessionId, "调查");
|
|
expect(calls.filter((call) => call === "list")).toHaveLength(2);
|
|
expect(calls).not.toContain("connect"); expect(calls).toContain("prompt");
|
|
});
|
|
|
|
it("does not retry an authorization error while reading the registry", async () => {
|
|
const { runtime, calls } = setup({ readFailure: "HTTP 403 PRIVATE_HEADER" });
|
|
let failure: unknown;
|
|
try { await runtime.prompt(sessionId, "调查"); } catch (error) { failure = error; }
|
|
expect(failure).toMatchObject({ code: "MCP_NOT_READY" });
|
|
expect((failure as Error).message).not.toContain("PRIVATE_HEADER");
|
|
expect(calls.filter((call) => call === "list")).toHaveLength(1);
|
|
expect(calls).not.toContain("prompt"); expect(calls).not.toContain("connect");
|
|
});
|