feat: upgrade investigation and presentation runtime
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { taskReportSchema, taskProgressSchema } from "../../src/product/investigation-report.js";
|
||||
import { taskPublicationDiagramSchema, taskPublicationProgressSchema, taskReportUiBlocksSchema } from "../../.opencode/tools/task-publication-schema.js";
|
||||
|
||||
const report = { title: "调查报告", current_conclusion: "无法确定原因", conclusion_boundary: "仅有上游数据",
|
||||
evidence_refs: ["query-1"], recommendations: [], pending_items: [], stage: "final", outcome: "insufficient_data",
|
||||
@@ -26,6 +27,9 @@ describe("new investigation report writes", () => {
|
||||
for (const mermaid of ['%%{init: {}}%%\nflowchart TD\nA-->B', 'flowchart TD\nclick A callback', 'flowchart TD; A-->B; click A callback', 'sequenceDiagram\nA->>B: 查询']) {
|
||||
expect(() => taskReportSchema.parse({ ...report, diagram: { ...diagram, mermaid } })).toThrow();
|
||||
}
|
||||
const mismatched = { ...diagram, node_refs: [{ ...diagram.node_refs[0], node_id: "query" }] };
|
||||
expect(() => taskReportSchema.parse({ ...report, diagram: mismatched })).toThrow("未在 Mermaid 源码中声明");
|
||||
expect(taskPublicationDiagramSchema.safeParse(mismatched).success).toBe(false);
|
||||
});
|
||||
it("rejects downstream work or unsupported claims on insufficient-data exit", () => {
|
||||
for (const changes of [{ recommendations: ["派采样工单"] }, { pending_items: ["等待人工"] },
|
||||
@@ -43,13 +47,26 @@ describe("new investigation report writes", () => {
|
||||
expect(progress.diagram?.node_refs[0]).toMatchObject({ status: "running", map_targets: [{ source_id: "scada", feature_id: "MP01" }] });
|
||||
expect(taskReportSchema.parse({ ...report, diagram }).diagram).toEqual(progress.diagram);
|
||||
expect(taskProgressSchema.parse({ summary: "调整调查方向", diagram: null }).diagram).toBeNull();
|
||||
for (const change of [{ status: "waiting" }, { map_targets: [{ source_id: "drainage", feature_id: "X" }] },
|
||||
for (const change of [{ status: "blocked" }, { map_targets: [{ source_id: "drainage", feature_id: "X" }] },
|
||||
{ map_targets: [{ source_id: "scada", feature_id: "" }] }]) {
|
||||
expect(() => taskProgressSchema.parse({ summary: "核对", diagram: {
|
||||
...diagram, node_refs: [{ ...diagram.node_refs[0], ...change }],
|
||||
} })).toThrow();
|
||||
}
|
||||
});
|
||||
it("preserves waiting, unknown and evidence-confirmed states at every diagram boundary", () => {
|
||||
const diagram = { title: "等待系统结果", mermaid: "flowchart LR\nDONE --> WAIT --> UNKNOWN --> UNSET", node_refs: [
|
||||
{ node_id: "DONE", label: "查询完成", status: "completed", evidence_refs: ["query-1"] },
|
||||
{ node_id: "WAIT", label: "等待系统结果", status: "waiting" },
|
||||
{ node_id: "UNKNOWN", label: "未知", status: null },
|
||||
{ node_id: "UNSET", label: "未确认" },
|
||||
] };
|
||||
const nodes = taskProgressSchema.parse({ summary: "等待系统结果", diagram }).diagram!.node_refs;
|
||||
expect(nodes?.map((node) => node.status)).toEqual(["completed", "waiting", null, undefined]);
|
||||
expect(taskReportSchema.parse({ ...report, diagram }).diagram?.node_refs).toEqual(nodes);
|
||||
expect(taskPublicationDiagramSchema.parse(diagram).node_refs).toEqual(nodes);
|
||||
expect(taskPublicationProgressSchema.parse({ summary: "等待系统结果", diagram }).diagram?.node_refs).toEqual(nodes);
|
||||
});
|
||||
it("preserves parallel branches with independent activity and a pending join", () => {
|
||||
const mermaid = 'flowchart TD\nSTART[受理投诉] --> SPLIT{并行核查}\nSPLIT --> TICKETS[工单核查]\nSPLIT --> SCADA[监测核查]\nTICKETS --> JOIN[汇总证据]\nSCADA --> JOIN';
|
||||
const diagram = { title: "并行调查", mermaid, node_refs: [
|
||||
@@ -76,6 +93,15 @@ const uiBlock = {
|
||||
};
|
||||
|
||||
describe("interactive report publication", () => {
|
||||
it("accepts final and legacy blocks and rejects process or unknown phases at both publication boundaries", () => {
|
||||
for (const phase of [undefined, "final", "process", "unknown"]) {
|
||||
const block = phase === undefined ? uiBlock : { ...uiBlock, phase };
|
||||
const blocks = [{ block, evidence_refs: ["query-1"] }];
|
||||
const accepted = phase === undefined || phase === "final";
|
||||
expect(taskReportSchema.safeParse({ ...report, ui_blocks: blocks }).success).toBe(accepted);
|
||||
expect(taskReportUiBlocksSchema.safeParse(blocks).success).toBe(accepted);
|
||||
}
|
||||
});
|
||||
it("preserves rich blocks and legacy reports without changing fixed facts", () => {
|
||||
expect(taskReportSchema.parse(report).ui_blocks).toBeUndefined();
|
||||
const parsed = taskReportSchema.parse({ ...report, ui_blocks: [{ block: uiBlock, evidence_refs: ["query-1"] }] });
|
||||
|
||||
@@ -7,7 +7,7 @@ const sessions: string[] = [];
|
||||
afterEach(() => { for (const id of sessions.splice(0)) releaseSessionCredential(id); });
|
||||
const settings = { issuer: "http://localhost/realms/test", resource: "http://localhost/mcp", audience: "tjwater-mcp", clientId: "tjwater-agent-mcp", clientSecret: "fixture-secret" };
|
||||
const owner = (id: string) => ({ context: { sessionId: id, actorKey: "actor", clientSessionId: id, projectId: "project", projectKey: "project", traceId: "test", purpose: "ordinary_chat" as const }, runtimeSessionId: id });
|
||||
const catalog = PRODUCT_MCP_TOOLS.map((name) => ({ name, inputSchema: { type: "object" as const, additionalProperties: false, properties: { context_handle: { type: "string" }, element_type: { type: "string" }, ...(name === "run_simulation" ? { timeout_seconds: { type: "integer", default: 120, maximum: 3600 } } : {}) }, required: ["context_handle", "element_type"] } }));
|
||||
const catalog = PRODUCT_MCP_TOOLS.map((name) => ({ name, inputSchema: { type: "object" as const, additionalProperties: false, properties: { context_handle: { type: "string" }, element_type: { type: "string" }, ...(name === "run_simulation" ? { timeout_seconds: { type: "integer", default: 120, maximum: 240 } } : {}) }, required: ["context_handle", "element_type"] } }));
|
||||
// Test through a transport-independent fake only where the concern is token ownership/renewal.
|
||||
function fixture(http: McpHttpFetch, authMethod = "client_secret_post") {
|
||||
const client = new McpQueryClient(settings, (async (url, init) => {
|
||||
@@ -26,7 +26,7 @@ const resourceToken = jwt("resource");
|
||||
const token = () => Response.json({ access_token: resourceToken, token_type: "Bearer", expires_in: 300 });
|
||||
function credential(id: string) { sessions.push(id); bindSessionCredential(id, { authorization: `Bearer ${jwt(id)}`, projectId: "project" }); }
|
||||
|
||||
it("forwards the selected simulation budget through HTTP and SDK while rejecting excess before OAuth", async () => {
|
||||
it("owns simulation HTTP budgets and rejects model overrides before OAuth", async () => {
|
||||
const id = "mcp-calculation-budget"; credential(id);
|
||||
let requests = 0, transportTimeout = 0, sdkTimeout = 0;
|
||||
const client = fixture((async (url, init) => {
|
||||
@@ -42,12 +42,14 @@ it("forwards the selected simulation budget through HTTP and SDK while rejecting
|
||||
return { content: [], structuredContent: params };
|
||||
} });
|
||||
} });
|
||||
await expect(client.query(id, owner(id), "run_simulation", { timeout_seconds: 3601 }, "excess")).rejects.toMatchObject({ status: 400 });
|
||||
await expect(client.query(id, owner(id), "run_simulation", { timeout_seconds: 241 }, "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(30_000);
|
||||
expect(sdkTimeout).toBe(30_000);
|
||||
expect(result.structuredContent).toMatchObject({ arguments: { timeout_seconds: 900, context_handle: "x".repeat(43) } });
|
||||
expect(client.catalog().find((tool) => tool.name === "run_simulation")!.inputSchema.properties).not.toHaveProperty("timeout_seconds");
|
||||
Object.assign(client, { tools: catalog.map((tool) => ({ ...tool, _meta: { "io.tjwater/request-timeout": { defaultSeconds: 240, maxSeconds: 240 } } })) });
|
||||
const result = await client.query(id, owner(id), "run_simulation", {}, "long");
|
||||
expect(transportTimeout).toBe(270_000);
|
||||
expect(sdkTimeout).toBe(270_000);
|
||||
expect(result.structuredContent).toMatchObject({ arguments: { timeout_seconds: 240, context_handle: "x".repeat(43) } });
|
||||
});
|
||||
|
||||
it("requires HTTPS except loopback, and fixed resource paths", () => {
|
||||
|
||||
@@ -196,6 +196,26 @@ describe("OperationalTaskClient", () => {
|
||||
expect(requests[2]!.headers.get("authorization")).toBe("Bearer transient-secret");
|
||||
});
|
||||
|
||||
it("injects the claimed run into task questions without changing its lease revision", async () => {
|
||||
const requests: Request[] = [];
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const request = new Request(input, init); requests.push(request);
|
||||
if (request.url.endsWith("/questions")) return Response.json({ question_request_id: crypto.randomUUID(), status: "pending", version: 1 });
|
||||
return Response.json(run);
|
||||
}) as typeof fetch;
|
||||
bindOperationalTaskCredential("runtime-1", { authorization: "Bearer transient-secret", projectId: "project-1" });
|
||||
const client = new OperationalTaskClient("http://server.test");
|
||||
const questions = [{ question_id: "flow", title: "流量", body: "是否有计量记录?", options: [],
|
||||
mode: "single" as const, allow_custom: true, required: true }];
|
||||
await expect(client.askTaskUser("runtime-1", taskId, { request_id: executionRequestId,
|
||||
sourceSessionId: "session-1", questions })).rejects.toThrow("task run context unavailable");
|
||||
await client.claimTask("runtime-1", taskId, runId, { requestId: executionRequestId, expectedRevision: 1, sourceSessionId: "session-1" });
|
||||
await client.askTaskUser("runtime-1", taskId, { request_id: executionRequestId, sourceSessionId: "session-1", questions });
|
||||
expect(await requests[1]!.json()).toEqual({ request_id: executionRequestId, source_session_id: "session-1", run_id: runId, questions });
|
||||
await client.heartbeatTask("runtime-1", taskId, runId, "session-1");
|
||||
expect(await requests[2]!.json()).toMatchObject({ expected_revision: run.revision });
|
||||
});
|
||||
|
||||
it("fails closed without a current product credential", async () => {
|
||||
const client = new OperationalTaskClient("http://server.test");
|
||||
expect(client.getAgentContext("runtime-1", "50000000-0000-4000-8000-000000000001"))
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("task presentation input boundaries", () => {
|
||||
it("accepts finite display commands and rejects unsupported data, layers and chart evidence", () => {
|
||||
const chart = { title: "压力", chart_type: "line", x_data: ["10:00"], series: [{ name: "MP01", data: [0.4] }] };
|
||||
const commands = [
|
||||
{ action: "highlight_features", layer: "scada", feature_ids: ["MP01"] },
|
||||
{ action: "highlight_features", layer: "scada", feature_ids: ["MP01"], effect: "pulse" },
|
||||
{ action: "clear_highlights" },
|
||||
{ action: "open_chart", chart_id: "pressure", chart, evidence_refs: ["reading-1"] },
|
||||
{ action: "close_chart", chart_id: "pressure" },
|
||||
@@ -47,5 +47,10 @@ describe("task presentation input boundaries", () => {
|
||||
await expect(publishProgress.execute({ request_id, progress: {
|
||||
summary: "查询", diagram: { title: "路线", mermaid: "flowchart TD; A-->B; click A callback", node_refs: [] },
|
||||
} }, { sessionID: "test" })).rejects.toThrow("Mermaid");
|
||||
await expect(publishProgress.execute({ request_id, progress: {
|
||||
summary: "查询", diagram: { title: "路线", mermaid: "flowchart TD\nA-->B",
|
||||
node_refs: [{ node_id: "query", label: "查询", status: "running",
|
||||
evidence_refs: [], action_refs: [], map_targets: [] }] },
|
||||
} }, { sessionID: "test" })).rejects.toThrow("未在 Mermaid 源码中声明");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user