feat(workbench): integrate operational workflows and map state
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { type AgentExecutionPlan } from "@/features/agent";
|
||||
import type { OperationalTask } from "../api/operational-task-client";
|
||||
import { AgentExecutionPlanPanel, ExecutionPlanSteps } from "./agent-execution-plan-panel";
|
||||
|
||||
describe("execution step cards", () => {
|
||||
it("shows parallel cards, exact join dependencies and escaped model content", () => {
|
||||
const plan: AgentExecutionPlan = {
|
||||
schema_version: "execution-plan@1", session_id: "s1", revision: 1,
|
||||
updated_at: "2026-09-23T01:00:00.000Z", title: "调查", reason: "核验",
|
||||
nodes: ["接入", "上游", "邻区", "汇合"].map((title, index) => ({
|
||||
id: String(index), title, status: index === 3 ? "waiting" : "completed",
|
||||
owner: index === 3 ? "human" : "agent", detail: '<script>alert("test")</script>', evidence: []
|
||||
})),
|
||||
edges: [{ from: "0", to: "1", label: "" }, { from: "0", to: "2", label: "" }, { from: "1", to: "3", label: "上游正常" }, { from: "2", to: "3", label: "投诉集中" }]
|
||||
};
|
||||
const { container } = render(<ExecutionPlanSteps plan={plan} />);
|
||||
expect(screen.getByText("并行处理")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("并行分枝 2 项")).toHaveAttribute("data-execution-layout", "branch");
|
||||
expect(screen.getByText("2 条独立分枝")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(4);
|
||||
expect(container.querySelector("script")).toBeNull();
|
||||
expect(screen.queryByText(/前置:/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("分枝 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("分枝 2")).toBeInTheDocument();
|
||||
expect(screen.queryByText("每条分枝按自身依赖推进,不要求同层节点同时完成。")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("待人工/资料")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a four-way fork and a later two-way fork without inventing cross-branch edges", () => {
|
||||
const node = (id: string, title: string, status: AgentExecutionPlan["nodes"][number]["status"] = "completed") => ({
|
||||
id, title, status, owner: "agent" as const, detail: `${title}详情`, evidence: []
|
||||
});
|
||||
const plan: AgentExecutionPlan = {
|
||||
schema_version: "execution-plan@1", session_id: "s-nested", revision: 1,
|
||||
updated_at: "2026-09-23T01:00:00.000Z", title: "多轮调查", reason: "按真实依赖推进",
|
||||
nodes: [
|
||||
node("start", "接收事件"),
|
||||
node("a1", "上游核查"), node("a2", "上游复核"),
|
||||
node("b1", "邻区投诉"),
|
||||
node("c1", "压力比对"), node("c2", "水力校核"), node("c3", "阀门排查"),
|
||||
node("d1", "运维记录"),
|
||||
node("join1", "阶段判断"),
|
||||
node("e1", "二次供水核查", "running"), node("e2", "水箱记录", "waiting"),
|
||||
node("f1", "现场采样", "waiting"),
|
||||
node("join2", "形成结论", "pending")
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "a1", label: "" }, { from: "a1", to: "a2", label: "" }, { from: "a2", to: "join1", label: "" },
|
||||
{ from: "start", to: "b1", label: "" }, { from: "b1", to: "join1", label: "" },
|
||||
{ from: "start", to: "c1", label: "" }, { from: "c1", to: "c2", label: "" }, { from: "c2", to: "c3", label: "" }, { from: "c3", to: "join1", label: "" },
|
||||
{ from: "start", to: "d1", label: "" }, { from: "d1", to: "join1", label: "" },
|
||||
{ from: "join1", to: "e1", label: "" }, { from: "e1", to: "e2", label: "" }, { from: "e2", to: "join2", label: "" },
|
||||
{ from: "join1", to: "f1", label: "" }, { from: "f1", to: "join2", label: "" }
|
||||
]
|
||||
};
|
||||
|
||||
const { container } = render(<ExecutionPlanSteps plan={plan} />);
|
||||
expect(screen.getByLabelText("并行分枝 4 项")).toBeVisible();
|
||||
expect(screen.getByLabelText("并行分枝 2 项")).toBeVisible();
|
||||
expect(screen.getAllByText("分枝汇合")).toHaveLength(2);
|
||||
expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(plan.nodes.length);
|
||||
expect(screen.queryByText(/前置工作与条件|前置:/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
const taskBase = {
|
||||
project_id: "11111111-1111-4111-8111-111111111111",
|
||||
task_id: "22222222-2222-4222-8222-222222222222",
|
||||
task_type: "event_investigation",
|
||||
workflow: "water_quality_complaint",
|
||||
lifecycle_status: "waiting_human",
|
||||
active_run: null,
|
||||
status: "waiting_human",
|
||||
priority: "normal",
|
||||
revision: 2,
|
||||
execution_generation: 1,
|
||||
execution_request_id: "33333333-3333-4333-8333-333333333333",
|
||||
title: "水质投诉调查",
|
||||
summary: "居民反映异味",
|
||||
source: { kind: "operator", external_id: null, external_revision: null, event_kind: "water_quality_complaint" },
|
||||
trigger: { kind: "manual", occurred_at: "2026-09-23T01:00:00Z", detail: "演示小区" },
|
||||
scheduled_for: null,
|
||||
agent_session_id: "execution-session",
|
||||
agent_lease_expires_at: null,
|
||||
current_plan_revision: null,
|
||||
current_report_revision: 1,
|
||||
created_at: "2026-09-23T01:00:00Z",
|
||||
updated_at: "2026-09-23T01:10:00Z",
|
||||
latest_plan: null,
|
||||
latest_report: {
|
||||
revision: 1, execution_generation: 1, source_session_id: "execution-session", published_at: "2026-09-23T01:09:00Z", published_by: "agent",
|
||||
report: { title: "调查报告", current_conclusion: "上游指标正常,待核查二次供水。", conclusion_boundary: "等待现场采样。", evidence_refs: ["e-1"], recommendations: ["安排现场采样"], pending_items: ["确认水箱清洗记录"] }
|
||||
},
|
||||
evidence: [{ evidence_id: "e-1", kind: "system_data", summary: "上游余氯处于正常范围", source_label: "SCADA 水质监测", source_ref: "WQ-01", observed_at: "2026-09-23T00:55:00Z", recorded_at: "2026-09-23T01:08:00Z", recorded_by: "agent", has_snapshot: false }],
|
||||
actions: [{ action_id: "44444444-4444-4444-8444-444444444444", task_id: "22222222-2222-4222-8222-222222222222", version: 3, kind: "create_work_order", node_id: null, reason: "需要现场采样", payload: { title: "现场采样", location: "演示小区", scheduled_at: "2026-09-23T02:00:00Z" }, evidence_refs: ["e-1"], required_approval_permission: "operational_action.work_order.approve", required_execution_permission: "operational_action.work_order.execute", approval_status: "pending", execution_status: "not_submitted", execution_request_id: null, proposed_by: "agent", proposed_at: "2026-09-23T01:09:00Z", decided_by: null, decided_at: null, decision_comment: null, executed_by: null, executed_at: null, receipt: null, execution_detail: null }],
|
||||
permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false, can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false, can_execute_commands: false },
|
||||
work_order_snapshot: null
|
||||
} as OperationalTask;
|
||||
|
||||
function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<typeof AgentExecutionPlanPanel>> = {}) {
|
||||
const props = {
|
||||
task,
|
||||
onContinue: vi.fn(),
|
||||
onDecision: vi.fn(async () => undefined),
|
||||
onExecute: vi.fn(async () => undefined),
|
||||
onReconcile: vi.fn(async () => undefined),
|
||||
onRefresh: vi.fn(),
|
||||
onLoadEvidenceSnapshot: vi.fn(async () => { throw new Error("no snapshot"); }),
|
||||
...overrides
|
||||
};
|
||||
render(<AgentExecutionPlanPanel {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe("operational task approval and evidence", () => {
|
||||
it("distinguishes the current report from a previous execution without exposing generation numbers", () => {
|
||||
taskPanel(taskBase);
|
||||
expect(screen.getByText("当前结论", { exact: true })).toBeVisible();
|
||||
|
||||
const previousReport = {
|
||||
...taskBase,
|
||||
status: "running" as const,
|
||||
execution_generation: 2,
|
||||
latest_report: { ...taskBase.latest_report!, execution_generation: 1 }
|
||||
};
|
||||
const { rerender } = render(<AgentExecutionPlanPanel
|
||||
task={previousReport}
|
||||
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
|
||||
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
|
||||
/>);
|
||||
expect(screen.getByText("上一轮报告,本轮分析中", { exact: true })).toBeVisible();
|
||||
expect(screen.queryByText(/第\s*[12]\s*代|generation/i)).not.toBeInTheDocument();
|
||||
|
||||
rerender(<AgentExecutionPlanPanel
|
||||
task={{ ...previousReport, status: "failed" }}
|
||||
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
|
||||
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
|
||||
/>);
|
||||
expect(screen.getByText("上一轮报告,当前任务失败", { exact: true })).toBeVisible();
|
||||
expect(screen.queryByText("上一轮报告,本轮分析中", { exact: true })).not.toBeInTheDocument();
|
||||
|
||||
rerender(<AgentExecutionPlanPanel
|
||||
task={{ ...previousReport, status: "cancelled" }}
|
||||
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
|
||||
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
|
||||
/>);
|
||||
expect(screen.getByText("上一轮报告,当前任务已取消", { exact: true })).toBeVisible();
|
||||
|
||||
rerender(<AgentExecutionPlanPanel
|
||||
task={{ ...previousReport, latest_report: { ...previousReport.latest_report!, execution_generation: null } }}
|
||||
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
|
||||
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
|
||||
/>);
|
||||
expect(screen.getByText("历史报告", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
it("labels current, previous and legacy execution plans without presenting old progress as current", () => {
|
||||
const latestPlan: NonNullable<OperationalTask["latest_plan"]> = {
|
||||
revision: 1,
|
||||
execution_generation: 1,
|
||||
source_session_id: "execution-session",
|
||||
published_at: "2026-09-23T01:05:00Z",
|
||||
published_by: "agent",
|
||||
plan: {
|
||||
schema: "execution-plan@1",
|
||||
title: "调查流程",
|
||||
reason: "核对真实状态",
|
||||
nodes: [{
|
||||
id: "investigate",
|
||||
title: "核查事件",
|
||||
owner: "agent",
|
||||
status: "completed",
|
||||
detail: "上一轮已完成。",
|
||||
evidence_refs: [],
|
||||
action_refs: []
|
||||
}],
|
||||
edges: []
|
||||
}
|
||||
};
|
||||
const currentPlanTask = { ...taskBase, latest_plan: latestPlan };
|
||||
const props = {
|
||||
onContinue: vi.fn(), onDecision: vi.fn(), onExecute: vi.fn(), onReconcile: vi.fn(),
|
||||
onRefresh: vi.fn(), onLoadEvidenceSnapshot: vi.fn()
|
||||
};
|
||||
const { rerender } = render(<AgentExecutionPlanPanel task={currentPlanTask} {...props} />);
|
||||
expect(screen.getByText("已完成 1 项 · 执行中 0 项 · 待人工或资料 0 项", { exact: true })).toBeVisible();
|
||||
|
||||
rerender(<AgentExecutionPlanPanel
|
||||
task={{ ...currentPlanTask, status: "running", execution_generation: 2 }}
|
||||
{...props}
|
||||
/>);
|
||||
expect(screen.getByText("上一轮流程,本轮规划中", { exact: true })).toBeVisible();
|
||||
expect(screen.queryByText(/已完成 1 项/)).not.toBeInTheDocument();
|
||||
|
||||
rerender(<AgentExecutionPlanPanel
|
||||
task={{ ...currentPlanTask, status: "failed", execution_generation: 2 }}
|
||||
{...props}
|
||||
/>);
|
||||
expect(screen.getByText("上一轮流程,当前任务失败", { exact: true })).toBeVisible();
|
||||
|
||||
rerender(<AgentExecutionPlanPanel
|
||||
task={{ ...currentPlanTask, latest_plan: { ...latestPlan, execution_generation: null } }}
|
||||
{...props}
|
||||
/>);
|
||||
expect(screen.getByText("历史流程", { exact: true })).toBeVisible();
|
||||
expect(screen.queryByText(/generation|第\s*\d+\s*代/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows exact payload and evidence metadata to a viewer without action controls", () => {
|
||||
taskPanel(taskBase);
|
||||
expect(screen.getByText("工单标题")).toBeVisible();
|
||||
expect(screen.getByText("位置")).toBeVisible();
|
||||
expect(screen.getByText("预约时间")).toBeVisible();
|
||||
expect(screen.getByText("现场采样")).toBeVisible();
|
||||
expect(screen.getByText("演示小区")).toBeVisible();
|
||||
expect(screen.getByText("确认依据")).toBeVisible();
|
||||
expect(screen.getByText(/上游余氯处于正常范围 · SCADA 水质监测/)).toBeVisible();
|
||||
expect(screen.getByText(/当前账号仅可查看/)).toBeVisible();
|
||||
expect(screen.queryByText(/operational_action\.work_order\.approve/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "批准创建工单" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("上游余氯处于正常范围"));
|
||||
expect(screen.getByText("SCADA 水质监测")).toBeVisible();
|
||||
expect(screen.getByText("系统数据")).toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps pending state after a decision network error", async () => {
|
||||
const onDecision = vi.fn(async () => { throw new Error("network unavailable"); });
|
||||
taskPanel({ ...taskBase, permissions: { ...taskBase.permissions, can_decide_work_orders: true } }, { onDecision });
|
||||
fireEvent.click(screen.getByRole("button", { name: "批准创建工单" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("当前状态未按成功处理");
|
||||
expect(screen.getByText("待确认")).toBeVisible();
|
||||
});
|
||||
|
||||
it("requires refresh and reconfirmation after a version conflict", async () => {
|
||||
const onRefresh = vi.fn();
|
||||
const onDecision = vi.fn(async () => { throw new Error("409 version conflict"); });
|
||||
taskPanel({ ...taskBase, permissions: { ...taskBase.permissions, can_decide_work_orders: true } }, { onDecision, onRefresh });
|
||||
fireEvent.click(screen.getByRole("button", { name: "批准创建工单" }));
|
||||
const refresh = await screen.findByRole("button", { name: "刷新后重新确认" });
|
||||
fireEvent.click(refresh);
|
||||
await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("allows unknown submissions to reconcile and shows a persisted receipt", async () => {
|
||||
const onReconcile = vi.fn(async () => undefined);
|
||||
const unknownAction = {
|
||||
...taskBase.actions[0],
|
||||
approval_status: "approved" as const,
|
||||
execution_status: "unknown" as const,
|
||||
execution_request_id: "55555555-5555-4555-8555-555555555555"
|
||||
};
|
||||
const receiptAction = {
|
||||
...unknownAction,
|
||||
execution_status: "succeeded" as const,
|
||||
receipt: {
|
||||
adapter: "peripheral",
|
||||
receipt_id: "receipt-20260923-01",
|
||||
submitted_at: "2026-09-23T01:10:00Z",
|
||||
completed_at: "2026-09-23T01:11:00Z",
|
||||
external_status: "completed",
|
||||
detail: "外围工单创建完成"
|
||||
}
|
||||
};
|
||||
const { rerender } = render(<AgentExecutionPlanPanel
|
||||
task={{ ...taskBase, actions: [unknownAction], permissions: { ...taskBase.permissions, can_execute_work_orders: true } }}
|
||||
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={onReconcile}
|
||||
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
|
||||
/>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "核对执行回执" }));
|
||||
await waitFor(() => expect(onReconcile).toHaveBeenCalledWith(unknownAction.action_id, unknownAction.version));
|
||||
|
||||
rerender(<AgentExecutionPlanPanel
|
||||
task={{ ...taskBase, actions: [receiptAction], permissions: { ...taskBase.permissions, can_execute_work_orders: true } }}
|
||||
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={onReconcile}
|
||||
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
|
||||
/>);
|
||||
expect(screen.getByRole("region", { name: "执行回执" })).toHaveTextContent("receipt-20260923-01");
|
||||
expect(screen.getByRole("region", { name: "执行回执" })).toHaveTextContent("外围工单创建完成");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user