feat(agent): improve investigation contracts and runtime readiness
Generic Container CI/CD / test-build-publish (push) Successful in 1m53s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 1m53s

This commit is contained in:
2026-11-07 19:43:57 +08:00
parent c7b750b099
commit b8f7edb3ee
29 changed files with 1465 additions and 119 deletions
+2 -1
View File
@@ -6,6 +6,7 @@ function runtime(state: "missing" | "enqueued" | "delivered", active = false) {
const adapter = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
ensureClient: async () => ({ session: { active: async () => active ? { s: {} } : {}, interrupt: async (input: unknown) => { calls.push(input); } } }),
agentMessageState: async () => state,
ensureSessionToolsReady: async () => undefined,
prompt: async (...args: unknown[]) => { calls.push(args); },
}) as OpencodeRuntimeAdapter;
return { adapter, calls };
@@ -13,7 +14,7 @@ function runtime(state: "missing" | "enqueued" | "delivered", active = false) {
test("host reply continuation injects fixed ID only when absent; queued and consumed IDs never reinject", async () => {
const missing = runtime("missing");
expect(await missing.adapter.continueInteraction("s", "msg_fixed", "answer")).toBe("started");
expect(missing.calls).toEqual([["s", "answer", undefined, "msg_fixed"]]);
expect(missing.calls).toEqual([["s", "answer", undefined, "msg_fixed", undefined]]);
const busy = runtime("missing", true);
await expect(busy.adapter.continueInteraction("s", "msg_fixed", "answer")).rejects.toThrow("still active");
expect(busy.calls).toEqual([]);
+161
View File
@@ -0,0 +1,161 @@
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");
});
+14 -1
View File
@@ -50,7 +50,7 @@ describe("task agent isolation", () => {
it("selects the profile from trusted purpose on every prompt including resumed sessions", async () => {
const selected: string[] = [];
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => ({ session: {
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureSessionToolsReady: async () => undefined, ensureClient: async () => ({ session: {
switchAgent: async ({ agent }: { agent: string }) => { selected.push(agent); }, prompt: async () => {},
} }) }) as OpencodeRuntimeAdapter;
for (const purpose of ["ordinary_chat", "task_execution", "task_collaboration"] as const) {
@@ -71,6 +71,19 @@ it("allows calculations to project coordinators but never delegated readers or p
}
});
it("keeps burst event handling separate from SCADA candidate location", () => {
const context = { sessionId: "event-handler", clientSessionId: "event-chat", actorKey: "a", projectKey: "p",
projectId: "p", taskId: "t", traceId: "test", purpose: "task_execution" as const,
taskWorkflow: "burst_report", taskReplay: false };
expect(allowedQueryTools(context)).not.toContain(queryMcpToolId("run_burst_location"));
expect(allowedQueryTools(context)).toContain(queryMcpToolId("analyze_valve_isolation"));
expect(allowedQueryTools(context)).toContain(queryMcpToolId("analyze_valve_closure"));
expect(allowedQueryTools({ ...context, purpose: "task_collaboration" })).not.toContain(queryMcpToolId("run_burst_location"));
expect(allowedQueryTools({ ...context, taskReplay: true })).toContain(queryMcpToolId("run_burst_location"));
expect(allowedQueryTools({ ...context, taskWorkflow: "scada_anomaly" })).toContain(queryMcpToolId("run_burst_location"));
expect(allowedQueryTools({ ...context, taskId: undefined, purpose: "ordinary_chat" })).toContain(queryMcpToolId("run_burst_location"));
});
it("keeps administrator denials for product MCP queries in both primary and reader permissions", () => {
const denied = serviceOpencodeConfig({ permissions: [{ action: "mcp_tjwater_*", resource: "*", effect: "deny" }] });