feat: upgrade investigation and presentation runtime
Generic Container CI/CD / test-build-publish (push) Successful in 1m6s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 1m7s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent 3b52a0ddab
commit 6336157ff4
104 changed files with 6541 additions and 961 deletions
+28 -8
View File
@@ -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(); }
});
}
+17 -22
View File
@@ -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();
});
+78
View File
@@ -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);
});