feat(workbench): integrate operational workflows and map state

This commit is contained in:
2026-09-24 04:34:46 +08:00
parent 1752b9ef84
commit 436d5500f1
76 changed files with 5081 additions and 1937 deletions
@@ -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("外围工单创建完成");
});
});