From 185bf31446a14fca57d543aaf81e4108634be84d Mon Sep 17 00:00:00 2001 From: Huarch Date: Fri, 30 Oct 2026 17:07:53 +0800 Subject: [PATCH] fix: preserve investigation execution history by round --- src/features/agent/api/client.test.ts | 70 +++++ src/features/agent/api/client.ts | 26 ++ .../agent/components/agent-command-panel.tsx | 23 +- .../agent-message-process-content.tsx | 4 +- .../api/operational-task-client.test.ts | 33 +++ .../workbench/api/operational-task-client.ts | 22 ++ .../agent-execution-plan-panel.test.tsx | 50 ++++ .../components/agent-execution-plan-panel.tsx | 6 +- .../components/investigation-task-report.tsx | 81 +++++- .../scheduled-condition-detail-panel.tsx | 14 +- .../components/scheduled-condition-feed.tsx | 4 + .../workbench/hooks/agent-session-runtime.ts | 1 + .../workbench/hooks/use-workbench-agent.ts | 166 ++++++++++- src/features/workbench/map-workbench-page.tsx | 31 ++- .../investigation-execution-history.e2e.ts | 261 ++++++++++++++++++ tests/browser/support/mock-agent-api.ts | 70 ++++- 16 files changed, 830 insertions(+), 32 deletions(-) create mode 100644 tests/browser/investigation-execution-history.e2e.ts diff --git a/src/features/agent/api/client.test.ts b/src/features/agent/api/client.test.ts index 2ce0cff..62bfbfd 100644 --- a/src/features/agent/api/client.test.ts +++ b/src/features/agent/api/client.test.ts @@ -138,6 +138,76 @@ describe("Agent API client sessions", () => { await expect(createAgentApiClient("http://agent.local").loadSession("missing")).resolves.toBeNull(); }); + it("loads a task execution history through its exact task and run binding", async () => { + const fetchMock = vi.fn(async (..._args: Parameters) => Response.json({ + id: "execution-session", + session_id: "execution-session", + title: "南桥管道爆管调查 · 执行记录", + purpose: "task_execution", + task_id: "50000000-0000-4000-8000-000000000001", + created_at: "2026-10-30T01:00:00Z", + updated_at: "2026-10-30T01:05:00Z", + messages: [{ id: "assistant", role: "assistant", parts: [{ type: "text", text: "调查完成" }] }], + is_streaming: false, + run_status: "completed" + })); + vi.stubGlobal("fetch", fetchMock); + const client = createAgentApiClient("http://agent.local", { + getAccessToken: async () => "token", + getProjectId: async () => "project" + }); + + const loaded = await client.loadTaskExecutionSession( + "50000000-0000-4000-8000-000000000001", + "70000000-0000-4000-8000-000000000001", + "execution-session" + ); + + expect(loaded).toMatchObject({ + sessionId: "execution-session", + title: "南桥管道爆管调查 · 执行记录", + purpose: "task_execution", + taskId: "50000000-0000-4000-8000-000000000001", + runStatus: "completed" + }); + const url = new URL(String(fetchMock.mock.calls[0]?.[0])); + expect(url.pathname).toBe("/api/v1/task/50000000-0000-4000-8000-000000000001/execution-session"); + expect(url.searchParams.get("run_id")).toBe("70000000-0000-4000-8000-000000000001"); + expect(url.searchParams.get("session_id")).toBe("execution-session"); + const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); + expect(headers.get("Authorization")).toBe("Bearer token"); + expect(headers.get("X-Project-Id")).toBe("project"); + }); + + it("creates a debug branch bound to an exact task execution history", async () => { + const fetchMock = vi.fn(async (..._args: Parameters) => Response.json({ + session_id: "debug-session", + title: "南桥管道爆管调查 · 调试", + purpose: "task_collaboration", + task_id: "50000000-0000-4000-8000-000000000001", + parent_session_id: "execution-session", + created: true + }, { status: 201 })); + vi.stubGlobal("fetch", fetchMock); + const client = createAgentApiClient("http://agent.local", { + getAccessToken: async () => "token", + getProjectId: async () => "project" + }); + + await expect(client.createTaskExecutionDebugSession( + "50000000-0000-4000-8000-000000000001", + "70000000-0000-4000-8000-000000000001", + "execution-session" + )).resolves.toMatchObject({ session_id: "debug-session", purpose: "task_collaboration" }); + + const [url, init] = fetchMock.mock.calls[0] ?? []; + expect(new URL(String(url)).pathname).toBe("/api/v1/task/50000000-0000-4000-8000-000000000001/execution-session/debug"); + expect(JSON.parse(String(init?.body))).toEqual({ + run_id: "70000000-0000-4000-8000-000000000001", + session_id: "execution-session" + }); + }); + it("falls back when a candidate is not the agent service", async () => { const fetchMock = vi .fn() diff --git a/src/features/agent/api/client.ts b/src/features/agent/api/client.ts index 7fc5456..38b7503 100644 --- a/src/features/agent/api/client.ts +++ b/src/features/agent/api/client.ts @@ -174,6 +174,8 @@ export type AgentApiClient = InteractionRequestApi & { moveSessionToTopic: (sessionId: string, topicId: string | null) => Promise; loadOlderMessages: (sessionId: string, before: string, signal?: AbortSignal) => Promise; createTaskCollaborationSession: (taskId: string) => Promise; + createTaskExecutionDebugSession: (taskId: string, runId: string, sessionId: string) => Promise; + loadTaskExecutionSession: (taskId: string, runId: string, sessionId: string, signal?: AbortSignal) => Promise; loadSession: (sessionId: string, options?: { messageLimit?: number; signal?: AbortSignal }) => Promise; streamSession: ( sessionId: string, @@ -365,6 +367,30 @@ export function createAgentApiClient( ); }, + async createTaskExecutionDebugSession(taskId, runId, sessionId) { + return requestJson( + `/task/${encodeURIComponent(taskId)}/execution-session/debug`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ run_id: runId, session_id: sessionId }) + } + ); + }, + + async loadTaskExecutionSession(taskId, runId, sessionId, signal) { + const query = new URLSearchParams({ run_id: runId, session_id: sessionId }); + const response = await request( + `/task/${encodeURIComponent(taskId)}/execution-session?${query}`, + { signal } + ); + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + if (response.status === 404) return null; + if (!response.ok) throw new Error(getResponseErrorMessage(data, response.status)); + return toLoadedSession(data); + }, + async getFrontendActionRegistry() { return requestJson("/frontend-action-registry"); }, diff --git a/src/features/agent/components/agent-command-panel.tsx b/src/features/agent/components/agent-command-panel.tsx index 0369037..fbaeb12 100644 --- a/src/features/agent/components/agent-command-panel.tsx +++ b/src/features/agent/components/agent-command-panel.tsx @@ -18,6 +18,7 @@ import { CheckCircle2, ChevronDown, ChevronLeft, + Eye, History, LoaderCircle, Pause, @@ -153,6 +154,7 @@ type AgentCommandPanelProps = { onRetryPromptSuggestions?: () => void; activeSessionId?: string | null; qaOnly?: boolean; + executionHistory?: boolean; statusLabel?: string; connectionStatus?: AgentConnectionStatus; streaming?: boolean; @@ -220,6 +222,7 @@ export function AgentCommandPanel({ onRetryPromptSuggestions, activeSessionId, qaOnly = false, + executionHistory = false, connectionStatus = "connecting", streaming = false, inputDelivery = "steer", @@ -561,6 +564,7 @@ export function AgentCommandPanel({ onResumeSpeech={resumeSpeech} onStopSpeech={stopSpeech} qaOnly={qaOnly} + showProcess={!qaOnly || executionHistory} onLocateAnswerFeatures={onLocateAnswerFeatures} onSubmitAnswerForm={onSubmitPrompt} onAsk={(selection) => { @@ -603,6 +607,15 @@ export function AgentCommandPanel({ : "border-t border-[var(--surface-divider)] p-3" )} > + {executionHistory ? ( +
+ +
+

执行记录自动同步

+

发送消息后进入独立调试会话,系统执行记录不会被修改。

+
+
+ ) : null} {centeredComposer && !promptReference ? (

有什么可以帮你?

@@ -1265,6 +1278,7 @@ type BackendMessageListProps = { onResumeSpeech: () => void; onStopSpeech: () => void; qaOnly: boolean; + showProcess: boolean; onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise | void; onSubmitAnswerForm: (text: string) => Promise | void; onAsk?: (selection: AnswerUiSelection) => void; @@ -1329,6 +1343,7 @@ function BackendMessage({ onResumeSpeech, onStopSpeech, qaOnly, + showProcess, onLocateAnswerFeatures, onSubmitAnswerForm, onAsk, @@ -1366,10 +1381,9 @@ function BackendMessage({ processMessages.some( (source) => getAnswerPresentation(source).process.length > 0 || - (!qaOnly && - (source.progress?.length || - (!hasPendingMessageInput(source) && - (source.todos?.todos.length || source.permissions?.length || source.questions?.length)))), + (showProcess && source.progress?.length) || + (!qaOnly && !hasPendingMessageInput(source) + && (source.todos?.todos.length || source.permissions?.length || source.questions?.length)), ); const hasProcessError = processMessages.some((source) => source.progress?.some((item) => item.status === "error")); return ( @@ -1485,6 +1499,7 @@ function BackendMessage({ diff --git a/src/features/agent/components/agent-message-process-content.tsx b/src/features/agent/components/agent-message-process-content.tsx index ce7a856..97fda94 100644 --- a/src/features/agent/components/agent-message-process-content.tsx +++ b/src/features/agent/components/agent-message-process-content.tsx @@ -7,10 +7,12 @@ import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-detai export function AgentMessageProcessContent({ messages, qaOnly, + showProgress, runningMessageId }: { messages: AgentChatMessage[]; qaOnly: boolean; + showProgress: boolean; runningMessageId?: string; }) { const progress = messages.flatMap((message) => (message.progress ?? []).map((item) => ({ @@ -18,7 +20,7 @@ export function AgentMessageProcessContent({ }))); return ( <> - {!qaOnly ? : null} + {showProgress ? : null} {messages.map((message) => { const { process } = getAnswerPresentation(message); const running = message.id === runningMessageId; diff --git a/src/features/workbench/api/operational-task-client.test.ts b/src/features/workbench/api/operational-task-client.test.ts index a2d81f6..49c90d8 100644 --- a/src/features/workbench/api/operational-task-client.test.ts +++ b/src/features/workbench/api/operational-task-client.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { fetchOperationalTaskEvidenceSnapshot, fetchOperationalTaskNotifications, + fetchOperationalTaskRuns, parseOperationalTask, parseOperationalTaskNotification } from "./operational-task-client"; @@ -39,6 +40,38 @@ function task(generation: number | null | undefined = 2) { afterEach(() => vi.unstubAllGlobals()); describe("operational task contract", () => { + it("loads task runs used to resolve a selected report round", async () => { + const run = { + run_id: id, + task_id: id, + generation: 1, + execution_request_id: id, + status: "succeeded", + revision: 2, + agent_session_id: "execution-session-1", + agent_instance_id: "agent-supply-1", + lease_expires_at: null, + heartbeat_at: time, + started_at: time, + finished_at: time, + failure_code: null, + failure_detail: null, + dispatch_attempts: 1, + next_dispatch_at: time, + created_at: time, + updated_at: time + }; + const fetchMock = vi.fn(async (..._args: Parameters) => + Response.json({ items: [run], next_cursor: null })); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchOperationalTaskRuns(id)).resolves.toEqual([run]); + const url = new URL(String(fetchMock.mock.calls[0]?.[0])); + expect(url.pathname).toBe(`/api/v1/operational-tasks/${id}/runs`); + expect(url.searchParams.get("limit")).toBe("100"); + expect(url.searchParams.get("offset")).toBe("0"); + }); + it("accepts execution generation on both plan and report", () => { const parsed = parseOperationalTask(task()); expect(parsed.latest_plan).toHaveProperty("execution_generation", 2); diff --git a/src/features/workbench/api/operational-task-client.ts b/src/features/workbench/api/operational-task-client.ts index 06a78ca..3151f2e 100644 --- a/src/features/workbench/api/operational-task-client.ts +++ b/src/features/workbench/api/operational-task-client.ts @@ -28,6 +28,7 @@ const operationalTaskRunSchema = z.object({ status: z.enum(["queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"]), revision: z.number().int().positive(), agent_session_id: z.string().min(1).nullable(), + agent_instance_id: z.string().min(1).nullable().optional(), lease_expires_at: workbenchDateTimeSchema.nullable(), heartbeat_at: workbenchDateTimeSchema.nullable(), started_at: workbenchDateTimeSchema.nullable(), @@ -39,6 +40,10 @@ const operationalTaskRunSchema = z.object({ created_at: workbenchDateTimeSchema, updated_at: workbenchDateTimeSchema }).strict(); +const operationalTaskRunPageSchema = z.object({ + items: z.array(operationalTaskRunSchema), + next_cursor: z.string().min(1).nullable() +}).strict(); const taskSummarySchema = z.object({ project_id: z.string().uuid(), task_id: z.string().uuid(), @@ -359,6 +364,11 @@ const taskDataSnapshotSchema = z.object({ export type OperationalTaskSummary = z.infer; export type OperationalTask = z.infer; +export type OperationalTaskRun = z.infer; +export type TaskExecutionHistoryTarget = { + executionGeneration: number | null; + sessionId: string; +}; export type OperationalTaskPlan = z.infer; export type OperationalTaskAction = z.infer; export type OperationalTaskAccessRequest = z.infer; @@ -423,6 +433,18 @@ export async function fetchOperationalTaskReports(taskId: string, offset: number )); } +export async function fetchOperationalTaskRuns(taskId: string, getAccessToken?: WorkbenchAccess) { + const runs: OperationalTaskRun[] = []; + const limit = 100; + for (let offset = 0; ; offset += limit) { + const page = operationalTaskRunPageSchema.parse(await operationalTaskRequest( + `/operational-tasks/${encodeURIComponent(taskId)}/runs?limit=${limit}&offset=${offset}`, getAccessToken, + )); + runs.push(...page.items); + if (page.items.length < limit) return runs; + } +} + export async function fetchOperationalTaskPresentationCursor(taskId: string, getAccessToken?: WorkbenchAccess, signal?: AbortSignal) { return investigationPresentationCursorSchema.parse(await operationalTaskRequest( `/operational-tasks/${encodeURIComponent(taskId)}/presentation-cursor`, getAccessToken, { signal } 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 9df5b5f..3ff31f5 100644 --- a/src/features/workbench/components/agent-execution-plan-panel.test.tsx +++ b/src/features/workbench/components/agent-execution-plan-panel.test.tsx @@ -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") => ({ diff --git a/src/features/workbench/components/agent-execution-plan-panel.tsx b/src/features/workbench/components/agent-execution-plan-panel.tsx index 86ef04a..fbd450a 100644 --- a/src/features/workbench/components/agent-execution-plan-panel.tsx +++ b/src/features/workbench/components/agent-execution-plan-panel.tsx @@ -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; onDecision: (actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise; onExecute: (actionId: string, expectedVersion: number) => Promise; onDraftUpdate?: (actionId: string, expectedVersion: number, payload: Record) => Promise; @@ -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 } renderEvidence={(item) => } diff --git a/src/features/workbench/components/investigation-task-report.tsx b/src/features/workbench/components/investigation-task-report.tsx index 85303d7..f99fddc 100644 --- a/src/features/workbench/components/investigation-task-report.tsx +++ b/src/features/workbench/components/investigation-task-report.tsx @@ -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; + onViewExecutionHistory?: (target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise | void; renderAction: (action: OperationalTaskAction) => ReactNode; renderEvidence: (evidence: OperationalTaskEvidence) => ReactNode; onLoadReports?: (offset: number) => Promise; @@ -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(0); const [historyLoading, setHistoryLoading] = useState(false); const [historyError, setHistoryError] = useState(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 (

- 调查任务 · 第 {task.execution_generation} 轮 + {displayedGeneration == null ? "调查任务 · 历史轮次" : `调查任务 · 第 ${displayedGeneration} 轮`}

{task.title}

@@ -247,17 +286,31 @@ export function InvestigationTaskReport({

{task.summary}

- +
+ + +
diff --git a/src/features/workbench/components/scheduled-condition-detail-panel.tsx b/src/features/workbench/components/scheduled-condition-detail-panel.tsx index 5979c9f..f29b289 100644 --- a/src/features/workbench/components/scheduled-condition-detail-panel.tsx +++ b/src/features/workbench/components/scheduled-condition-detail-panel.tsx @@ -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; + onViewTaskExecutionHistory?: (taskId: string, target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise | void; onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise; onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise; onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record) => Promise; @@ -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); diff --git a/src/features/workbench/components/scheduled-condition-feed.tsx b/src/features/workbench/components/scheduled-condition-feed.tsx index 085e00d..5850d8f 100644 --- a/src/features/workbench/components/scheduled-condition-feed.tsx +++ b/src/features/workbench/components/scheduled-condition-feed.tsx @@ -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; onSubmitPrompt: (prompt: string) => void | Promise; onOpenTaskCollaboration?: (taskId: string, discussion?: InvestigationDiscussion) => Promise; + onViewTaskExecutionHistory?: (taskId: string, target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise; onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise; onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise; onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record) => Promise; @@ -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} diff --git a/src/features/workbench/hooks/agent-session-runtime.ts b/src/features/workbench/hooks/agent-session-runtime.ts index 34a44db..59355ea 100644 --- a/src/features/workbench/hooks/agent-session-runtime.ts +++ b/src/features/workbench/hooks/agent-session-runtime.ts @@ -27,6 +27,7 @@ export type AgentSessionRuntime = { topicId?: string | null; purpose: AgentSessionPurpose; taskId?: string; + taskExecutionHistory?: { taskId: string; runId: string; sessionId: string }; draft: string; reference?: AnswerUiSelection; taskDiscussion?: { context: AgentTaskDiscussionContext; reportGeneration?: number | null }; diff --git a/src/features/workbench/hooks/use-workbench-agent.ts b/src/features/workbench/hooks/use-workbench-agent.ts index c69bc99..94f8a6c 100644 --- a/src/features/workbench/hooks/use-workbench-agent.ts +++ b/src/features/workbench/hooks/use-workbench-agent.ts @@ -200,7 +200,12 @@ export function useWorkbenchAgent({ } } }, [resources, scopeKey, scopeClient, selectedModel, approvalMode]); - const interactionRequests = useInteractionRequests(scopeClient, scopeKey, sessionId, continueInteraction); + const interactionRequests = useInteractionRequests( + scopeClient, + scopeKey, + sessionPurpose === "task_execution" ? null : sessionId, + continueInteraction + ); const refreshInteractionRequests = interactionRequests.refresh; @@ -510,7 +515,9 @@ export function useWorkbenchAgent({ const streaming = runtimeRunning(runtime); const { data: pendingInputs = [], mutate: refreshPendingInputs } = useSWR( - sessionId ? ["agent", "response-inputs", getAccessToken, scopeKey, sessionId] : null, + sessionId && sessionPurpose !== "task_execution" + ? ["agent", "response-inputs", getAccessToken, scopeKey, sessionId] + : null, () => clientRef.current.listResponseInputs(sessionId!), { refreshInterval: streaming ? 1000 : 0, shouldRetryOnError: false } ); @@ -776,6 +783,118 @@ export function useWorkbenchAgent({ return id; }, [activateRuntime, getAccessToken, getProjectId, projectScopeKey, resources]); + const openTaskExecutionHistory = useCallback(async (taskId: string, runId: string, executionSessionId: string) => { + const normalizedTaskId = taskId.trim(); + const normalizedRunId = runId.trim(); + const normalizedSessionId = executionSessionId.trim(); + if (!normalizedTaskId || !normalizedRunId || !normalizedSessionId) { + throw new Error("调查任务缺少可用的执行记录标识。"); + } + const projectClient = createAgentApiClient(undefined, { + getAccessToken: getWorkbenchTokenProvider(getAccessToken), + getProjectId + }); + const loaded = await projectClient.loadTaskExecutionSession( + normalizedTaskId, + normalizedRunId, + normalizedSessionId, + AbortSignal.timeout(15_000) + ); + if (!loaded) throw new Error("调查执行记录不存在或尚未持久化。"); + let target = agentRuntimes(resources).find((entry) => + entry.scope === projectScopeKey && entry.sessionId === loaded.sessionId + ); + if (!target) { + target = createAgentRuntime( + resources, + projectScopeKey, + projectClient, + createWorkbenchAgentTransport(projectClient, getAccessToken, getProjectId), + loaded + ); + } else { + target.client = projectClient; + target.chat.messages = toAgentUiMessages(loaded.messages); + target.title = loaded.title; + target.purpose = "task_execution"; + target.taskId = normalizedTaskId; + target.loadedAt = Date.now(); + target.updatedAt = loaded.updatedAt; + target.runStatus = loaded.runStatus; + target.error = undefined; + } + target.taskDiscussion = undefined; + target.taskExecutionHistory = { + taskId: normalizedTaskId, + runId: normalizedRunId, + sessionId: normalizedSessionId + }; + target.draft = ""; + activateRuntime(target); + setStatusLabel(loaded.runStatus === "running" ? "调查执行记录自动同步中" : "调查执行记录已打开"); + return loaded.sessionId; + }, [activateRuntime, getAccessToken, getProjectId, projectScopeKey, resources]); + + useEffect(() => { + const target = runtimeRef.current; + const binding = target?.taskExecutionHistory; + if (sessionPurpose !== "task_execution" || !target || !binding || target.sessionId !== binding.sessionId) return; + let stopped = false; + let refreshing = false; + let timer: ReturnType | undefined; + const controller = new AbortController(); + const schedule = (delay = 2_000) => { + if (!stopped) timer = setTimeout(() => void refresh(), delay); + }; + const refresh = async () => { + if (stopped || refreshing || resources.activeAgent !== target || target.purpose !== "task_execution") return; + refreshing = true; + if (timer) { + clearTimeout(timer); + timer = undefined; + } + try { + const loaded = await target.client.loadTaskExecutionSession( + binding.taskId, + binding.runId, + binding.sessionId, + controller.signal + ); + if (!loaded || stopped || resources.activeAgent !== target || target.purpose !== "task_execution") { + refreshing = false; + return; + } + target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages)); + target.title = loaded.title; + target.updatedAt = loaded.updatedAt; + target.loadedAt = Date.now(); + target.runStatus = loaded.runStatus; + target.connectionError = undefined; + setSessionTitle(loaded.title); + setStatusLabel(loaded.runStatus === "running" ? "调查执行记录自动同步中" : "调查执行记录已同步"); + resources.notify(); + refreshing = false; + if (loaded.runStatus === "running" || loaded.isStreaming) schedule(); + } catch (error) { + refreshing = false; + if (controller.signal.aborted || stopped) return; + target.connectionError = error instanceof Error ? error.message : "调查执行记录同步失败"; + setStatusLabel("调查执行记录同步中断,正在重试"); + resources.notify(); + schedule(4_000); + } + }; + const refreshOnFocus = () => void refresh(); + window.addEventListener("focus", refreshOnFocus); + schedule(); + return () => { + stopped = true; + controller.abort(); + if (timer) clearTimeout(timer); + window.removeEventListener("focus", refreshOnFocus); + }; + }, [resources, sessionId, sessionPurpose]); + const renameHistorySession = useCallback( async (nextSessionId: string, title: string) => { const normalizedTitle = title.trim(); @@ -890,6 +1009,42 @@ export function useWorkbenchAgent({ if (!normalizedPrompt) { return; } + let activeSessionId = sessionIdRef.current; + if (runtimeRef.current?.purpose === "task_execution") { + const target = runtimeRef.current; + const binding = target.taskExecutionHistory; + if (!binding) throw new Error("调查执行记录缺少调试分支信息,请重新打开后再试。"); + setStatusLabel("正在创建调查调试分支"); + try { + const branch = await target.client.createTaskExecutionDebugSession( + binding.taskId, + binding.runId, + binding.sessionId + ); + const loaded = await target.client.loadSession(branch.session_id, { + messageLimit: 40, + signal: AbortSignal.timeout(15_000) + }); + if (!loaded) throw new Error("调查调试分支创建后无法读取。"); + if (runtimeRef.current !== target) throw new Error("当前 Agent 会话已切换,请在新会话中重新发送。"); + target.sessionId = loaded.sessionId; + target.title = loaded.title; + target.purpose = "task_collaboration"; + target.taskId = binding.taskId; + target.taskExecutionHistory = undefined; + target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages)); + target.messagePage = loaded.messagePage; + target.loadedAt = Date.now(); + target.updatedAt = loaded.updatedAt; + target.runStatus = loaded.runStatus; + activeSessionId = loaded.sessionId; + activateRuntime(target); + setStatusLabel("调查调试会话已创建"); + } catch (error) { + showMapNotice({ tone: "error", title: "调查调试会话创建失败", message: error instanceof Error ? error.message : "请稍后重试。" }); + throw error; + } + } if (streaming || interactionRequests.awaitingInput) { const owningRuntime = runtimeRef.current; @@ -925,8 +1080,8 @@ export function useWorkbenchAgent({ { text: normalizedPrompt }, { body: { - session_id: sessionIdRef.current ?? undefined, - topic_id: !sessionIdRef.current ? runtimeRef.current?.topicId ?? undefined : undefined, + session_id: activeSessionId ?? undefined, + topic_id: !activeSessionId ? runtimeRef.current?.topicId ?? undefined : undefined, model: selectedModel || undefined, approval_mode: approvalMode, capabilities: frontendActionEnabledRef.current ? ["frontend-action@1", "host-question@1"] : ["host-question@1"], @@ -935,7 +1090,7 @@ export function useWorkbenchAgent({ } ); }, - [approvalMode, chat, inputDelivery, interactionRequests.awaitingInput, refreshInteractionRequests, refreshPendingInputs, selectedModel, streaming, taskDiscussion] + [activateRuntime, approvalMode, chat, inputDelivery, interactionRequests.awaitingInput, refreshInteractionRequests, refreshPendingInputs, selectedModel, streaming, taskDiscussion] ); const triggerEvent = useCallback(async (trigger: AgentEventTriggerContext, prompt: string) => { @@ -1060,6 +1215,7 @@ export function useWorkbenchAgent({ startNewSession, loadHistorySession, openTaskCollaboration, + openTaskExecutionHistory, renameHistorySession, deleteHistorySession, sessionHistory, diff --git a/src/features/workbench/map-workbench-page.tsx b/src/features/workbench/map-workbench-page.tsx index 954b3c8..b00ddb7 100644 --- a/src/features/workbench/map-workbench-page.tsx +++ b/src/features/workbench/map-workbench-page.tsx @@ -50,6 +50,7 @@ import { FeaturePopover } from "./components/feature-popover"; import { ScheduledConditionFeed } from "./components/scheduled-condition-feed"; import { TaskConversationReview } from "./components/task-conversation-review"; import type { InvestigationDiscussion } from "./api/investigation-report"; +import { fetchOperationalTaskRuns, type TaskExecutionHistoryTarget } from "./api/operational-task-client"; import { WorkbenchAgentPanels } from "./components/workbench-agent-panels"; import { OperationalTimeline } from "./operational-timeline/operational-timeline"; import { @@ -1013,6 +1014,29 @@ function MapWorkbenchContent({ openAgentPanelForViewport(); } + async function handleViewTaskExecutionHistory( + taskId: string, + target: TaskExecutionHistoryTarget, + knownRunId?: string + ) { + mapController.clearScadaAnalysis(); + let runId = knownRunId; + if (!runId) { + const runs = await fetchOperationalTaskRuns(taskId, getAccessToken); + runId = runs.find((run) => (target.executionGeneration == null + || run.generation === target.executionGeneration) + && run.agent_session_id === target.sessionId)?.run_id; + } + if (!runId) { + throw new Error(target.executionGeneration == null + ? "所选历史报告没有匹配的执行记录。" + : `第 ${target.executionGeneration} 轮调查没有匹配的执行记录。`); + } + await agent.openTaskExecutionHistory(taskId, runId, target.sessionId); + readingWorkspace?.close(); + openAgentPanelForViewport(); + } + async function handleDeleteAgentHistorySession(sessionId: string) { const clearsActiveAnalysis = sessionId === agent.sessionId; const deleted = await agent.deleteHistorySession(sessionId); @@ -1240,14 +1264,15 @@ function MapWorkbenchContent({ promptSuggestionsLoading: promptSuggestions.loading, promptSuggestionsUnavailable: promptSuggestions.unavailable, activeSessionId: agent.sessionId, - qaOnly: agent.sessionPurpose === "task_collaboration", + qaOnly: agent.sessionPurpose === "task_collaboration" || agent.sessionPurpose === "task_execution", + executionHistory: agent.sessionPurpose === "task_execution", statusLabel: agent.statusLabel, connectionStatus: agent.connectionStatus, streaming: agent.streaming, inputDelivery: agent.inputDelivery, onInputDeliveryChange: agent.setInputDelivery, pendingInputs: agent.pendingInputs, - interactionRequests: agent.interactionRequests, + interactionRequests: agent.sessionPurpose === "task_execution" ? undefined : agent.interactionRequests, onPendingInputDeliveryChange: agent.updateInputDelivery, onPendingInputEdit: (id) => agent.withdrawInput(id, true), onPendingInputCancel: (id) => agent.withdrawInput(id, false), @@ -1542,6 +1567,7 @@ function MapWorkbenchContent({ onLoadHistorySession={handleLoadAgentHistorySession} onSubmitPrompt={agent.submitPrompt} onOpenTaskCollaboration={handleOpenTaskCollaboration} + onViewTaskExecutionHistory={handleViewTaskExecutionHistory} onTaskDecision={operationalTasks.decideAction} onTaskExecute={operationalTasks.executeAction} onTaskDraftUpdate={operationalTasks.updateWorkOrderDraft} @@ -1699,6 +1725,7 @@ function MapWorkbenchContent({ onLoadHistorySession={handleLoadAgentHistorySession} onSubmitPrompt={agent.submitPrompt} onOpenTaskCollaboration={handleOpenTaskCollaboration} + onViewTaskExecutionHistory={handleViewTaskExecutionHistory} onTaskDecision={operationalTasks.decideAction} onTaskExecute={operationalTasks.executeAction} onTaskDraftUpdate={operationalTasks.updateWorkOrderDraft} diff --git a/tests/browser/investigation-execution-history.e2e.ts b/tests/browser/investigation-execution-history.e2e.ts new file mode 100644 index 0000000..9e1a801 --- /dev/null +++ b/tests/browser/investigation-execution-history.e2e.ts @@ -0,0 +1,261 @@ +import { expect, test } from "@playwright/test"; +import type { OperationalTask, OperationalTaskSummary } from "../../src/features/workbench/api/operational-task-client"; +import { mockAgentApi } from "./support/mock-agent-api"; + +const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e"; +const taskId = "82000000-0000-4000-8000-000000000011"; +const runId = "82500000-0000-4000-8000-000000000011"; +const previousRunId = "82500000-0000-4000-8000-000000000010"; +const sessionId = "investigation-execution-session"; +const previousSessionId = "investigation-execution-session-previous"; +const now = "2026-10-30T08:00:00Z"; + +const latestRun: NonNullable = { + run_id: runId, + task_id: taskId, + generation: 2, + execution_request_id: "85000000-0000-4000-8000-000000000011", + status: "succeeded", + revision: 4, + agent_session_id: sessionId, + agent_instance_id: "agent-supply-1", + lease_expires_at: null, + heartbeat_at: now, + started_at: now, + finished_at: now, + failure_code: null, + failure_detail: null, + dispatch_attempts: 1, + next_dispatch_at: now, + created_at: now, + updated_at: now +}; + +const previousRun: NonNullable = { + ...latestRun, + run_id: previousRunId, + generation: 1, + execution_request_id: "85000000-0000-4000-8000-000000000010", + agent_session_id: previousSessionId, + created_at: "2026-10-29T08:00:00Z", + updated_at: "2026-10-29T08:05:00Z" +}; + +const latestReport: NonNullable = { + revision: 2, + execution_generation: 2, + source_session_id: sessionId, + published_at: now, + published_by: "agent", + report: { + title: "南桥爆管调查报告", + current_conclusion: "第二轮已完成现场记录复核", + conclusion_boundary: "以第二轮调查数据为准", + evidence_refs: [], + recommendations: [], + pending_items: [] + } +}; + +const previousReport: NonNullable = { + ...latestReport, + revision: 1, + execution_generation: 1, + source_session_id: previousSessionId, + published_at: "2026-10-29T08:05:00Z", + report: { + ...latestReport.report, + current_conclusion: "第一轮完成压力与流量核对", + conclusion_boundary: "尚未取得现场阀门记录" + } +}; + +const task: OperationalTask = { + project_id: projectId, + task_id: taskId, + task_type: "event_investigation", + workflow: "burst-report", + lifecycle_status: "completed", + active_run: null, + latest_run: latestRun, + status: "completed", + priority: "normal", + revision: 2, + execution_generation: 2, + execution_request_id: "85000000-0000-4000-8000-000000000011", + title: "南桥爆管调查", + summary: "核对压力、流量和现场反馈", + source: { kind: "operator", external_id: null, external_revision: null, event_kind: "burst" }, + trigger: { kind: "manual", occurred_at: now, detail: "调试调查执行历史" }, + scheduled_for: null, + agent_session_id: sessionId, + agent_lease_expires_at: null, + current_plan_revision: null, + current_report_revision: 2, + created_at: now, + updated_at: now, + latest_plan: null, + latest_report: latestReport, + latest_progress: null, + evidence: [], + actions: [], + access_requests: [], + 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, + can_decide_access: false + }, + work_order_snapshot: null +}; + +const summary = (item: OperationalTask): OperationalTaskSummary => { + const { latest_plan, latest_report, latest_progress, evidence, actions, access_requests, permissions, work_order_snapshot, ...value } = item; + void latest_plan; void latest_report; void latest_progress; void evidence; + void actions; void access_requests; void permissions; void work_order_snapshot; + return value; +}; + +test("调查任务执行记录自动刷新并可创建调试会话追加消息", async ({ page }, testInfo) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await mockAgentApi(page); + let executionHistoryReads = 0; + let debugBranchCreates = 0; + const openedBindings: Array<{ runId: string | null; sessionId: string | null }> = []; + let debugBranchPayload: unknown; + await page.route(`http://127.0.0.1:8787/api/v1/task/${taskId}/execution-session?**`, async (route) => { + executionHistoryReads += 1; + const refreshed = executionHistoryReads > 1; + const query = new URL(route.request().url()).searchParams; + openedBindings.push({ runId: query.get("run_id"), sessionId: query.get("session_id") }); + await route.fulfill({ + headers: { "Access-Control-Allow-Origin": "*" }, + json: { + id: previousSessionId, + session_id: previousSessionId, + title: "南桥爆管调查 · 第一轮执行记录", + purpose: "task_execution", + task_id: taskId, + status: "active", + created_at: now, + updated_at: refreshed ? "2026-10-30T08:01:00Z" : now, + messages: [ + { id: "execution-user", role: "user", parts: [{ type: "text", text: "执行后台调查任务" }] }, + { id: "execution-assistant", role: "assistant", parts: [ + { type: "data-progress", data: { id: "pressure-flow", phase: "tool", title: "核对第一轮压力与流量", detail: "查询监测记录并校验时间范围。", status: "completed" } }, + { type: "text", text: "第一轮已完成压力与流量证据核对" } + ] }, + ...(refreshed ? [{ id: "execution-refresh", role: "assistant", parts: [{ type: "text", text: "第一轮自动同步发现现场阀门记录" }] }] : []) + ], + is_streaming: !refreshed, + run_status: refreshed ? "completed" : "running", + read_only: true + } + }); + }); + page.on("request", (request) => { + if (request.method() === "POST" && request.url().endsWith(`/task/${taskId}/execution-session/debug`)) { + debugBranchCreates += 1; + debugBranchPayload = request.postDataJSON(); + } + }); + await page.route("**/runtime-config.js", (route) => route.fulfill({ + contentType: "text/javascript", + body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({ + TJWATER_AUTH_MODE: "disabled", + TJWATER_SERVER_API_BASE_URL: "http://server.test", + TJWATER_MAP_URL: "http://map.test/geoserver", + TJWATER_GEOSERVER_WORKSPACE: "tjwater_next" + })}` + })); + await page.route("http://server.test/api/v1/projects**", (route) => route.fulfill({ json: { + items: [{ project_id: projectId, gs_workspace: "tjwater_next", status: "active" }], total: 1, limit: 1000, offset: 0 + } })); + await page.route("http://server.test/api/v1/scada-devices**", (route) => route.fulfill({ json: { items: [], total: 0, limit: 1000, offset: 0 } })); + await page.route("http://server.test/api/v1/workbench/sync-snapshot", (route) => route.fulfill({ json: { + schema: "workbench-sync-snapshot@1", + event_cursor: 0, + runtime: { + availability: "not_configured", source: "none", scenario_availability: "not_configured", + artifact_availability: "not_configured", schedule_availability: "not_configured", + work_order_availability: "not_configured", alert_availability: "not_configured", + generated_at: now, conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] + }, + tasks: [summary(task)], + notifications: [], + generated_at: now + } })); + await page.route("http://server.test/api/v1/workbench/events**", (route) => route.fulfill({ contentType: "text/event-stream", body: ": keepalive\n\n" })); + await page.route("http://server.test/api/v1/operational-task-notifications**", (route) => route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } })); + await page.route("http://server.test/api/v1/operational-tasks**", (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith("/operational-tasks/schedules")) return route.fulfill({ json: { items: [] } }); + if (path.endsWith(`/operational-tasks/${taskId}/reports`)) return route.fulfill({ json: { + items: [latestReport, previousReport], next_offset: null + } }); + if (path.endsWith(`/operational-tasks/${taskId}/runs`)) return route.fulfill({ json: { + items: [latestRun, previousRun], next_cursor: null + } }); + if (path.endsWith(`/operational-tasks/${taskId}`)) return route.fulfill({ json: task }); + if (path.endsWith("/operational-tasks")) return route.fulfill({ json: { items: [summary(task)], limit: 50, offset: 0, next_offset: null } }); + return route.fulfill({ status: 404, json: {} }); + }); + await page.route("http://map.test/**", (route) => route.fulfill({ status: 204 })); + + await page.goto("/"); + await page.getByRole("button", { name: "工况任务", exact: true }).click(); + const taskPanel = page.getByRole("region", { name: "工况任务", exact: true }); + const expand = taskPanel.getByRole("button", { name: "展开工况任务" }); + if (await expand.isVisible()) await expand.click(); + await taskPanel.getByRole("button", { name: /调查任务/ }).click(); + await taskPanel.getByText("南桥爆管调查", { exact: true }).first().click(); + await expect(taskPanel.getByText("调查任务 · 第 2 轮", { exact: true })).toBeVisible(); + await taskPanel.getByRole("button", { name: "展开报告版本" }).click(); + await page.getByRole("dialog", { name: "报告版本" }).getByRole("button", { name: /第 1 轮 · 报告第 1 版/ }).click(); + await expect(taskPanel.getByText("调查任务 · 第 1 轮", { exact: true })).toBeVisible(); + + const executionHistory = taskPanel.getByRole("button", { name: "执行记录" }); + await expect(executionHistory).toBeVisible(); + await executionHistory.click(); + + const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" }); + await expect(agentPanel.getByRole("note").getByText("执行记录自动同步", { exact: true })).toBeVisible(); + await expect(agentPanel).toContainText("第一轮已完成压力与流量证据核对"); + const processToggle = agentPanel.getByRole("button", { name: "查看处理过程", exact: true }).first(); + await expect(processToggle).toBeVisible(); + await processToggle.click(); + await expect(agentPanel.getByText("核对第一轮压力与流量", { exact: true })).toBeVisible(); + await expect(agentPanel.locator("textarea")).toBeVisible(); + await expect(agentPanel).toContainText("第一轮自动同步发现现场阀门记录", { timeout: 5_000 }); + expect(executionHistoryReads).toBeGreaterThanOrEqual(2); + expect(openedBindings).toEqual(expect.arrayContaining([ + { runId: previousRunId, sessionId: previousSessionId } + ])); + + await agentPanel.locator("textarea").fill("继续核对失败前最后一次工具调用"); + await agentPanel.getByRole("button", { name: "发送 Agent 指令" }).click(); + await expect.poll(() => debugBranchCreates).toBe(1); + expect(debugBranchPayload).toEqual({ run_id: previousRunId, session_id: previousSessionId }); + await expect(agentPanel).toContainText("继续核对失败前最后一次工具调用"); + await expect(agentPanel.getByRole("note").getByText("执行记录自动同步", { exact: true })).toHaveCount(0); + await testInfo.attach("desktop-execution-history", { + body: await agentPanel.screenshot(), contentType: "image/png" + }); + + await page.getByRole("button", { name: "折叠 Agent 面板", exact: true }).click(); + await page.setViewportSize({ width: 375, height: 720 }); + const openTasks = page.getByRole("button", { name: "打开工况任务", exact: true }); + if (await openTasks.isVisible()) await openTasks.click(); + const mobilePanel = page.getByRole("region", { name: "工况任务", exact: true }); + const mobileExpand = mobilePanel.getByRole("button", { name: "展开工况任务" }); + if (await mobileExpand.isVisible()) await mobileExpand.click(); + await mobilePanel.getByRole("button", { name: /调查任务/ }).click(); + await expect(mobilePanel.getByRole("button", { name: "执行记录" })).toBeVisible(); + await testInfo.attach("mobile-execution-history-button", { + body: await page.getByRole("region", { name: "工况任务抽屉" }).screenshot(), contentType: "image/png" + }); +}); diff --git a/tests/browser/support/mock-agent-api.ts b/tests/browser/support/mock-agent-api.ts index ca0fbe0..35924ac 100644 --- a/tests/browser/support/mock-agent-api.ts +++ b/tests/browser/support/mock-agent-api.ts @@ -21,6 +21,7 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {} icon: "bolt" as const }; const triggeredSessions = new Map(); + const executionDebugSessions = new Map(); await page.route("http://127.0.0.1:8787/api/v1/**", async (route) => { if (route.request().method() === "OPTIONS") { await route.fulfill({ @@ -70,6 +71,58 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {} return; } + const executionHistoryMatch = pathname.match(/\/task\/([^/]+)\/execution-session$/); + if (executionHistoryMatch && route.request().method() === "GET") { + const url = new URL(route.request().url()); + const sessionId = url.searchParams.get("session_id") ?? "task-execution-session"; + await route.fulfill({ + headers: corsHeaders, + json: { + id: sessionId, + session_id: sessionId, + title: "水质投诉动态调查 · 执行记录", + purpose: "task_execution", + task_id: executionHistoryMatch[1], + status: "active", + created_at: "2026-09-22T02:00:00Z", + updated_at: "2026-09-22T02:10:00Z", + messages: [ + { id: "execution-user", role: "user", parts: [{ type: "text", text: "执行后台调查任务" }] }, + { id: "execution-assistant", role: "assistant", parts: [{ type: "text", text: "已完成压力与流量证据核对" }] } + ], + is_streaming: false, + run_status: "completed", + read_only: true + } + }); + return; + } + + const executionDebugMatch = pathname.match(/\/task\/([^/]+)\/execution-session\/debug$/); + if (executionDebugMatch && route.request().method() === "POST") { + const body = (route.request().postDataJSON() ?? {}) as { session_id?: string }; + const sessionId = `execution-debug-${executionDebugMatch[1]}`; + const created = !executionDebugSessions.has(sessionId); + executionDebugSessions.set(sessionId, { + taskId: executionDebugMatch[1], + sourceSessionId: body.session_id ?? "task-execution-session" + }); + await route.fulfill({ + status: created ? 201 : 200, + headers: corsHeaders, + json: { + session_id: sessionId, + title: "水质投诉动态调查 · 调试", + purpose: "task_collaboration", + task_id: executionDebugMatch[1], + parent_session_id: body.session_id, + status: "active", + created + } + }); + return; + } + if (pathname.endsWith("/session") && route.request().method() === "POST") { const body = (route.request().postDataJSON() ?? {}) as { trigger_context?: { trigger_id?: string }; @@ -98,25 +151,34 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {} const sessionMatch = pathname.match(/\/session\/([^/]+)$/); if (sessionMatch && route.request().method() === "GET") { const collaboration = sessionMatch[1].startsWith("collaboration-"); + const executionDebug = executionDebugSessions.get(sessionMatch[1]); const stored = options.sessions?.find((session) => (session.session_id ?? session.id) === sessionMatch[1]); await route.fulfill({ headers: corsHeaders, json: { id: sessionMatch[1], session_id: sessionMatch[1], - title: stored?.title ?? (collaboration ? "任务协作问答" : "水质投诉事件调查"), - purpose: stored?.purpose ?? (collaboration ? "task_collaboration" : "ordinary_chat"), - task_id: collaboration ? sessionMatch[1].slice("collaboration-".length) : undefined, + title: stored?.title ?? (executionDebug ? "水质投诉动态调查 · 调试" : collaboration ? "任务协作问答" : "水质投诉事件调查"), + purpose: stored?.purpose ?? (executionDebug || collaboration ? "task_collaboration" : "ordinary_chat"), + task_id: executionDebug?.taskId ?? (collaboration ? sessionMatch[1].slice("collaboration-".length) : undefined), status: "active", created_at: "2026-09-22T02:00:00Z", updated_at: "2026-09-22T02:00:00Z", - messages: [], + messages: executionDebug ? [ + { id: "execution-user", role: "user", parts: [{ type: "text", text: "执行后台调查任务" }] }, + { id: "execution-assistant", role: "assistant", parts: [{ type: "text", text: "已完成压力与流量证据核对" }] } + ] : [], is_streaming: false } }); return; } + if (/\/session\/[^/]+\/interactions$/.test(pathname)) { + await route.fulfill({ headers: corsHeaders, json: { items: [] } }); + return; + } + if (pathname.endsWith("/responses")) { await route.fulfill({ status: 200,