fix: preserve investigation execution history by round

This commit is contained in:
2026-10-30 17:07:53 +08:00
parent 1ee21d1c7f
commit 185bf31446
16 changed files with 830 additions and 32 deletions
@@ -149,6 +149,56 @@ it("references the selected report round instead of the latest task round, inclu
});
});
it("opens the investigation execution record separately from Agent collaboration", async () => {
const onViewExecutionHistory = vi.fn(async () => undefined);
taskPanel(taskBase, { onViewExecutionHistory });
const executionHistory = screen.getByRole("button", { name: "执行记录" });
expect(executionHistory).toHaveAttribute("title", "在左侧 Agent 中打开所选轮次自动同步的调查执行记录,并可创建调试会话。");
fireEvent.click(executionHistory);
await waitFor(() => expect(onViewExecutionHistory).toHaveBeenCalledWith({
executionGeneration: 1,
sessionId: "execution-session"
}));
});
it("opens the execution record bound to the selected report round", async () => {
const currentReport = {
...taskBase.latest_report!,
revision: 2,
execution_generation: 2,
source_session_id: "execution-session-2"
};
const previousReport = {
...taskBase.latest_report!,
revision: 1,
execution_generation: 1,
source_session_id: "execution-session-1"
};
const onViewExecutionHistory = vi.fn(async () => undefined);
taskPanel({
...taskBase,
execution_generation: 2,
current_report_revision: 2,
latest_report: currentReport
}, {
onViewExecutionHistory,
onLoadReports: vi.fn(async () => ({ items: [currentReport, previousReport], next_offset: null }))
});
fireEvent.click(screen.getByRole("button", { name: "展开报告版本" }));
fireEvent.click(await screen.findByRole("button", { name: /第 1 轮 · 报告第 1 版/ }));
expect(screen.getByText("调查任务 · 第 1 轮", { exact: true })).toBeVisible();
expect(screen.queryByText("调查任务 · 第 2 轮", { exact: true })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "执行记录", hidden: true }));
await waitFor(() => expect(onViewExecutionHistory).toHaveBeenCalledWith({
executionGeneration: 1,
sessionId: "execution-session-1"
}));
});
describe("operational task approval and evidence", () => {
it("shows the selected v2 route first and keeps unchosen work available", () => {
const node = (id: string, title: string, status: "completed" | "waiting" | "skipped") => ({
@@ -31,6 +31,7 @@ import type {
OperationalTaskPlan,
OperationalTaskDataSnapshot
} from "../api/operational-task-client";
import type { TaskExecutionHistoryTarget } from "../api/operational-task-client";
type ExecutionNode = {
id: string;
@@ -65,6 +66,7 @@ type AgentExecutionPlanPanelProps = {
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => void;
task: OperationalTask;
onContinue: (discussion?: InvestigationDiscussion) => void;
onViewExecutionHistory?: (target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void> | void;
onDecision: (actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onExecute: (actionId: string, expectedVersion: number) => Promise<unknown>;
onDraftUpdate?: (actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
@@ -82,6 +84,7 @@ export function AgentExecutionPlanPanel({
onLocate,
task,
onContinue,
onViewExecutionHistory,
onDecision,
onExecute,
onDraftUpdate,
@@ -95,7 +98,8 @@ export function AgentExecutionPlanPanel({
focusActionId
}: AgentExecutionPlanPanelProps) {
if (task.task_type === "event_investigation") return <InvestigationTaskReport key={task.task_id} task={task}
onContinue={onContinue} onLoadReports={onLoadReports} onReportViewed={onReportViewed} focusActionId={focusActionId} onLocate={onLocate}
onContinue={onContinue} onViewExecutionHistory={onViewExecutionHistory}
onLoadReports={onLoadReports} onReportViewed={onReportViewed} focusActionId={focusActionId} onLocate={onLocate}
renderAction={(action) => <HumanActionCard key={action.action_id} task={task} action={action} onDecision={onDecision}
onExecute={onExecute} onDraftUpdate={onDraftUpdate} onConfirmDispatch={onConfirmDispatch} onReconcile={onReconcile} onRefresh={onRefresh} />}
renderEvidence={(item) => <EvidenceDisclosure key={item.evidence_id} item={item} onLoadSnapshot={onLoadEvidenceSnapshot} />}
@@ -1,5 +1,5 @@
import { useEffect, useState, type ReactNode } from "react";
import { MessageSquare } from "lucide-react";
import { History, LoaderCircle, MessageSquare } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure";
import { StatusBadge } from "@/shared/ui/status";
@@ -11,7 +11,8 @@ import type {
OperationalTaskAction,
OperationalTaskEvidence,
OperationalTaskReport,
OperationalTaskReportPage
OperationalTaskReportPage,
TaskExecutionHistoryTarget
} from "../api/operational-task-client";
import {
investigationDiagram,
@@ -24,11 +25,13 @@ import { useReportUpdateMotion } from "../hooks/use-report-update-motion";
import { ExpandableReading } from "@/shared/ui/reading-workspace";
import { InvestigationReportBlocks } from "./investigation-report-blocks";
import { InvestigationReportNavigation, type InvestigationReportSection } from "./investigation-report-navigation";
import { showMapNotice } from "@/features/map/core";
type ReportSelection = { snapshot: OperationalTaskReport; diagram: InvestigationDiagram | null };
type Props = {
task: OperationalTask;
onContinue: (discussion?: InvestigationDiscussion) => Promise<boolean | void> | boolean | void;
onViewExecutionHistory?: (target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void> | void;
renderAction: (action: OperationalTaskAction) => ReactNode;
renderEvidence: (evidence: OperationalTaskEvidence) => ReactNode;
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
@@ -126,6 +129,7 @@ function investigationTimeline(
export function InvestigationTaskReport({
task,
onContinue,
onViewExecutionHistory,
renderAction,
renderEvidence,
onLoadReports,
@@ -139,8 +143,24 @@ export function InvestigationTaskReport({
const [historyOffset, setHistoryOffset] = useState<number | null>(0);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const [openingExecutionHistory, setOpeningExecutionHistory] = useState(false);
const live = selection === null;
const snapshot = selection?.snapshot ?? task.latest_report;
const displayedGeneration = live ? task.execution_generation : snapshot?.execution_generation;
const fallbackExecutionRun = task.latest_run ?? task.active_run;
const executionHistoryTarget: TaskExecutionHistoryTarget | null = snapshot ? {
executionGeneration: snapshot.execution_generation ?? null,
sessionId: snapshot.source_session_id
} : live && fallbackExecutionRun?.agent_session_id ? {
executionGeneration: fallbackExecutionRun.generation,
sessionId: fallbackExecutionRun.agent_session_id
} : null;
const knownExecutionRun = executionHistoryTarget
? [task.active_run, task.latest_run].find((run) => run
&& (executionHistoryTarget.executionGeneration == null
|| run.generation === executionHistoryTarget.executionGeneration)
&& run.agent_session_id === executionHistoryTarget.sessionId) ?? null
: null;
const report = snapshot?.report;
const progress = task.latest_progress?.execution_generation === task.execution_generation ? task.latest_progress : null;
// A current final report owns its archived graph. During investigation, a
@@ -221,6 +241,25 @@ export function InvestigationTaskReport({
setHistoryLoading(false);
}
};
const openExecutionHistory = async () => {
if (!onViewExecutionHistory || !executionHistoryTarget || openingExecutionHistory) return;
setOpeningExecutionHistory(true);
try {
if (knownExecutionRun) {
await onViewExecutionHistory(executionHistoryTarget, knownExecutionRun.run_id);
} else {
await onViewExecutionHistory(executionHistoryTarget);
}
} catch (error) {
showMapNotice({
tone: "error",
title: "调查执行记录打开失败",
message: error instanceof Error ? error.message : "请稍后重试。"
});
} finally {
setOpeningExecutionHistory(false);
}
};
return (
<ExpandableReading title={snapshot?.report.title ?? task.title}
@@ -229,7 +268,7 @@ export function InvestigationTaskReport({
<header className="investigation-report-header">
<div className="min-w-0">
<p className="text-xs text-[var(--ink-muted)]">
调查任务 · 第 {task.execution_generation} 轮
{displayedGeneration == null ? "调查任务 · 历史轮次" : `调查任务 · 第 ${displayedGeneration} 轮`}
</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold leading-6 text-[var(--ink)]">{task.title}</h2>
@@ -247,17 +286,31 @@ export function InvestigationTaskReport({
</div>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{task.summary}</p>
</div>
<Button
variant="outline"
size="sm"
title="打开调查协作对话,引用此任务,不会直接发送。"
onClick={() =>
onContinue({ context: snapshot ? { report_revision: snapshot.revision } : {}, reportGeneration: snapshot?.execution_generation })
}
>
<MessageSquare size={14} aria-hidden />
Agent 协作
</Button>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={!onViewExecutionHistory || !executionHistoryTarget || openingExecutionHistory}
title={onViewExecutionHistory && executionHistoryTarget ? "在左侧 Agent 中打开所选轮次自动同步的调查执行记录,并可创建调试会话。" : "所选轮次还没有可查看的执行记录。"}
onClick={() => void openExecutionHistory()}
>
{openingExecutionHistory
? <LoaderCircle size={14} className="animate-spin" aria-hidden />
: <History size={14} aria-hidden />}
执行记录
</Button>
<Button
variant="outline"
size="sm"
title="打开调查协作对话,引用此任务,不会直接发送。"
onClick={() =>
onContinue({ context: snapshot ? { report_revision: snapshot.revision } : {}, reportGeneration: snapshot?.execution_generation })
}
>
<MessageSquare size={14} aria-hidden />
Agent 协作
</Button>
</div>
</header>
<InvestigationReportNavigation reportRef={reportRef} sections={sections} />
@@ -19,7 +19,7 @@ import {
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { AgentExecutionPlanPanel } from "./agent-execution-plan-panel";
import type { OperationalTaskDecision } from "../api/operational-task-client";
import type { OperationalTaskDecision, TaskExecutionHistoryTarget } from "../api/operational-task-client";
import type {
ExternalWorkOrderSnapshot,
OperationalTaskDataSnapshot
@@ -114,6 +114,7 @@ type ConditionDetailPanelProps = {
dataSource?: WorkbenchRuntimeDataSource;
workOrderDataSource?: WorkbenchWorkOrderDataSource;
onContinueConversation: (discussion?: import("../api/investigation-report").InvestigationDiscussion) => Promise<boolean | void> | boolean | void;
onViewTaskExecutionHistory?: (taskId: string, target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void> | void;
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
@@ -168,6 +169,7 @@ export function ConditionDetailPanel({
dataSource = "mock",
workOrderDataSource = "mock",
onContinueConversation,
onViewTaskExecutionHistory,
onTaskDecision,
onTaskExecute,
onTaskDraftUpdate,
@@ -196,6 +198,7 @@ export function ConditionDetailPanel({
dataSource={dataSource}
workOrderDataSource={workOrderDataSource}
onContinueConversation={onContinueConversation}
onViewTaskExecutionHistory={onViewTaskExecutionHistory}
onTaskDecision={onTaskDecision}
onTaskExecute={onTaskExecute}
onTaskDraftUpdate={onTaskDraftUpdate}
@@ -232,6 +235,7 @@ function ConditionRecordDetailPanel({
dataSource,
workOrderDataSource,
onContinueConversation,
onViewTaskExecutionHistory,
onTaskDecision,
onTaskExecute,
onTaskDraftUpdate,
@@ -249,6 +253,7 @@ function ConditionRecordDetailPanel({
dataSource: WorkbenchRuntimeDataSource;
workOrderDataSource: WorkbenchWorkOrderDataSource;
onContinueConversation: ConditionDetailPanelProps["onContinueConversation"];
onViewTaskExecutionHistory?: ConditionDetailPanelProps["onViewTaskExecutionHistory"];
onTaskDecision?: ConditionDetailPanelProps["onTaskDecision"];
onTaskExecute?: ConditionDetailPanelProps["onTaskExecute"];
onTaskDraftUpdate?: ConditionDetailPanelProps["onTaskDraftUpdate"];
@@ -321,6 +326,13 @@ function ConditionRecordDetailPanel({
onReportViewed={onReportViewed ? (generation, revision) => onReportViewed(condition.operationalTask!.task_id, generation, revision) : undefined}
focusActionId={focusActionId}
onContinue={onContinueConversation}
onViewExecutionHistory={onViewTaskExecutionHistory
? (target, knownRunId) => onViewTaskExecutionHistory(
condition.operationalTask!.task_id,
target,
knownRunId
)
: undefined}
onDecision={(actionId, version, decision, comment) => {
if (!onTaskDecision) return Promise.reject(new Error("当前未配置人工确认接口。"));
return onTaskDecision(condition.operationalTask!.task_id, actionId, version, decision, comment);
@@ -17,6 +17,7 @@ import type {
WorkbenchRuntimeDataSource,
WorkbenchWorkOrderDataSource
} from "../types";
import type { TaskExecutionHistoryTarget } from "../api/operational-task-client";
import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts";
import type { OperationalTaskDecision } from "../api/operational-task-client";
import type { InvestigationDiscussion } from "../api/investigation-report";
@@ -85,6 +86,7 @@ type ScheduledConditionFeedProps = {
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
onSubmitPrompt: (prompt: string) => void | Promise<void>;
onOpenTaskCollaboration?: (taskId: string, discussion?: InvestigationDiscussion) => Promise<void>;
onViewTaskExecutionHistory?: (taskId: string, target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void>;
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
@@ -125,6 +127,7 @@ export function ScheduledConditionFeed({
onLoadHistorySession,
onSubmitPrompt,
onOpenTaskCollaboration,
onViewTaskExecutionHistory,
onTaskDecision,
onTaskExecute,
onTaskDraftUpdate,
@@ -554,6 +557,7 @@ export function ScheduledConditionFeed({
dataSource={dataSource}
workOrderDataSource={workOrderDataSource}
onContinueConversation={(discussion) => handleContinueConversation(selectedCondition, discussion)}
onViewTaskExecutionHistory={onViewTaskExecutionHistory}
onTaskDecision={onTaskDecision}
onTaskExecute={onTaskExecute}
onTaskDraftUpdate={onTaskDraftUpdate}