From 4e3ed5098e5d327feb6c2e896a9cbb451c2add9c Mon Sep 17 00:00:00 2001 From: Huarch Date: Sat, 7 Nov 2026 22:20:04 +0800 Subject: [PATCH] fix(workbench): retry approved task actions after dispatch failure --- .../agent-execution-plan-panel.test.tsx | 42 ++++++++++ .../components/agent-execution-plan-panel.tsx | 2 + .../components/investigation-task-report.tsx | 11 ++- .../utils/investigation-presentation.ts | 8 +- .../browser/investigation-action-retry.e2e.ts | 78 +++++++++++++++++++ 5 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 tests/browser/investigation-action-retry.e2e.ts diff --git a/src/features/workbench/components/agent-execution-plan-panel.test.tsx b/src/features/workbench/components/agent-execution-plan-panel.test.tsx index 96884da..41772ea 100644 --- a/src/features/workbench/components/agent-execution-plan-panel.test.tsx +++ b/src/features/workbench/components/agent-execution-plan-panel.test.tsx @@ -130,6 +130,48 @@ function taskPanel(task: OperationalTask, overrides: Partial { + let finish!: () => void; + const onExecute = vi.fn(() => new Promise((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(); + 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, diff --git a/src/features/workbench/components/agent-execution-plan-panel.tsx b/src/features/workbench/components/agent-execution-plan-panel.tsx index f3a95c8..93285c2 100644 --- a/src/features/workbench/components/agent-execution-plan-panel.tsx +++ b/src/features/workbench/components/agent-execution-plan-panel.tsx @@ -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
{action.approval_status === "pending" && canDecide ? <>{!command && onDraftUpdate ? : null} : null} {action.approval_status === "approved" && action.execution_status === "not_submitted" ? : null} + {isRetryableInvestigationAction(action) ? : null} {action.approval_status === "approved" && ["submitted", "unknown"].includes(action.execution_status) ? : null}
diff --git a/src/features/workbench/components/investigation-task-report.tsx b/src/features/workbench/components/investigation-task-report.tsx index c14e3e2..814b10c 100644 --- a/src/features/workbench/components/investigation-task-report.tsx +++ b/src/features/workbench/components/investigation-task-report.tsx @@ -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" + ? "待执行" + : "待核对回执"} } > diff --git a/src/features/workbench/utils/investigation-presentation.ts b/src/features/workbench/utils/investigation-presentation.ts index ad4af2b..293808b 100644 --- a/src/features/workbench/utils/investigation-presentation.ts +++ b/src/features/workbench/utils/investigation-presentation.ts @@ -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); +} diff --git a/tests/browser/investigation-action-retry.e2e.ts b/tests/browser/investigation-action-retry.e2e.ts new file mode 100644 index 0000000..91198e5 --- /dev/null +++ b/tests/browser/investigation-action-retry.e2e.ts @@ -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('
'); + 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((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("工单已提交,等待现场核验回执"); +});