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"); });