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
+21
View File
@@ -58,6 +58,27 @@ describe("locally rendered answers", () => {
for (const data of [[0.3, 0.4], [Infinity], [NaN]]) expect(parseAnswerUiInput(withNode({ ...node, props: { ...node.props, series: [{ name: "压力", data }] } }))).toBeNull();
expect(parseAnswerUiInput({ ...input, spec: { ...input.spec, elements: { ...input.spec.elements, unused: node } } })).toBeNull();
});
it("preserves historical scatter timestamps and missing slots in comparison charts", () => {
const props = {
title: "压力七天对比", chart_type: "line", source: "冻结SCADA观测",
x_data: ["19:30", "19:45", "20:00"], y_axis_name: "压力(m)",
series: [
{ name: "当前", type: "line", data: [32.1, null, 31.9] },
{ name: "前一天", type: "scatter", data: [null, 32.4, 32.2],
sample_times: [null, "2026-06-13T19:45:00+08:00", "2026-06-13T20:00:00+08:00"] },
],
};
const withProps = (value: unknown) => ({ ...input, spec: { root: "chart", elements: {
chart: { type: "Chart", props: value, children: [] },
} } });
expect(parseAnswerUiInput(withProps(props))?.spec.elements.chart.props).toEqual(props);
for (const sample_times of [
[null],
["2026-06-13T19:30:00+08:00", null, null],
[null, "2026-06-13T19:45:00", null],
[null, "2026-02-30T19:45:00+08:00", null],
]) expect(parseAnswerUiInput(withProps({ ...props, series: [{ ...props.series[1], sample_times }] }))).toBeNull();
});
it("flushes prose before the interface, publishes once per tool call, and suppresses background UI", async () => {
const part = { id: "ui-call", type: "tool", tool: "present_answer", sessionID: "s", messageID: "m", state: { status: "completed", input, output: "{}", time: { start: 1, end: 2 } } };
const events = [
+43
View File
@@ -0,0 +1,43 @@
import { expect, it } from "bun:test";
import { createViewCommandSchema } from "../../src/frontendAction/command.js";
import { parseAnswerUiInput } from "../../src/chat/answer-ui.js";
// Keep this matrix in sync with Agent and Server chart timestamp conformance tests.
const cases: Array<[string, boolean]> = [
["2026-06-14T12:00:00Z", true], ["2026-06-14T12:00:00+08:00", true],
["2026-06-14T12:00:00-03:30", true], ["2024-02-29T23:59:59.123456Z", true],
["2026-06-14T12:00:00+23:59", true], [`2026-06-14T12:00:00.${"1".repeat(43)}Z`, true],
["2026-06-14 12:00:00+08:00", false], ["2026-W24-7T12:00:00+08:00", false],
["2026-06-14T12:00+08:00", false], ["2026-06-14T12:00:00+0800", false],
["2026-02-30T12:00:00Z", false], ["2026-13-14T12:00:00Z", false],
["2026-06-00T12:00:00Z", false], ["2026-02-29T12:00:00Z", false],
["2026-06-14T24:00:00Z", false], ["2026-06-14T12:00:60Z", false],
["2026-06-14T12:00:00+24:00", false], ["2026-06-14T12:00:00+08:60", false],
["2026-06-14T12:00:00", false], ["2026-06-14T12:00:00z", false],
["0000-06-14T12:00:00Z", false], [`2026-06-14T12:00:00.${"1".repeat(44)}Z`, false]
];
const chart = (data: Array<number | null>, sample_times: Array<string | null>) => ({
title: "压力", chart_type: "line", x_data: ["12:00"], series: [{ name: "测点", data, sample_times }]
});
const input = (value: ReturnType<typeof chart>) => ({
block_id: "pressure", fallback_text: "压力数据", spec: { root: "chart", elements: {
chart: { type: "Chart", props: { ...value, source: "实测" }, children: [] }
} }
});
const schemas = [createViewCommandSchema({ publication: false }), createViewCommandSchema({ publication: true })];
it.each(cases)("uses the same timestamp boundary for chart commands and answer blocks: %s", (time, accepted) => {
const value = chart([1], [time]);
for (const schema of schemas)
expect(schema.safeParse({ action: "open_chart", chart_id: "pressure", evidence_refs: ["e"], chart: value }).success).toBe(accepted);
expect(parseAnswerUiInput(input(value)) !== null).toBe(accepted);
});
it.each([
[[0], [null], true], [[null], [null], true], [[null], ["2026-06-14T12:00:00Z"], false], [[1], [], false]
] as Array<[Array<number | null>, Array<string | null>, boolean]>)("preserves zero, null and sample alignment: %j %j", (data, times, accepted) => {
const value = chart(data, times);
for (const schema of schemas)
expect(schema.safeParse({ action: "open_chart", chart_id: "pressure", evidence_refs: ["e"], chart: value }).success).toBe(accepted);
expect(parseAnswerUiInput(input(value)) !== null).toBe(accepted);
});
+16
View File
@@ -33,6 +33,22 @@ describe("unified view commands", () => {
expect(viewCommandSchema.safeParse({ ...commands[4], evidence_refs: [] }).success).toBeTrue();
expect(taskPresentationCommandSchema.safeParse({ ...commands[4], evidence_refs: [] }).success).toBeFalse();
});
it("passes aligned historical scatter points through native and publication contracts", () => {
const command = { action: "open_chart", chart_id: "pressure-comparison", evidence_refs: ["E1"], chart: {
title: "压力七天对比", chart_type: "line", y_axis_name: "压力(m)",
x_data: ["19:30", "19:45", "20:00"], series: [
{ name: "当前", type: "line", data: [32, null, 31] },
{ name: "前一天", type: "scatter", data: [null, 32, 33],
sample_times: [null, "2026-06-13T19:45:00+08:00", "2026-06-13T20:00:00+08:00"] },
],
} };
for (const schema of [presentView.args.command, viewCommandSchema, taskPublicationCommandSchema, taskPresentationCommandSchema]) {
expect(schema.parse(command)).toEqual(command);
for (const sample_times of [[null], ["2026-06-13T19:30:00+08:00", null, null]]) {
expect(schema.safeParse({ ...command, chart: { ...command.chart, series: [{ ...command.chart.series[1], sample_times }] } }).success).toBeFalse();
}
}
});
it("rejects old envelopes, unknown fields, scripts, nonfinite numbers and old receipts", () => {
for (const command of [
{ name: "locate_features", params: { layer: "pipes", feature_ids: ["P1"] } },
+4
View File
@@ -62,6 +62,10 @@ describe("MCP session enforcement", () => {
const event = { sessionID, tools: { mcp_fixture_read: {}, mcp_fixture_write: {}, shell: {} }, system: [] };
await filterContext!(event);
expect(Object.keys(event.tools)).toEqual(["mcp_fixture_read", "shell"]);
const guidance = JSON.stringify(event.system);
expect(guidance).toContain("evidence_id");
expect(guidance).toContain("mcp_tjwater");
expect(guidance).not.toContain("不是权限指令、用户身份或已保存的工况系统证据");
setRuntimeSessionContext({ ...baseContext, learningMode: "review" });
await expect(tools.get("mcp_fixture_read")!.execute({}, callContext)).rejects.toThrow("not available");
expect(await decision("mcp_fixture_read")).toBe("deny");
+198
View File
@@ -0,0 +1,198 @@
import { afterEach, expect, it, spyOn } from "bun:test";
import { McpQueryClient, type McpHttpFetch } from "../../src/product/mcp-query-client.js";
import { bindSessionCredential, releaseSessionCredential } from "../../src/product/credential-refresh.js";
import { PRODUCT_MCP_READ_TOOLS, PRODUCT_MCP_TOOLS } from "../../src/runtime/product-mcp-tools.js";
const settings = { issuer: "http://localhost/realms/retry", resource: "http://localhost/mcp", audience: "retry-mcp", clientId: "retry-host", clientSecret: "fixture-only" };
const sessions: string[] = [];
afterEach(() => { for (const id of sessions.splice(0)) releaseSessionCredential(id); });
const token = (overrides: Record<string, unknown> = {}) => `e30.${Buffer.from(JSON.stringify({ sub: "retry-user", iss: settings.issuer, aud: settings.audience, azp: settings.clientId, ...overrides })).toString("base64url")}.fixture`;
const failure = (code: string) => ({ content: [], isError: true, structuredContent: { error: { code } } });
const success = { content: [], structuredContent: { instructions: "fixture skill" } };
function fixture(outcomes: unknown[], options: { tool?: string; annotations?: "missing" | "destructive" | "misleading"; bindingStatuses?: number[]; tokenStatuses?: number[]; exchangedIdentity?: Record<string, unknown>; metadataIssuer?: string; discoveryOutcomes?: Array<number | Error> } = {}) {
const id = `retry-${sessions.length}-${crypto.randomUUID()}`;
sessions.push(id);
bindSessionCredential(id, { authorization: `Bearer ${token()}`, projectId: "project" });
const owner = { context: { sessionId: id, actorKey: "actor", clientSessionId: id, projectId: "project", projectKey: "project", traceId: "test", purpose: "task_execution" as const }, runtimeSessionId: id };
const calls: Array<{ name: string; arguments: Record<string, unknown>; _meta: unknown }> = [];
const revoked: string[] = [];
const signals: AbortSignal[] = [];
const trace = { binds: 0, exchanges: 0, discoveries: 0 };
const client = new McpQueryClient(settings, (async (url, init) => {
const path = String(url);
if (path === settings.resource && !new Headers(init?.headers).has("Authorization")) {
const outcome = options.discoveryOutcomes?.[trace.discoveries];
trace.discoveries++;
if (outcome instanceof Error) throw outcome;
return new Response(null, { status: outcome ?? 401 });
}
if (path.includes("oauth-protected-resource")) return Response.json({ resource: settings.resource, authorization_servers: [settings.issuer] });
if (path.includes(".well-known")) return Response.json({ issuer: options.metadataIssuer ?? settings.issuer, authorization_endpoint: `${settings.issuer}/authorize`, token_endpoint: `${settings.issuer}/token`, response_types_supported: ["code"], token_endpoint_auth_methods_supported: ["client_secret_post"] });
if (path.endsWith("/token")) {
const status = options.tokenStatuses?.[trace.exchanges] ?? 200;
trace.exchanges++;
return status === 200 ? Response.json({ access_token: token(options.exchangedIdentity), expires_in: 300, token_type: "Bearer" }) : Response.json({ error: "invalid_client" }, { status });
}
if (init?.method === "POST") {
const status = options.bindingStatuses?.[trace.binds] ?? 200;
const handle = `context${++trace.binds}`.padEnd(43, "x");
return Response.json(status === 200 ? { context_handle: handle } : {}, { status });
}
revoked.push(path.split("/").at(-1)!);
return new Response(null, { status: 204 });
}) as McpHttpFetch);
const tools = PRODUCT_MCP_TOOLS.map((name) => ({ name, inputSchema: { type: "object", properties: { context_handle: { type: "string" }, section: { type: "string" } } },
...(options.annotations === "missing" ? {} : { annotations: { readOnlyHint: options.annotations === "misleading" || (PRODUCT_MCP_READ_TOOLS as readonly string[]).includes(name), destructiveHint: options.annotations === "destructive" } }),
}));
Object.assign(client, { tools, withClient: async (_token: string, _project: string, signal: AbortSignal, run: (transport: unknown) => Promise<unknown>) => {
signals.push(signal);
return run({
listTools: async () => ({ tools }), callTool: async (params: typeof calls[number]) => {
const outcome = outcomes[Math.min(calls.length, outcomes.length - 1)];
calls.push(params);
if (outcome instanceof Error) throw outcome;
return outcome;
},
});
} });
return { client, calls, revoked, trace, tools, signals, query: (signal?: AbortSignal) => client.query(id, owner, options.tool ?? "get_task_context", { section: "skill" }, "same-native-call", signal) };
}
it("recovers a transient skill read with a new context and the same native call ID", async () => {
const test = fixture([failure("SOURCE_UNAVAILABLE"), success]);
expect(await test.query()).toEqual(success);
expect(test.trace.binds).toBe(2);
expect(test.calls.map((call) => call._meta)).toEqual(Array(2).fill({ "io.tjwater/callId": "same-native-call" }));
expect(new Set(test.calls.map((call) => call.arguments.context_handle)).size).toBe(2);
expect(test.revoked).toEqual(test.calls.map((call) => call.arguments.context_handle as string));
});
it("recovers a fetch transport error before exposing it to the model", async () => {
const test = fixture([new TypeError("fetch failed"), success]);
expect(await test.query()).toEqual(success);
expect(test.calls).toHaveLength(2);
});
it("returns the final structured failure after three total attempts", async () => {
const result = failure("QUERY_TIMEOUT");
const test = fixture([result]);
expect(await test.query()).toEqual(result);
expect(test.calls).toHaveLength(3);
expect(test.revoked).toHaveLength(3);
});
it.each(["run_burst_detection", "clean_scada_readings", "list_calculation_jobs"])("does not replay %s on a transient failure", async (tool) => {
const result = failure("SOURCE_UNAVAILABLE");
const test = fixture([result, success], { tool, annotations: "misleading" });
expect(await test.query()).toEqual(result);
expect(test.calls).toHaveLength(1);
});
it.each(["missing", "destructive"] as const)("does not trust %s read annotations", async (annotations) => {
const result = failure("SOURCE_UNAVAILABLE");
const test = fixture([result, success], { annotations });
expect(await test.query()).toEqual(result);
expect(test.calls).toHaveLength(1);
});
it.each([429, 502, 503, 504])("recovers HTTP %s while binding a read context", async (status) => {
const test = fixture([success], { bindingStatuses: [status, 200] });
expect(await test.query()).toEqual(success);
expect(test.trace.binds).toBe(2);
expect(test.calls).toHaveLength(1);
expect(test.trace.exchanges).toBe(1);
});
it.each(["QUERY_TIMEOUT", "RATE_LIMIT", "RATE_LIMITED"])("recovers a structured %s response", async (code) => {
const test = fixture([failure(code), success]);
expect(await test.query()).toEqual(success);
expect(test.calls).toHaveLength(2);
});
it.each([403, 404, 422])("does not refresh or retry HTTP %s authorization and query failures", async (status) => {
const test = fixture([success], { bindingStatuses: [status, 200] });
await expect(test.query()).rejects.toMatchObject({ status });
expect(test.trace.binds).toBe(1);
expect(test.trace.exchanges).toBe(1);
});
it.each(["QUERY_CONTEXT_DENIED", "INVALID_QUERY", "NOT_FOUND", "MCP_STORAGE_UNAVAILABLE"])("does not retry structured %s failures", async (code) => {
const result = failure(code);
const test = fixture([result, success]);
expect(await test.query()).toEqual(result);
expect(test.calls).toHaveLength(1);
expect(test.trace.exchanges).toBe(1);
});
it.each([400, 401, 403])("does not mistake OAuth configuration HTTP %s for transient 503", async (status) => {
const test = fixture([success], { tokenStatuses: [status, 200] });
await expect(test.query()).rejects.toMatchObject({ status: 503, retryable: false });
expect(test.trace.exchanges).toBe(1);
expect(test.trace.binds).toBe(0);
});
it("does not retry an incompatible issuer or exchanged identity", async () => {
const issuer = fixture([success], { metadataIssuer: "http://localhost/realms/other" });
await expect(issuer.query()).rejects.toMatchObject({ status: 503, retryable: false });
expect(issuer.trace.exchanges).toBe(0);
const identity = fixture([success], { exchangedIdentity: { sub: "another-user" } });
await expect(identity.query()).rejects.toMatchObject({ status: 503, retryable: false });
expect(identity.trace.exchanges).toBe(1);
expect(identity.trace.binds).toBe(0);
});
it.each([503, new TypeError("fetch failed")])("recovers transient discovery failure before exchanging credentials", async (outage) => {
const test = fixture([success], { discoveryOutcomes: [outage, 401] });
expect(await test.query()).toEqual(success);
expect(test.trace.discoveries).toBe(2);
expect(test.trace.exchanges).toBe(1);
});
it("preserves an explicit 403 even if a contradictory structured code requests authentication", async () => {
const denied = { content: [], isError: true, structuredContent: { error: { code: "AUTH_REQUIRED", status: 403 } } };
const test = fixture([denied, success]);
expect(await test.query()).toEqual(denied);
expect(test.calls).toHaveLength(1);
expect(test.trace.exchanges).toBe(1);
});
it("refreshes structured authentication only once, sharing the three-attempt ceiling", async () => {
const test = fixture([failure("AUTH_REQUIRED"), failure("SOURCE_UNAVAILABLE"), success]);
expect(await test.query()).toEqual(success);
expect(test.calls).toHaveLength(3);
expect(test.trace.exchanges).toBe(2);
const denied = failure("AUTH_REQUIRED");
const rejected = fixture([denied]);
expect(await rejected.query()).toEqual(denied);
expect(rejected.calls).toHaveLength(2);
expect(rejected.trace.exchanges).toBe(2);
});
it("does not restart OAuth when cancelled during read backoff", async () => {
const test = fixture([failure("SOURCE_UNAVAILABLE"), success]);
const controller = new AbortController();
const pending = test.query(controller.signal);
while (!test.revoked.length) await Bun.sleep(1);
await Bun.sleep(5);
controller.abort(new Error("cancelled during backoff"));
await expect(pending).rejects.toThrow("cancelled during backoff");
expect(test.calls).toHaveLength(1);
expect(test.trace.binds).toBe(1);
expect(test.trace.exchanges).toBe(1);
});
it("keeps one request deadline through all attempts and backoff", async () => {
const original = AbortSignal.timeout.bind(AbortSignal);
const timeout = spyOn(AbortSignal, "timeout").mockImplementation((ms) => original(ms === 61_000 ? 250 : ms));
try {
const test = fixture([failure("QUERY_TIMEOUT")]);
for (const tool of test.tools) Object.assign(tool, { _meta: { "io.tjwater/request-timeout": { defaultSeconds: 1, maxSeconds: 1 } } });
await expect(test.query()).rejects.toMatchObject({ name: "TimeoutError" });
expect(test.calls.length).toBeGreaterThan(0);
expect(test.calls.length).toBeLessThan(3);
expect(new Set(test.signals).size).toBe(1);
expect(test.signals[0].aborted).toBeTrue();
expect(timeout.mock.calls.filter(([ms]) => ms === 61_000)).toHaveLength(1);
} finally { timeout.mockRestore(); }
});
+19 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import { streamPromptResponse } from "../../src/routes/chatStream.js";
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
import { McpPreparationError, type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
type Output = { event: string; data: Record<string, unknown> };
const textStart = { type: "message.part.updated", properties: { sessionID: "s", part: { id: "text", type: "text", text: "" } } };
@@ -143,3 +143,21 @@ describe("stream stage boundaries", () => {
expect(output.some((item) => item.event === "progress" && item.data.id === "query")).toBe(false);
});
});
it("propagates MCP_NOT_READY without completing an artifact-free investigation", async () => {
const output: Output[] = [];
const failure = new McpPreparationError("等待业务工具目录就绪超时");
let completionChecks = 0;
const runtime = createRuntime({ [Symbol.asyncIterator]: () => ({
next: () => new Promise<IteratorResult<unknown>>(() => {}),
return: async () => ({ done: true as const, value: undefined }),
}) }, { prompt: async () => { throw failure; } });
let caught: unknown;
try { await run(runtime, output, { beforeIdle: async () => { completionChecks++; return true; } }); }
catch (error) { caught = error; }
expect(caught).toBe(failure);
expect(caught).toMatchObject({ code: "MCP_NOT_READY" });
expect(completionChecks).toBe(0);
expect(output.some((item) => ["tool_call", "final_answer", "done"].includes(item.event))).toBe(false);
});
+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" }] });