127 lines
7.8 KiB
TypeScript
127 lines
7.8 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import { createExecutionPlanSnapshot, executionPlanNeedsTaskPublish, executionPlanSchema, newExecutionPlanSchema, readExecutionPlans } from "../../src/routes/executionPlan.js";
|
|
import { streamPromptResponse } from "../../src/routes/chatStream.js";
|
|
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
|
|
|
const plan = {
|
|
title: "水质调查", reason: "收到投诉",
|
|
nodes: [
|
|
{ id: "upstream", title: "上游监测", status: "completed", owner: "agent", detail: "合成数据", evidence: ["场景输入:上游指标正常"] },
|
|
{ id: "sample", title: "现场采样", status: "waiting", owner: "human", detail: "等待现场记录", evidence: [] },
|
|
],
|
|
edges: [{ from: "upstream", to: "sample", label: "上游正常" }],
|
|
};
|
|
|
|
describe("execution plans", () => {
|
|
it("requires v2 for newly published task plans while retaining legacy reads", () => {
|
|
const legacy = {
|
|
title: "Legacy plan",
|
|
reason: "Stored before graph plans",
|
|
nodes: [{ id: "inspect", title: "Inspect", status: "pending", owner: "agent", detail: "", evidence: [] }],
|
|
edges: [],
|
|
};
|
|
|
|
expect(executionPlanSchema.parse(legacy).schema).toBe("execution-plan@1");
|
|
expect(newExecutionPlanSchema.safeParse(legacy).success).toBe(false);
|
|
});
|
|
|
|
it("accepts explicit v2 branches and structural joins without invented evidence", () => {
|
|
const v2 = {
|
|
schema: "execution-plan@2", title: "动态调查", reason: "工单请求现场及数据并行核验",
|
|
nodes: [
|
|
{ id: "split", title: "并行启动", status: "completed", owner: "agent", detail: "同时核查", evidence: [], kind: "parallel_split" },
|
|
{ id: "field", title: "现场采样", status: "waiting", owner: "human", detail: "等待采样", evidence: [], kind: "activity" },
|
|
{ id: "data", title: "监测数据", status: "completed", owner: "agent", detail: "已核对", evidence: ["e-1"], kind: "activity" },
|
|
{ id: "join", title: "证据汇合", status: "waiting", owner: "agent", detail: "等待现场", evidence: [], kind: "parallel_join" },
|
|
],
|
|
edges: [
|
|
{ id: "field-route", from: "split", to: "field", label: "现场" },
|
|
{ id: "data-route", from: "split", to: "data", label: "数据" },
|
|
{ id: "field-join", from: "field", to: "join", label: "" },
|
|
{ id: "data-join", from: "data", to: "join", label: "" },
|
|
],
|
|
};
|
|
const parsed = executionPlanSchema.parse(v2);
|
|
expect(createExecutionPlanSnapshot(parsed, [], "session-1").schema_version).toBe("execution-plan@2");
|
|
expect(executionPlanSchema.safeParse({ ...v2, edges: [{ ...v2.edges[0], id: "data-route" }, ...v2.edges.slice(1)] }).success).toBe(false);
|
|
});
|
|
it("rejects missing dependencies, cycles, duplicate IDs and unsupported completion", () => {
|
|
for (const invalid of [
|
|
{ ...plan, edges: [{ from: "unknown", to: "sample", label: "" }] },
|
|
{ ...plan, edges: [...plan.edges, { from: "sample", to: "upstream", label: "" }] },
|
|
{ ...plan, nodes: [plan.nodes[0], plan.nodes[0]] },
|
|
{ ...plan, nodes: [{ ...plan.nodes[0], evidence: [] }, plan.nodes[1]] },
|
|
{
|
|
...plan,
|
|
nodes: [
|
|
{ ...plan.nodes[0], status: "waiting", detail: "等待数据", evidence: [] },
|
|
{ ...plan.nodes[1], status: "completed", evidence: ["evidence-2"] },
|
|
],
|
|
},
|
|
]) expect(executionPlanSchema.safeParse(invalid).success).toBe(false);
|
|
});
|
|
|
|
it("preserves completed evidence and versions across persisted UI messages and active snapshots", () => {
|
|
const first = createExecutionPlanSnapshot(plan, [], "session-1");
|
|
const messages = [{ parts: [{ type: "data-execution_plan", data: first }] }, { executionPlans: [first] }];
|
|
expect(readExecutionPlans(messages)).toHaveLength(1);
|
|
const next = createExecutionPlanSnapshot({ ...plan, reason: "等待人工补充" }, messages, "session-1");
|
|
expect(next.revision).toBe(2);
|
|
expect(next.nodes[1].status).toBe("waiting");
|
|
expect(() => createExecutionPlanSnapshot({ ...plan, nodes: [plan.nodes[1]], edges: [] }, messages, "session-1")).toThrow("Keep previous nodes");
|
|
expect(() => createExecutionPlanSnapshot({ ...plan, nodes: [{ ...plan.nodes[0], status: "running" }, plan.nodes[1]] }, messages, "session-1")).toThrow("Keep completed evidence");
|
|
});
|
|
|
|
it("publishes only successful validated tool completions, once per call", async () => {
|
|
const part = (status: string) => ({ type: "message.part.updated", properties: { sessionID: "runtime", part: {
|
|
id: "plan-call", messageID: "assistant", sessionID: "runtime", type: "tool", tool: "publish_execution_plan",
|
|
callID: "call-1", state: { status, input: plan, output: JSON.stringify({ plan_revision: 1, report_revision: null }), title: "plan", time: { start: 1, end: 2 } }
|
|
} } });
|
|
const events = [part("running"), part("completed"), part("completed"), { type: "session.idle", properties: { sessionID: "runtime" } }];
|
|
const runtime = {
|
|
subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { for (const event of events) yield event; } }),
|
|
prompt: async () => undefined, messages: async () => []
|
|
} as unknown as OpencodeRuntimeAdapter;
|
|
const published: unknown[] = [];
|
|
await streamPromptResponse({ runtime, sessionId: "runtime", clientSessionId: "session-1", message: "investigate", write(event, data) { if (event === "execution_plan") published.push(data); } });
|
|
expect(published).toHaveLength(1);
|
|
expect(published[0]).toMatchObject({ session_id: "session-1", server_published: true, nodes: plan.nodes });
|
|
});
|
|
|
|
it("falls back to route publication for a completed legacy local-only plan tool", async () => {
|
|
const completed = { type: "message.part.updated", properties: { sessionID: "runtime", part: {
|
|
id: "plan-call", messageID: "assistant", sessionID: "runtime", type: "tool", tool: "publish_execution_plan",
|
|
callID: "call-1", state: { status: "completed", input: plan, output: JSON.stringify(plan), title: "plan", time: { start: 1, end: 2 } }
|
|
} } };
|
|
const runtime = {
|
|
subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { yield completed; yield { type: "session.idle", properties: { sessionID: "runtime" } }; } }),
|
|
prompt: async () => undefined, messages: async () => []
|
|
} as unknown as OpencodeRuntimeAdapter;
|
|
const published: Record<string, unknown>[] = [];
|
|
|
|
await streamPromptResponse({ runtime, sessionId: "runtime", clientSessionId: "session-1", message: "investigate", write(event, data) { if (event === "execution_plan") published.push(data); } });
|
|
|
|
expect(published).toHaveLength(1);
|
|
expect(published[0]?.server_published).toBeUndefined();
|
|
expect(executionPlanNeedsTaskPublish(published[0])).toBe(true);
|
|
});
|
|
|
|
it("does not publish a failed tool plan or duplicate an already persisted plan", async () => {
|
|
const failed = { type: "message.part.updated", properties: { sessionID: "runtime", part: {
|
|
id: "plan-call", messageID: "assistant", sessionID: "runtime", type: "tool", tool: "publish_execution_plan",
|
|
callID: "call-1", state: { status: "error", input: plan, error: "server rejected plan", time: { start: 1, end: 2 } }
|
|
} } };
|
|
const runtime = {
|
|
subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { yield failed; yield { type: "session.idle", properties: { sessionID: "runtime" } }; } }),
|
|
prompt: async () => undefined, messages: async () => []
|
|
} as unknown as OpencodeRuntimeAdapter;
|
|
const published: unknown[] = [];
|
|
|
|
await streamPromptResponse({ runtime, sessionId: "runtime", clientSessionId: "session-1", message: "investigate", write(event, data) { if (event === "execution_plan") published.push(data); } });
|
|
|
|
expect(published).toEqual([]);
|
|
expect(executionPlanNeedsTaskPublish({ server_published: true })).toBe(false);
|
|
expect(executionPlanNeedsTaskPublish(plan)).toBe(true);
|
|
});
|
|
});
|