Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e3ed5098e |
@@ -130,6 +130,48 @@ function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<type
|
||||
return { ...props, rerender };
|
||||
}
|
||||
|
||||
it.each(["failed", "unsupported"] as const)("retries an approved %s action using its current version", async (status) => {
|
||||
let finish!: () => void;
|
||||
const onExecute = vi.fn(() => new Promise<void>((resolve) => { finish = resolve; }));
|
||||
const task: OperationalTask = { ...taskBase,
|
||||
permissions: { ...taskBase.permissions, can_execute_work_orders: true },
|
||||
actions: [{ ...taskBase.actions[0], approval_status: "approved", execution_status: status }]
|
||||
};
|
||||
const props = taskPanel(task, { onExecute });
|
||||
const section = screen.getByRole("region", { name: "当前需处理" });
|
||||
expect(section).toHaveTextContent("待重试");
|
||||
const retry = within(section).getByRole("button", { name: "重试执行" });
|
||||
fireEvent.click(retry);
|
||||
expect(retry).toBeDisabled();
|
||||
fireEvent.click(retry);
|
||||
expect(onExecute).toHaveBeenCalledExactlyOnceWith(task.actions[0].action_id, task.actions[0].version);
|
||||
expect(props.onDecision).not.toHaveBeenCalled();
|
||||
finish();
|
||||
await waitFor(() => expect(retry).toBeEnabled());
|
||||
props.rerender(<AgentExecutionPlanPanel {...props} task={{ ...task,
|
||||
actions: [{ ...task.actions[0], version: 4, execution_status: "submitted" }]
|
||||
}} />);
|
||||
expect(screen.queryByRole("button", { name: "重试执行" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "核对执行回执" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps retry disabled without work-order execution permission", () => {
|
||||
const props = taskPanel({ ...taskBase, actions: [{ ...taskBase.actions[0],
|
||||
approval_status: "approved", execution_status: "unsupported"
|
||||
}] });
|
||||
const retry = screen.getByRole("button", { name: "重试执行" });
|
||||
expect(retry).toBeDisabled();
|
||||
fireEvent.click(retry);
|
||||
expect(props.onExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["pending", "rejected"] as const)("does not retry a %s action", (approval) => {
|
||||
taskPanel({ ...taskBase, permissions: { ...taskBase.permissions, can_execute_work_orders: true },
|
||||
actions: [{ ...taskBase.actions[0], approval_status: approval, execution_status: "failed" }]
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "重试执行" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps fixed question answers in the report and focuses them from a data gap", () => {
|
||||
const task: OperationalTask = { ...taskBase, actions: [], active_run: null, lifecycle_status: "waiting_human",
|
||||
questions: [{ question_request_id: "70000000-0000-4000-8000-000000000001", task_id: taskBase.task_id,
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
OperationalTaskDataSnapshot
|
||||
} from "../api/operational-task-client";
|
||||
import type { TaskExecutionHistoryTarget } from "../api/operational-task-client";
|
||||
import { isRetryableInvestigationAction } from "../utils/investigation-presentation";
|
||||
|
||||
type ExecutionNode = {
|
||||
id: string;
|
||||
@@ -568,6 +569,7 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
|
||||
<div className="mt-3 flex flex-wrap justify-end gap-2">
|
||||
{action.approval_status === "pending" && canDecide ? <><Button variant="outline" size="sm" disabled={submitting !== null} onClick={() => void run("reject")}>拒绝动作</Button>{!command && onDraftUpdate ? <Button variant="outline" size="sm" disabled={submitting !== null || editing} onClick={() => { setDraft(workOrderDraft(action.payload)); setEditing(true); }}>编辑草稿</Button> : null}<Button size="sm" disabled={submitting !== null || editing || savedVersion === action.version || (!command && !!onConfirmDispatch && !canExecute)} onClick={() => void run(!command && onConfirmDispatch ? "confirm" : "approve")}>{submitting === "approve" || submitting === "confirm" ? <Loader2 className="animate-spin" /> : null}{command ? "批准下发命令" : onConfirmDispatch ? "确认并创建预约工单" : "批准创建预约工单"}</Button></> : null}
|
||||
{action.approval_status === "approved" && action.execution_status === "not_submitted" ? <Button size="sm" disabled={!canExecute || submitting !== null} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : null}{command ? "下发命令" : "创建预约工单"}</Button> : null}
|
||||
{isRetryableInvestigationAction(action) ? <Button size="sm" disabled={!canExecute || submitting !== null || stale} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}重试执行</Button> : null}
|
||||
{action.approval_status === "approved" && ["submitted", "unknown"].includes(action.execution_status) ? <Button variant="outline" size="sm" disabled={!canExecute || submitting !== null} onClick={() => void run("reconcile")}>{submitting === "reconcile" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}核对执行回执</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,8 @@ import type {
|
||||
import {
|
||||
investigationDiagram,
|
||||
investigationStatus,
|
||||
isPendingInvestigationAction
|
||||
isPendingInvestigationAction,
|
||||
isRetryableInvestigationAction
|
||||
} from "../utils/investigation-presentation";
|
||||
import { InvestigationDiagramView } from "./investigation-diagram-view";
|
||||
import { InvestigationExpandCard } from "./investigation-expand-card";
|
||||
@@ -433,9 +434,11 @@ export function InvestigationTaskReport({
|
||||
{actionTitle(action)} ·{" "}
|
||||
{action.approval_status === "pending"
|
||||
? "待审核"
|
||||
: action.execution_status === "not_submitted"
|
||||
? "待执行"
|
||||
: "待核对回执"}
|
||||
: isRetryableInvestigationAction(action)
|
||||
? "待重试"
|
||||
: action.execution_status === "not_submitted"
|
||||
? "待执行"
|
||||
: "待核对回执"}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -71,6 +71,7 @@ export function investigationStatus(task: OperationalTask): string {
|
||||
if (task.lifecycle_status === "cancelled") return "本轮已取消";
|
||||
if (task.questions?.some((question) => question.status === "pending" && question.execution_generation === task.execution_generation)) return "等待回答";
|
||||
if (task.actions.some((action) => action.approval_status === "pending")) return "待人工审核";
|
||||
if (task.actions.some(isRetryableInvestigationAction)) return "待重试动作";
|
||||
if (
|
||||
task.actions.some(
|
||||
(action) =>
|
||||
@@ -105,6 +106,11 @@ export function isPendingInvestigationAction(action: OperationalTask["actions"][
|
||||
return (
|
||||
action.approval_status === "pending" ||
|
||||
(action.approval_status === "approved" &&
|
||||
["not_submitted", "submitted", "unknown"].includes(action.execution_status))
|
||||
["not_submitted", "submitted", "unknown"].includes(action.execution_status)) ||
|
||||
isRetryableInvestigationAction(action)
|
||||
);
|
||||
}
|
||||
|
||||
export function isRetryableInvestigationAction(action: OperationalTask["actions"][number]) {
|
||||
return action.approval_status === "approved" && ["failed", "unsupported"].includes(action.execution_status);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
|
||||
test("已批准的派单失败动作可重试,并在提交期间禁止重复点击", async ({ page }, testInfo) => {
|
||||
await mockAgentApi(page);
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.setContent('<!doctype html><html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="reader" style="max-width:680px;margin:auto;padding:16px"></main></body></html>');
|
||||
await page.evaluate(async () => {
|
||||
const entry = await (await fetch("/src/main.tsx")).text();
|
||||
const reactUrl = entry.match(/from "([^"\n]*\/react\.js[^"\n]*)"/)?.[1];
|
||||
const clientUrl = entry.match(/from "([^"\n]*\/react-dom_client\.js[^"\n]*)"/)?.[1];
|
||||
if (!reactUrl || !clientUrl) throw new Error("Vite React module paths unavailable");
|
||||
const reactModule = await import(reactUrl);
|
||||
const React = reactModule.default ?? reactModule;
|
||||
const clientModule = await import(clientUrl);
|
||||
const { createRoot } = clientModule.default ?? clientModule;
|
||||
const { AgentExecutionPlanPanel } = await import("/src/features/workbench/components/agent-execution-plan-panel.tsx");
|
||||
const now = "2026-11-07T13:36:15Z";
|
||||
let task = {
|
||||
task_id: "task-retry", task_type: "event_investigation", title: "SCADA 异常调查",
|
||||
summary: "候选管段515870待现场核验", execution_generation: 1,
|
||||
lifecycle_status: "waiting_human", status: "waiting_human", updated_at: now,
|
||||
latest_report: { revision: 2, execution_generation: 1, published_at: now, report: {
|
||||
title: "现场核验工单派发状态", stage: "final", outcome: "concluded",
|
||||
current_conclusion: "工单已批准,执行适配器尚未配置,现场核验未派发。",
|
||||
conclusion_boundary: "事件未闭环。", recommendations: [], pending_items: [],
|
||||
data_gaps: [], evidence_refs: [], diagram: null
|
||||
} },
|
||||
latest_progress: null, latest_plan: null, active_run: null, evidence: [], access_requests: [],
|
||||
permissions: { can_execute_work_orders: true },
|
||||
actions: [{ action_id: "action-retry", task_id: "task-retry", version: 4,
|
||||
kind: "create_work_order", reason: "候选管段515870需现场核验", proposed_at: now,
|
||||
decided_at: now, executed_at: now, approval_status: "approved", execution_status: "unsupported",
|
||||
execution_detail: "尚未配置该动作的外部执行适配器", evidence_refs: [], receipt: null,
|
||||
payload: { title: "候选管段515870现场漏水核查", description: "核查渗漏、阀门状态并回填照片和压力复测。",
|
||||
work_order_type: "general", priority: "normal" } }]
|
||||
};
|
||||
const calls: { actionId: string; version: number }[] = [];
|
||||
let finish: () => void;
|
||||
const root = createRoot(document.getElementById("reader"));
|
||||
const render = () => root.render(React.createElement(AgentExecutionPlanPanel, {
|
||||
task, onContinue: () => undefined, onDecision: async () => undefined,
|
||||
onReconcile: async () => undefined, onRefresh: () => undefined,
|
||||
onLoadEvidenceSnapshot: async () => undefined,
|
||||
onExecute: async (actionId: string, version: number) => {
|
||||
calls.push({ actionId, version });
|
||||
await new Promise<void>((resolve) => { finish = resolve; });
|
||||
task = { ...task, actions: [{ ...task.actions[0], version: 5,
|
||||
execution_status: "submitted", execution_detail: "工单已提交,等待现场核验回执" }] };
|
||||
render();
|
||||
}
|
||||
}));
|
||||
Object.assign(window, { retryCalls: calls, finishRetry: () => finish() });
|
||||
render();
|
||||
});
|
||||
const section = page.getByRole("region", { name: "当前需处理", exact: true });
|
||||
await expect(section).toContainText("待重试");
|
||||
await section.locator("summary").filter({ hasText: "待重试" }).click();
|
||||
const retry = section.getByRole("button", { name: "重试执行", exact: true });
|
||||
await expect(retry).toBeEnabled();
|
||||
await retry.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: testInfo.outputPath("retry-desktop.png") });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await retry.scrollIntoViewIfNeeded();
|
||||
await expect(retry).toBeInViewport();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await page.screenshot({ path: testInfo.outputPath("retry-mobile.png") });
|
||||
await retry.click();
|
||||
await expect(retry).toBeDisabled();
|
||||
expect(await page.evaluate(() => (window as unknown as { retryCalls: unknown[] }).retryCalls)).toEqual([
|
||||
{ actionId: "action-retry", version: 4 }
|
||||
]);
|
||||
await page.evaluate(() => (window as unknown as { finishRetry: () => void }).finishRetry());
|
||||
await expect(retry).toHaveCount(0);
|
||||
await expect(section.getByRole("button", { name: "核对执行回执" })).toBeVisible();
|
||||
await expect(section).toContainText("工单已提交,等待现场核验回执");
|
||||
});
|
||||
Reference in New Issue
Block a user