feat: upgrade investigation and presentation runtime
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { afterEach, expect, it, mock } from "bun:test";
|
||||
import { afterEach, expect, it, mock, spyOn } from "bun:test";
|
||||
import { Client, StreamableHTTPClientTransport, type Tool } from "@modelcontextprotocol/client";
|
||||
import { startQueryMcpServer } from "../../src/mcp/query-server.js";
|
||||
import { createLocalQueryExecutor } from "../../src/mcp/local-query-tools.js";
|
||||
@@ -17,17 +17,17 @@ afterEach(async () => {
|
||||
});
|
||||
const productTool: Tool = { name: "get_task_context", description: "Bound task", inputSchema: { type: "object", properties: { report_revision: { type: "integer", minimum: 1 } }, additionalProperties: false } };
|
||||
|
||||
it("advertises simulation budgets and derives the native connection ceiling from the catalog", async () => {
|
||||
it("hides HTTP budgets from models and fixes the native connection ceiling", async () => {
|
||||
const simulation: Tool = { name: "run_simulation", inputSchema: { type: "object", properties: {
|
||||
timeout_seconds: { type: "integer", minimum: 1, maximum: 3600, default: 120 },
|
||||
timeout_seconds: { type: "integer", minimum: 1, maximum: 240, default: 120 },
|
||||
}, additionalProperties: false } };
|
||||
const { server, client, call, query } = await setup({ productTools: [simulation] });
|
||||
setRuntimeSessionContext(owner);
|
||||
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 });
|
||||
try { await call("run_simulation", { timeout_seconds: 3601 }); } catch { /* SDK schema rejection */ }
|
||||
expect(server.executionTimeoutMs).toBe(300_000);
|
||||
expect((await client.listTools()).tools[0].inputSchema.properties).not.toHaveProperty("timeout_seconds");
|
||||
expect((await call("run_simulation", {})).isError).not.toBeTrue();
|
||||
expect((query.mock.calls[0] as unknown as unknown[])[3]).toEqual({});
|
||||
try { await call("run_simulation", { timeout_seconds: 241 }); } catch { /* SDK schema rejection */ }
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("discovers calculations natively but denies verified readers before upstream dispatch", async () => {
|
||||
@@ -183,3 +183,23 @@ it("distinguishes safe network and timeout failures without upstream text", asyn
|
||||
expect(JSON.stringify(result)).not.toContain("SECRET");
|
||||
}
|
||||
});
|
||||
|
||||
for (const stage of ["reader", "identity", "artifact"] as const) {
|
||||
it(`bounds a hanging ${stage} operation within the host deadline`, async () => {
|
||||
const never = () => new Promise<never>(() => {});
|
||||
const name = stage === "artifact" ? "export_project_model" : "get_task_context";
|
||||
const { call } = await setup({ productTools: [{ ...productTool, name }],
|
||||
...(stage === "reader" ? { resolveReader: never } : {}),
|
||||
...(stage === "identity" ? { resolveNativeCall: never } : {}),
|
||||
...(stage === "artifact" ? { artifactWorkspace: never } : {}),
|
||||
});
|
||||
if (stage !== "reader") setRuntimeSessionContext(owner);
|
||||
const original = AbortSignal.timeout.bind(AbortSignal);
|
||||
const timeout = spyOn(AbortSignal, "timeout").mockImplementation((ms) => original(ms === 180_000 ? 20 : ms));
|
||||
try {
|
||||
const result = await call(name);
|
||||
expect(result.isError).toBeTrue();
|
||||
expect(JSON.stringify(result)).toContain("Query timed out");
|
||||
} finally { timeout.mockRestore(); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,34 +1,29 @@
|
||||
import { expect, it } from "bun:test";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import { nativeQueryTimeout, queryTimeouts } from "../../src/mcp/query-timeouts.js";
|
||||
import { boundedQuery, nativeQueryTimeout, queryTimeouts } from "../../src/mcp/query-timeouts.js";
|
||||
|
||||
const simulation: Tool = { name: "run_simulation", inputSchema: { type: "object", properties: {
|
||||
timeout_seconds: { type: "integer", minimum: 1, maximum: 3600, default: 120 },
|
||||
timeout_seconds: { type: "integer", minimum: 1, maximum: 240, default: 120 },
|
||||
} } };
|
||||
|
||||
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: 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 });
|
||||
it("keeps HTTP budgets host-owned and separate from calculation duration", () => {
|
||||
expect(queryTimeouts(simulation, { duration: 86400, timeout_seconds: 240 })).toEqual({ serverMs: 120_000, callMs: 180_000, requestMs: 180_000 });
|
||||
expect(queryTimeouts({ name: "list_elements", inputSchema: { type: "object" }, _meta: {
|
||||
"io.tjwater/request-timeout": { defaultSeconds: 240, maxSeconds: 240 },
|
||||
} }).requestMs).toBe(270_000);
|
||||
expect(nativeQueryTimeout([simulation])).toBe(300_000);
|
||||
expect(nativeQueryTimeout([])).toBe(300_000);
|
||||
});
|
||||
|
||||
it("honors the effective advertised maximum and rejects invalid per-call budgets", () => {
|
||||
for (const timeout_seconds of [0, -1, 1.5, 3601, "900", null, true, NaN, Infinity]) {
|
||||
expect(() => queryTimeouts(simulation, { timeout_seconds })).toThrow();
|
||||
it("falls back safely for malformed advertised budgets", () => {
|
||||
for (const metadata of [null, [], { defaultSeconds: 999, maxSeconds: 999 }, { defaultSeconds: 240, maxSeconds: 120 }, { defaultSeconds: "120", maxSeconds: 240 }]) {
|
||||
expect(queryTimeouts({ ...simulation, _meta: { "io.tjwater/request-timeout": metadata } }).requestMs).toBe(180_000);
|
||||
}
|
||||
const limited = { ...simulation, inputSchema: { type: "object" as const, properties: {
|
||||
timeout_seconds: { type: "integer", maximum: 120, default: 120 },
|
||||
} } };
|
||||
expect(queryTimeouts(limited, { timeout_seconds: 120 }).executionMs).toBe(120_000);
|
||||
expect(() => queryTimeouts(limited, { timeout_seconds: 121 })).toThrow();
|
||||
expect(nativeQueryTimeout([simulation, limited])).toBe(35_000);
|
||||
expect(nativeQueryTimeout([limited])).toBe(35_000);
|
||||
expect(queryTimeouts({ ...simulation, inputSchema: { type: "object", properties: {
|
||||
timeout_seconds: { type: "integer", maximum: 3600, default: 1800 },
|
||||
} } }).requestMs).toBe(180_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);
|
||||
it("returns on deadline even when the underlying adapter ignores cancellation", async () => {
|
||||
await expect(boundedQuery(() => new Promise(() => {}), AbortSignal.timeout(10))).rejects.toThrow();
|
||||
});
|
||||
|
||||
@@ -36,3 +36,81 @@ it("resumes recovery for a worker-interrupted job instead of waiting or completi
|
||||
expect(resume).toContain("不要重复提交");
|
||||
expect(pause).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const failure of [
|
||||
Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET" } }),
|
||||
new DOMException("Timed out", "TimeoutError"),
|
||||
Object.assign(new Error("busy"), { status: 429 }),
|
||||
Object.assign(new Error("unavailable"), { status: 503 }),
|
||||
...["RATE_LIMITED", "QUERY_TIMEOUT", "SOURCE_UNAVAILABLE"].map((code) => ({ content: [], isError: true, structuredContent: { error: { code } } })),
|
||||
]) {
|
||||
it(`retries transient status failures (${"name" in failure ? failure.name : failure.structuredContent.error.code})`, async () => {
|
||||
let calls = 0;
|
||||
const query = mock(async () => {
|
||||
if (calls++ === 0) { if (failure instanceof Error || failure instanceof DOMException) throw failure; return failure; }
|
||||
return result("succeeded");
|
||||
});
|
||||
const pause = mock(async (_ms: number, _signal: AbortSignal) => {});
|
||||
const wait = createTaskCalculationWaiter({ query }, () => owner, pause, () => 0.5);
|
||||
expect(await wait("s", new AbortController().signal)).toContain("succeeded");
|
||||
expect(pause.mock.calls.map(([ms]) => ms)).toEqual([1000]);
|
||||
expect(query).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
}
|
||||
|
||||
it("bounds exponential retry delays and fails clearly after exhaustion", async () => {
|
||||
const names: string[] = [];
|
||||
const query = mock(async (_id: string, _owner: unknown, name: string) => {
|
||||
names.push(name);
|
||||
throw Object.assign(new Error("unavailable"), { status: 503 });
|
||||
});
|
||||
const pause = mock(async (_ms: number, _signal: AbortSignal) => {});
|
||||
const wait = createTaskCalculationWaiter({ query }, () => owner, pause, () => 1);
|
||||
await expect(wait("s", new AbortController().signal)).rejects.toThrow("after transient retries");
|
||||
expect(pause.mock.calls.map(([ms]) => ms)).toEqual([1250, 2500, 5000]);
|
||||
expect(names).toEqual(Array(4).fill("list_calculation_jobs"));
|
||||
});
|
||||
|
||||
for (const failure of [
|
||||
Object.assign(new Error("denied"), { status: 401 }),
|
||||
Object.assign(new Error("denied"), { status: 403 }),
|
||||
...["AUTH_REQUIRED", "QUERY_CONTEXT_DENIED", "QUERY_CONTEXT_STALE"].map((code) => ({ content: [], isError: true, structuredContent: { error: { code } } })),
|
||||
]) {
|
||||
it("fails authorization and stale context errors without retrying", async () => {
|
||||
const query = mock(async () => { if (failure instanceof Error) throw failure; return failure; });
|
||||
const pause = mock(async (_ms: number, _signal: AbortSignal) => {});
|
||||
await expect(createTaskCalculationWaiter({ query }, () => owner, pause)("s", new AbortController().signal)).rejects.toThrow();
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
expect(pause).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
it("aborts retry backoff without reading again or cancelling jobs", async () => {
|
||||
const controller = new AbortController();
|
||||
const names: string[] = [];
|
||||
const query = mock(async (_id: string, _owner: unknown, name: string) => {
|
||||
names.push(name);
|
||||
throw new DOMException("Timed out", "TimeoutError");
|
||||
});
|
||||
const wait = createTaskCalculationWaiter({ query }, () => owner, async () => { controller.abort(); });
|
||||
await expect(wait("s", controller.signal)).rejects.toThrow();
|
||||
expect(names).toEqual(["list_calculation_jobs"]);
|
||||
});
|
||||
|
||||
it("returns new terminal jobs while other jobs remain pending and checks pending jobs next round", async () => {
|
||||
const mixed = { content: [], structuredContent: { data: [{ job_id: "job-1", status: "succeeded" }, { job_id: "job-2", status: "running" }], page: { has_more: false } } };
|
||||
const finished = { content: [], structuredContent: { data: [{ job_id: "job-1", status: "succeeded" }, { job_id: "job-2", status: "failed" }], page: { has_more: false } } };
|
||||
const outputs = [mixed, mixed, finished];
|
||||
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;
|
||||
const first = await wait("s", signal);
|
||||
expect(first).toContain("job-1");
|
||||
expect(first).not.toContain("job-2");
|
||||
expect(pause).not.toHaveBeenCalled();
|
||||
const second = await wait("s", signal);
|
||||
expect(second).toContain("job-2");
|
||||
expect(second).not.toContain("job-1");
|
||||
expect(pause).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user