feat(mcp): resume queued calculation jobs
This commit is contained in:
@@ -1,13 +1,22 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import plugin from "../../.opencode/plugins/tjwater-tools.js";
|
||||
|
||||
const originalConnectionPath = process.env.TJWATER_AGENT_MCP_CONNECTION_PATH;
|
||||
const connectionRoot = mkdtempSync(join(tmpdir(), "call-id-plugin-"));
|
||||
const originalInternalToken = process.env.AGENT_INTERNAL_TOKEN;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.TJWATER_AGENT_MCP_CONNECTION_PATH = join(connectionRoot, "absent.json");
|
||||
process.env.AGENT_INTERNAL_TOKEN = "test-internal-token";
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalConnectionPath === undefined) delete process.env.TJWATER_AGENT_MCP_CONNECTION_PATH;
|
||||
else process.env.TJWATER_AGENT_MCP_CONNECTION_PATH = originalConnectionPath;
|
||||
rmSync(connectionRoot, { recursive: true, force: true });
|
||||
if (originalInternalToken === undefined) delete process.env.AGENT_INTERNAL_TOKEN;
|
||||
else process.env.AGENT_INTERNAL_TOKEN = originalInternalToken;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, it } from "bun:test";
|
||||
import { buildToolProgressDetail, getToolProgressTitle } from "../../src/routes/chatStreamEvents.js";
|
||||
|
||||
it("distinguishes completed submissions from completed calculations", () => {
|
||||
const jobId = "f40ca28d-1ea4-4ed7-8e1a-4cc476e748e8";
|
||||
const output = (status: string) => JSON.stringify({ semantics: "task", data: { job_id: jobId, status } });
|
||||
const detail = buildToolProgressDetail("run_burst_location", "completed", {}, "", undefined, output("queued"));
|
||||
expect(detail).toContain("等待计算");
|
||||
expect(detail).toContain(jobId);
|
||||
expect(detail).not.toContain("已执行完成");
|
||||
expect(getToolProgressTitle("run_burst_location", "completed", output("running"))).toContain("后台计算中");
|
||||
expect(getToolProgressTitle("cancel_calculation_job", "completed", output("cancel_requested"))).toContain("等待计算停止");
|
||||
expect(getToolProgressTitle("get_calculation_job", "completed", output("failed"))).toContain("计算失败");
|
||||
expect(getToolProgressTitle("query", "completed", "not-json")).toBe("query 已完成");
|
||||
expect(buildToolProgressDetail("query", "completed", {}, "")).toContain("已执行完成");
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { fromJsonSchema } from "@modelcontextprotocol/server";
|
||||
import { normalizeRuntimeMessage } from "../../src/runtime/opencode2-events.js";
|
||||
import { expect, it } from "bun:test";
|
||||
import { resolveMatchingNativeCall } from "../../src/mcp/native-call-identity.js";
|
||||
import { allowedQueryTools } from "../../src/runtime/query-mcp-policy.js";
|
||||
import { QUERY_MCP_AUTOALLOW_TOOL_IDS } from "../../src/runtime/product-mcp-tools.js";
|
||||
|
||||
const part = (callID: string, status = "running", input: unknown = { duration: 60 }) => ({ type: "tool", tool: "mcp_tjwater_run_simulation", callID, state: { status, input } });
|
||||
it("resolves only an exact active persisted native call and fails closed on ambiguity", () => {
|
||||
expect(resolveMatchingNativeCall([{ parts: [part("old", "completed"), part("current")] }], "run_simulation", { duration: 60 })).toBe("current");
|
||||
expect(resolveMatchingNativeCall([{ parts: [part("current")] }], "run_simulation", { duration: 61 })).toBeNull();
|
||||
expect(resolveMatchingNativeCall([{ parts: [part("a"), part("b")] }], "run_simulation", { duration: 60 })).toBeNull();
|
||||
});
|
||||
it("allows readers only job reads and never autoallows cancellation", () => {
|
||||
const context = { sessionId: "s", clientSessionId: "c", actorKey: "a", projectKey: "p", projectId: "p", traceId: "t", purpose: "task_execution" as const };
|
||||
const tools = allowedQueryTools(context, true);
|
||||
for (const name of ["get_calculation_job", "get_calculation_result", "list_calculation_jobs"]) expect(tools).toContain(`mcp_tjwater_${name}`);
|
||||
expect(tools).not.toContain("mcp_tjwater_cancel_calculation_job");
|
||||
expect(tools).not.toContain("mcp_tjwater_run_simulation");
|
||||
expect(QUERY_MCP_AUTOALLOW_TOOL_IDS).not.toContain("mcp_tjwater_cancel_calculation_job");
|
||||
});
|
||||
|
||||
|
||||
it("matches omitted MCP defaults against the actual native message-list normalizer", async () => {
|
||||
const schema = fromJsonSchema<Record<string, unknown>>({
|
||||
type: "object", additionalProperties: false,
|
||||
properties: { timeout_seconds: { type: "integer", default: 120 }, dry_run: { type: "boolean", default: true } },
|
||||
});
|
||||
for (const input of [{}, { timeout_seconds: 900 }, { dry_run: false }]) {
|
||||
const validated = await schema["~standard"].validate(input);
|
||||
expect(validated.issues).toBeUndefined();
|
||||
if (!("value" in validated)) throw new Error("MCP input validation failed");
|
||||
expect(validated.value).toEqual(input);
|
||||
const native = {
|
||||
id: "assistant-message", type: "assistant", time: { created: 1 },
|
||||
content: [{ type: "tool", id: "native-call", name: "mcp_tjwater_run_simulation", time: { created: 1 }, state: { status: "running", input } }],
|
||||
} as unknown as Parameters<typeof normalizeRuntimeMessage>[0];
|
||||
const messages = [normalizeRuntimeMessage(native, "native-session")];
|
||||
expect(resolveMatchingNativeCall(messages, "run_simulation", validated.value!)).toBe("native-call");
|
||||
expect(resolveMatchingNativeCall(messages, "run_simulation", { ...validated.value, arbitrary_parameter: true })).toBeNull();
|
||||
}
|
||||
});
|
||||
@@ -23,7 +23,7 @@ it("advertises simulation budgets and derives the native connection ceiling from
|
||||
}, additionalProperties: false } };
|
||||
const { server, client, call, query } = await setup({ productTools: [simulation] });
|
||||
setRuntimeSessionContext(owner);
|
||||
expect(server.executionTimeoutMs).toBe(3_665_000);
|
||||
expect(server.executionTimeoutMs).toBe(35_000);
|
||||
expect((await client.listTools()).tools[0].inputSchema.properties?.timeout_seconds).toMatchObject({ maximum: 3600 });
|
||||
expect((await call("run_simulation", { timeout_seconds: 900 })).isError).not.toBeTrue();
|
||||
expect((query.mock.calls[0] as unknown as unknown[])[3]).toEqual({ timeout_seconds: 900 });
|
||||
@@ -45,7 +45,7 @@ it("discovers calculations natively but denies verified readers before upstream
|
||||
async function setup(extra: Partial<Parameters<typeof startQueryMcpServer>[0]> = {}) {
|
||||
const executeLocal = mock(async () => ({ safe: true }));
|
||||
const query = mock(async () => ({ content: [{ type: "text" as const, text: "evidence" }], structuredContent: { evidence_id: "E1", data: { value: 1 } } }));
|
||||
const server = startQueryMcpServer({ internalToken: "MCP_TEST_ONLY", productClient: { query }, productTools: [productTool], resolveReader: async () => null, executeLocal, ...extra });
|
||||
const server = startQueryMcpServer({ internalToken: "MCP_TEST_ONLY", productClient: { query }, productTools: [productTool], resolveNativeCall: async () => "native-call-1", resolveReader: async () => null, executeLocal, ...extra });
|
||||
servers.push(server);
|
||||
const client = new Client({ name: "query-test", version: "1" }, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
|
||||
clients.push(client);
|
||||
@@ -159,3 +159,27 @@ it("replaces unavailable evidence resources with the authorized paging tool with
|
||||
data: { evidence_read: { tool: "mcp_tjwater_get_task_evidence", arguments: { evidence_id: "E1", limit: 20, offset: 0 } } } });
|
||||
expect(upstream.structuredContent.data.resource_uri).toBe(uri);
|
||||
});
|
||||
|
||||
|
||||
it("keeps the same host request identity across reconnect retries and rejects missing identity", async () => {
|
||||
setRuntimeSessionContext(owner);
|
||||
const { call, query } = await setup();
|
||||
await call("get_task_context");
|
||||
await call("get_task_context");
|
||||
const ids = query.mock.calls.map((args) => (args as unknown as unknown[])[4]);
|
||||
expect(ids[0]).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(ids[1]).toBe(ids[0]);
|
||||
const missing = await setup({ productTools: [{ name: "run_simulation", inputSchema: { type: "object", properties: {} } }], resolveNativeCall: async () => null });
|
||||
expect((await missing.call("run_simulation")).isError).toBeTrue();
|
||||
expect(missing.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("distinguishes safe network and timeout failures without upstream text", async () => {
|
||||
setRuntimeSessionContext(owner);
|
||||
for (const [cause, message] of [[new DOMException("SECRET", "TimeoutError"), "Query timed out"], [new TypeError("SECRET"), "Query network failure"]] as const) {
|
||||
const { call } = await setup({ productClient: { query: async () => { throw cause; } } });
|
||||
const result = await call("get_task_context");
|
||||
expect(JSON.stringify(result)).toContain(message);
|
||||
expect(JSON.stringify(result)).not.toContain("SECRET");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@ const simulation: Tool = { name: "run_simulation", inputSchema: { type: "object"
|
||||
} } };
|
||||
|
||||
it("budgets calculations independently from their simulated duration and from ordinary reads", () => {
|
||||
expect(queryTimeouts(simulation, { duration: 86400, timeout_seconds: 900 })).toEqual({ executionMs: 900_000, callMs: 905_000, requestMs: 960_000 });
|
||||
expect(queryTimeouts(simulation, {})).toEqual({ executionMs: 120_000, callMs: 125_000, requestMs: 180_000 });
|
||||
expect(queryTimeouts(simulation, { duration: 86400, timeout_seconds: 900 })).toEqual({ executionMs: 900_000, callMs: 30_000, requestMs: 35_000 });
|
||||
expect(queryTimeouts(simulation, {})).toEqual({ executionMs: 120_000, callMs: 30_000, requestMs: 35_000 });
|
||||
expect(queryTimeouts({ name: "list_elements", inputSchema: { type: "object" } }, {})).toEqual({ executionMs: 30_000, callMs: 30_000, requestMs: 35_000 });
|
||||
});
|
||||
|
||||
@@ -21,6 +21,14 @@ it("honors the effective advertised maximum and rejects invalid per-call budgets
|
||||
} } };
|
||||
expect(queryTimeouts(limited, { timeout_seconds: 120 }).executionMs).toBe(120_000);
|
||||
expect(() => queryTimeouts(limited, { timeout_seconds: 121 })).toThrow();
|
||||
expect(nativeQueryTimeout([simulation, limited])).toBe(3_665_000);
|
||||
expect(nativeQueryTimeout([limited])).toBe(185_000);
|
||||
expect(nativeQueryTimeout([simulation, limited])).toBe(35_000);
|
||||
expect(nativeQueryTimeout([limited])).toBe(35_000);
|
||||
});
|
||||
|
||||
it("uses the catalog's thirty-minute burst-location default as a worker execution budget", () => {
|
||||
const location: Tool = { name: "run_burst_location", inputSchema: { type: "object", properties: {
|
||||
timeout_seconds: { type: "integer", minimum: 1, maximum: 3600, default: 1800 },
|
||||
} } };
|
||||
expect(queryTimeouts(location, {})).toEqual({ executionMs: 1_800_000, callMs: 30_000, requestMs: 35_000 });
|
||||
expect(nativeQueryTimeout([location])).toBe(35_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, it, mock } from "bun:test";
|
||||
import { createTaskCalculationWaiter } from "../../src/mcp/task-calculation-waiter.js";
|
||||
const owner = { context: { sessionId: "s", clientSessionId: "c", actorKey: "a", projectKey: "p", projectId: "p", traceId: "t", purpose: "task_execution" as const }, runtimeSessionId: "s" };
|
||||
const result = (status: string) => ({ content: [], structuredContent: { data: [{ job_id: "job-1", status, poll_after_ms: 1500 }], page: { has_more: false } } });
|
||||
it("waits in the host across bounded status reads then resumes once for terminal results", async () => {
|
||||
const outputs = [result("queued"), result("running"), result("succeeded"), result("succeeded")];
|
||||
const query = mock(async () => outputs.shift()!);
|
||||
const pause = mock(async (_ms: number, _signal: AbortSignal) => {});
|
||||
const wait = createTaskCalculationWaiter({ query }, () => owner, pause);
|
||||
const signal = new AbortController().signal;
|
||||
expect(await wait("s", signal)).toContain("get_calculation_result");
|
||||
expect(pause).toHaveBeenCalledTimes(2);
|
||||
expect(pause.mock.calls[0]?.[0]).toBe(1500);
|
||||
expect(await wait("s", signal)).toBeNull();
|
||||
expect(query).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
it("does not turn unavailable status into a completed task", async () => {
|
||||
const query = mock(async () => ({ content: [], isError: true }));
|
||||
await expect(createTaskCalculationWaiter({ query }, () => owner)("s", new AbortController().signal)).rejects.toThrow("unavailable");
|
||||
});
|
||||
it("aborts host waiting without issuing Server cancellation", async () => {
|
||||
const controller = new AbortController();
|
||||
const names: string[] = [];
|
||||
const query = mock(async (_id: string, _owner: unknown, name: string) => { names.push(name); return result("running"); });
|
||||
const wait = createTaskCalculationWaiter({ query }, () => owner, async () => { controller.abort(); });
|
||||
await expect(wait("s", controller.signal)).rejects.toThrow();
|
||||
expect(names).toEqual(["list_calculation_jobs"]);
|
||||
});
|
||||
|
||||
it("resumes recovery for a worker-interrupted job instead of waiting or completing silently", async () => {
|
||||
const query = mock(async () => result("interrupted"));
|
||||
const pause = mock(async (_ms: number, _signal: AbortSignal) => {});
|
||||
const wait = createTaskCalculationWaiter({ query }, () => owner, pause);
|
||||
const resume = await wait("s", new AbortController().signal);
|
||||
expect(resume).toContain("interrupted");
|
||||
expect(resume).toContain("不要重复提交");
|
||||
expect(pause).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -19,7 +19,9 @@ it("configures a pinned native MCP connection using the host-owned endpoint and
|
||||
writeQueryMcpConnection(join(root, "sessions"), "http://127.0.0.1:12345/mcp");
|
||||
expect(queryMcpConnection()).toMatchObject({ type: "remote", protocol: "2026-07-28", oauth: false, codemode: false, headers: { "x-agent-internal-token": "MCP_TEST_ONLY" } });
|
||||
writeQueryMcpConnection(join(root, "sessions"), "http://127.0.0.1:12345/mcp", 3_665_000);
|
||||
expect(queryMcpConnection()?.timeout?.execution).toBe(3_665_000);
|
||||
expect(() => queryMcpConnection()).toThrow("timeout");
|
||||
writeQueryMcpConnection(join(root, "sessions"), "http://127.0.0.1:12345/mcp", 35_000);
|
||||
expect(queryMcpConnection()?.timeout?.execution).toBe(35_000);
|
||||
writeQueryMcpConnection(join(root, "sessions"), "http://127.0.0.1:12345/mcp", Infinity);
|
||||
expect(() => queryMcpConnection()).toThrow("timeout");
|
||||
writeQueryMcpConnection(join(root, "sessions"), "https://foreign.test/mcp");
|
||||
|
||||
@@ -45,8 +45,8 @@ it("forwards the selected simulation budget through HTTP and SDK while rejecting
|
||||
await expect(client.query(id, owner(id), "run_simulation", { timeout_seconds: 3601 }, "excess")).rejects.toMatchObject({ status: 400 });
|
||||
expect(requests).toBe(0);
|
||||
const result = await client.query(id, owner(id), "run_simulation", { timeout_seconds: 900 }, "long");
|
||||
expect(transportTimeout).toBe(905_000);
|
||||
expect(sdkTimeout).toBe(905_000);
|
||||
expect(transportTimeout).toBe(30_000);
|
||||
expect(sdkTimeout).toBe(30_000);
|
||||
expect(result.structuredContent).toMatchObject({ arguments: { timeout_seconds: 900, context_handle: "x".repeat(43) } });
|
||||
});
|
||||
|
||||
|
||||
@@ -45,6 +45,29 @@ describe("streamPromptResponse", () => {
|
||||
expect(emitted.filter((event) => event === "done")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("resumes the same investigation with host-provided calculation result guidance", async () => {
|
||||
const prompts: string[] = [];
|
||||
let idleChecks = 0;
|
||||
const runtime = {
|
||||
prompt: async (_sessionId: string, text: string) => { prompts.push(text); },
|
||||
subscribeEvents: async () => createEventStream([1, 2].flatMap((round) => [
|
||||
{ type: "message.part.updated", properties: { sessionID: "s", part: {
|
||||
id: `calculation-text-${round}`, type: "text", text: `round ${round}`, sessionID: "s", messageID: `m-${round}`,
|
||||
} } },
|
||||
{ type: "session.idle", properties: { sessionID: "s" } },
|
||||
])),
|
||||
messages: async () => [],
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const result = await streamPromptResponse({
|
||||
runtime, sessionId: "s", clientSessionId: "client", message: "investigate", suppressAnswerUi: true,
|
||||
beforeIdle: async () => ++idleChecks === 1 ? "Read get_calculation_result for job-1" : true,
|
||||
write: () => {},
|
||||
});
|
||||
expect(result.failed).toBeFalse();
|
||||
expect(prompts).toEqual(["investigate", "Read get_calculation_result for job-1"]);
|
||||
expect(idleChecks).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps the first pending event when the V2 prompt acknowledgement arrives first", async () => {
|
||||
const emitted: { event: string; data: Record<string, unknown> }[] = [];
|
||||
const runtime = {
|
||||
|
||||
Reference in New Issue
Block a user