diff --git a/README.md b/README.md
index 5ea7d80..b117c45 100644
--- a/README.md
+++ b/README.md
@@ -89,6 +89,7 @@ pnpm format # 使用 Prettier 格式化文件
pnpm test # 运行 Vitest 单元测试
pnpm test:watch # 监听模式运行单元测试
pnpm test:browser # 运行 Playwright 浏览器测试
+pnpm check:contracts # 在三端集成工作区只读检查前端生成的 Agent/Server 契约
```
## Docker
diff --git a/package.json b/package.json
index 480cf13..2a38e8b 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"scripts": {
"dev": "vite",
"export:answer-ui-contract": "node scripts/export-answer-ui-contract.mjs",
+ "check:contracts": "node scripts/export-answer-ui-contract.mjs --check && node scripts/export-view-command-contract.mjs --check",
"build": "tsc --noEmit && vite build && pnpm build:verify-css && pnpm build:tts",
"build:verify-css": "node scripts/verify-production-css.mjs",
"build:tts": "esbuild server/edge-tts-server.ts --bundle --platform=node --format=cjs --outfile=dist-server/edge-tts-server.cjs",
diff --git a/scripts/export-answer-ui-contract.mjs b/scripts/export-answer-ui-contract.mjs
index ac2a86c..75c4fe7 100644
--- a/scripts/export-answer-ui-contract.mjs
+++ b/scripts/export-answer-ui-contract.mjs
@@ -1,11 +1,13 @@
import process from "node:process";
-import { writeFile } from "node:fs/promises";
+import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { zodToJsonSchema } from "zod-to-json-schema";
import { answerUiCatalog, ANSWER_UI_VERSION } from "../src/features/agent/answer-ui/catalog.ts";
-const targets = process.argv[2]
- ? [resolve(process.argv[2])]
+const check = process.argv.includes("--check");
+const destination = process.argv.slice(2).find((arg) => arg !== "--check");
+const targets = destination
+ ? [resolve(destination)]
: [
resolve("../next-tjwater-agent/src/chat/answer-ui-catalog.json"),
resolve("../next-tjwater-server/contracts/answer-ui-catalog.json")
@@ -23,7 +25,12 @@ const contract = {
])
)
};
+const content = `${JSON.stringify(contract, null, 2)}\n`;
for (const target of targets) {
- await writeFile(target, `${JSON.stringify(contract, null, 2)}\n`);
- process.stdout.write(`Exported ${contract.components.length} components to ${target}\n`);
+ if (check) {
+ if (await readFile(target, "utf8") !== content) throw new Error(`Answer UI contract differs: ${target}`);
+ } else {
+ await writeFile(target, content);
+ }
+ process.stdout.write(`${check ? "Checked" : "Exported"} ${contract.components.length} components ${check ? "in" : "to"} ${target}\n`);
}
diff --git a/src/features/agent/api/client.ts b/src/features/agent/api/client.ts
index 3fae7a7..9ce04dc 100644
--- a/src/features/agent/api/client.ts
+++ b/src/features/agent/api/client.ts
@@ -121,6 +121,7 @@ export type AgentSessionStreamDataEventType =
| "todo_update"
| "permission_request"
| "permission_response"
+ | "run_status"
| "interaction_request"
| "question_request"
| "question_response"
@@ -131,6 +132,7 @@ export type AgentSessionStreamDataEventType =
| "ui_envelope"
| "session_title"
| "done"
+ | "aborted"
| "error"
| "auth_required"
| "credential_refresh_required"
@@ -837,6 +839,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "execution_plan" ||
value === "permission_request" ||
value === "permission_response" ||
+ value === "run_status" ||
value === "interaction_request" ||
value === "question_request" ||
value === "question_response" ||
@@ -847,6 +850,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "ui_envelope" ||
value === "session_title" ||
value === "done" ||
+ value === "aborted" ||
value === "error" ||
value === "credential_refresh_required" ||
value === "credential_refreshed" ||
diff --git a/src/features/agent/components/agent-command-panel.tsx b/src/features/agent/components/agent-command-panel.tsx
index fcbdc91..ab1d46a 100644
--- a/src/features/agent/components/agent-command-panel.tsx
+++ b/src/features/agent/components/agent-command-panel.tsx
@@ -1066,6 +1066,11 @@ function BackendMessage({
)
) : null}
+ {message.role === "assistant" && message.runError && message.content !== `错误:${message.runError}` ? (
+
+ 错误:{message.runError}
+
+ ) : null}
{message.role === "assistant" && processComplete && hasProcessError ? (
部分查询或工具未完成,可展开处理过程查看原因。
diff --git a/src/features/agent/components/agent-progress-status-icon.tsx b/src/features/agent/components/agent-progress-status-icon.tsx
index 3c3c870..3135d09 100644
--- a/src/features/agent/components/agent-progress-status-icon.tsx
+++ b/src/features/agent/components/agent-progress-status-icon.tsx
@@ -1,9 +1,10 @@
-import { CheckCircle2, LoaderCircle, XCircle } from "lucide-react";
+import { Ban, CheckCircle2, LoaderCircle, XCircle } from "lucide-react";
import { StatusIcon, type StatusTone } from "@/shared/ui/status";
import type { AgentChatProgress } from "../types";
const states = {
completed: { label: "已完成", tone: "success", Icon: CheckCircle2 },
+ cancelled: { label: "已中止", tone: "neutral", Icon: Ban },
error: { label: "失败", tone: "danger", Icon: XCircle },
running: { label: "进行中", tone: "info", Icon: LoaderCircle }
} satisfies Record<
diff --git a/src/features/agent/index.ts b/src/features/agent/index.ts
index 0c9e12e..97f70c7 100644
--- a/src/features/agent/index.ts
+++ b/src/features/agent/index.ts
@@ -55,6 +55,7 @@ export {
cancelRunningTodos,
completeRunningProgress,
finalizeAssistantMessageAfterAbort,
+ finalizeAssistantMessageAfterError,
toTodoUpdate,
upsertPermission,
upsertProgress,
diff --git a/src/features/agent/session-state.test.ts b/src/features/agent/session-state.test.ts
index e527717..0d1d5d4 100644
--- a/src/features/agent/session-state.test.ts
+++ b/src/features/agent/session-state.test.ts
@@ -73,6 +73,8 @@ describe("agent session state", () => {
requestId: "permission-1",
status: "approved_once"
});
+ expect(applyPermissionResponse(permissions, { request_id: "permission-1", status: "error", error: "native unavailable" })?.[0])
+ .toMatchObject({ status: "error", error: "native unavailable" });
});
it("dedupes question requests by tool call id", () => {
@@ -152,7 +154,7 @@ describe("agent session state", () => {
const next = finalizeAssistantMessageAfterAbort(message);
expect(next.content).toBe("请求已中止。");
- expect(next.progress?.[0].status).toBe("completed");
+ expect(next.progress?.[0].status).toBe("cancelled");
expect(next.permissions?.[0].status).toBe("aborted");
});
});
diff --git a/src/features/agent/session-state.ts b/src/features/agent/session-state.ts
index 80ca998..e5fe827 100644
--- a/src/features/agent/session-state.ts
+++ b/src/features/agent/session-state.ts
@@ -47,7 +47,10 @@ export function upsertProgress(progress: AgentChatProgress[] | undefined, data:
return next;
}
-export function completeRunningProgress(progress: AgentChatProgress[] | undefined) {
+export function completeRunningProgress(
+ progress: AgentChatProgress[] | undefined,
+ status: "completed" | "error" | "cancelled" = "completed"
+) {
return progress?.map((item) => {
if (item.status !== "running") {
return item;
@@ -56,7 +59,7 @@ export function completeRunningProgress(progress: AgentChatProgress[] | undefine
const endedAt = Date.now();
return {
...item,
- status: "completed" as const,
+ status,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
@@ -154,9 +157,9 @@ export function applyPermissionResponse(
permission.requestId === requestId
? {
...permission,
- status: toPermissionStatus(readString(payload.reply)),
+ status: payload.status === "error" ? "error" as const : toPermissionStatus(readString(payload.reply)),
repliedAt: Date.now(),
- error: undefined
+ error: payload.status === "error" ? readString(payload.error) : undefined
}
: permission
);
@@ -235,7 +238,7 @@ export function applyQuestionResponse(questions: AgentQuestionRequest[] | undefi
}
export function finalizeAssistantMessageAfterAbort(message: AgentChatMessage): AgentChatMessage {
- const progress = completeRunningProgress(message.progress);
+ const progress = completeRunningProgress(message.progress, "cancelled");
const todos = cancelRunningTodos(message.todos);
return {
@@ -266,6 +269,18 @@ export function finalizeAssistantMessageAfterAbort(message: AgentChatMessage): A
};
}
+export function finalizeAssistantMessageAfterError(message: AgentChatMessage, reason: string): AgentChatMessage {
+ const settled = finalizeAssistantMessageAfterAbort(message);
+ return {
+ ...settled,
+ content: message.content || `错误:${reason}`,
+ runError: reason,
+ progress: settled.progress?.map((item, index) => message.progress?.[index].status === "running"
+ ? { ...item, status: "error", detail: item.detail ? `${item.detail}\n${reason}` : reason }
+ : item)
+ };
+}
+
function toPermissionStatus(reply: string | undefined): AgentPermissionStatus {
if (reply === "always") {
return "approved_always";
@@ -340,7 +355,7 @@ function toToolRef(value: unknown): AgentInteractionToolRef | undefined {
}
function readProgressStatus(value: unknown): AgentChatProgress["status"] {
- return value === "completed" || value === "error" ? value : "running";
+ return value === "completed" || value === "error" || value === "cancelled" ? value : "running";
}
function readTodoStatus(value: unknown): AgentTodoItem["status"] | undefined {
diff --git a/src/features/agent/types.ts b/src/features/agent/types.ts
index 45aaaca..63dba1e 100644
--- a/src/features/agent/types.ts
+++ b/src/features/agent/types.ts
@@ -2,6 +2,7 @@ export type AgentChatMessage = {
id: string;
role: "user" | "assistant";
content: string;
+ runError?: string;
answerParts?: import("./answer-ui/contract").AnswerUiPart[];
finalAnswer?: { content: string; uiBlockIds: string[] };
progress?: AgentChatProgress[];
@@ -24,7 +25,7 @@ export type AgentChatProgress = {
tool?: { name: string; callId?: string; input?: Record };
id: string;
phase: string;
- status: "running" | "completed" | "error";
+ status: "running" | "completed" | "error" | "cancelled";
title: string;
detail?: string;
startedAt?: number;
diff --git a/src/features/workbench/api/gis-properties-client.ts b/src/features/workbench/api/gis-properties-client.ts
index 7dbd3e3..542d84e 100644
--- a/src/features/workbench/api/gis-properties-client.ts
+++ b/src/features/workbench/api/gis-properties-client.ts
@@ -1,13 +1,7 @@
-import { getWorkbenchAccessToken } from "./request-context";
+import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import type { WorkbenchAccess } from "./request-context";
-import { env } from "@/shared/config/env";
import type { WaterNetworkSourceId } from "../map/sources";
-import {
- createWorkbenchHeaders,
- findOwnedWorkbenchProject,
- getServerError
-} from "./project-client";
const gisAttributeGroupSchema = z.object({
dataset_key: z.string().min(1),
@@ -43,7 +37,6 @@ const sourceGisFeaturePropertiesSchema = z.object({
export type GisAttributeGroup = z.infer;
-const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchElementGisProperties(
elementType: WaterNetworkSourceId,
@@ -52,26 +45,15 @@ export async function fetchElementGisProperties(
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise {
- const ownedProject = projectId
- ? null
- : await findOwnedWorkbenchProject(getAccessToken, signal);
- const accessToken = ownedProject?.accessToken ?? await getWorkbenchAccessToken(getAccessToken);
- const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
- if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
const path = [
"/api/v1/elements",
encodeURIComponent(elementType),
encodeURIComponent(elementId),
"gis-properties"
].join("/");
- const response = await fetch(`${SERVER_API_BASE_URL}${path}`, {
- headers: createWorkbenchHeaders(accessToken, resolvedProjectId),
- signal
+ const payload = await requestWorkbenchJson(path, "GIS 台账属性查询", {
+ access: getAccessToken, projectId, init: { signal }
});
- const payload: unknown = await response.json().catch(() => null);
- if (!response.ok) {
- throw new Error(getServerError(payload, response.status, "GIS 台账属性查询"));
- }
const result = elementGisPropertiesSchema.parse(payload);
if (result.element_type !== elementType || result.element_id !== elementId) {
throw new Error("GIS 台账属性与所选元素不一致");
@@ -86,12 +68,6 @@ export async function fetchSourceGisFeatureProperties(
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise {
- const ownedProject = projectId
- ? null
- : await findOwnedWorkbenchProject(getAccessToken, signal);
- const accessToken = ownedProject?.accessToken ?? await getWorkbenchAccessToken(getAccessToken);
- const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
- if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
const path = [
"/api/v1/source-gis/layers",
encodeURIComponent(layerKey),
@@ -99,14 +75,9 @@ export async function fetchSourceGisFeatureProperties(
encodeURIComponent(String(sourceFid)),
"properties"
].join("/");
- const response = await fetch(`${SERVER_API_BASE_URL}${path}`, {
- headers: createWorkbenchHeaders(accessToken, resolvedProjectId),
- signal
+ const payload = await requestWorkbenchJson(path, "源 GIS 属性查询", {
+ access: getAccessToken, projectId, init: { signal }
});
- const payload: unknown = await response.json().catch(() => null);
- if (!response.ok) {
- throw new Error(getServerError(payload, response.status, "源 GIS 属性查询"));
- }
const result = sourceGisFeaturePropertiesSchema.parse(payload);
if (result.layer_key !== layerKey || result.source_fid !== sourceFid) {
throw new Error("源 GIS 属性与所选要素不一致");
diff --git a/src/features/workbench/api/operational-task-client.ts b/src/features/workbench/api/operational-task-client.ts
index fc12eb0..f73505a 100644
--- a/src/features/workbench/api/operational-task-client.ts
+++ b/src/features/workbench/api/operational-task-client.ts
@@ -1,9 +1,8 @@
+import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import { investigationReportSchema, investigationDiagramSchema } from "./investigation-report";
import { investigationPresentationCursorSchema, investigationPresentationHistoryPageSchema, type InvestigationPresentationHistoryEvent } from "./investigation-presentation";
import type { WorkbenchAccess } from "./request-context";
-import { env } from "@/shared/config/env";
-import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const taskStatusSchema = z.enum(["queued", "running", "waiting_human", "completed", "failed", "cancelled"]);
const taskPrioritySchema = z.enum(["normal", "urgent"]);
@@ -432,16 +431,9 @@ export type CreateOperationalTaskInput = {
agent_session_id?: string;
};
-const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
async function operationalTaskRequest(path: string, getAccessToken?: WorkbenchAccess, init?: RequestInit) {
- const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, init?.signal ?? undefined);
- const headers = new Headers(createWorkbenchHeaders(accessToken, project.project_id));
- if (init?.body) headers.set("Content-Type", "application/json");
- const response = await fetch(`${SERVER_API_BASE_URL}/api/v1${path}`, { ...init, headers });
- const payload: unknown = await response.json().catch(() => null);
- if (!response.ok) throw new Error(getServerError(payload, response.status, "工况任务请求"));
- return payload;
+ return requestWorkbenchJson(`/api/v1${path}`, "工况任务请求", { access: getAccessToken, init });
}
export async function fetchOperationalTask(taskId: string, getAccessToken?: WorkbenchAccess, signal?: AbortSignal) {
diff --git a/src/features/workbench/api/place-search-client.ts b/src/features/workbench/api/place-search-client.ts
index 743b98e..a663266 100644
--- a/src/features/workbench/api/place-search-client.ts
+++ b/src/features/workbench/api/place-search-client.ts
@@ -1,8 +1,7 @@
-import { getWorkbenchAccessToken } from "./request-context";
+import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import type { WorkbenchAccess } from "./request-context";
-import { env } from "@/shared/config/env";
-import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError, type PlaceSearchConfiguration } from "./project-client";
+import { type PlaceSearchConfiguration } from "./project-client";
export const MAX_PLACE_SEARCH_RESULTS = 10;
@@ -71,21 +70,8 @@ async function requestPlaceData(
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise {
- const requestSignal = signal
- ? AbortSignal.any([signal, AbortSignal.timeout(20_000)])
- : AbortSignal.timeout(20_000);
- const ownedProject = projectId ? null : await findOwnedWorkbenchProject(getAccessToken, requestSignal);
- const accessToken = ownedProject?.accessToken ?? await getWorkbenchAccessToken(getAccessToken);
- requestSignal.throwIfAborted();
- const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
- if (!resolvedProjectId) throw new Error("未找到地名搜索所属项目");
- const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/${path}`, {
- method: "POST",
- headers: createWorkbenchHeaders(accessToken, resolvedProjectId, true),
- body: JSON.stringify(body),
- signal: requestSignal
+ return requestWorkbenchJson(`/api/v1/${path}`, "地名搜索", {
+ access: getAccessToken, projectId, timeoutMs: 20_000,
+ init: { method: "POST", body: JSON.stringify(body), signal }
});
- const payload: unknown = await response.json().catch(() => null);
- if (!response.ok) throw new Error(getServerError(payload, response.status, "地名搜索"));
- return payload;
}
diff --git a/src/features/workbench/api/project-request.ts b/src/features/workbench/api/project-request.ts
index ad727ff..a241f1c 100644
--- a/src/features/workbench/api/project-request.ts
+++ b/src/features/workbench/api/project-request.ts
@@ -48,18 +48,19 @@ export async function withProjectRequest(
baseUrl: string,
operation: string,
request: (signal: AbortSignal) => Promise,
- signal?: AbortSignal
+ signal?: AbortSignal,
+ timeoutMs: number | null = PROJECT_REQUEST_TIMEOUT_MS
): Promise {
- const deadline = AbortSignal.timeout(PROJECT_REQUEST_TIMEOUT_MS);
- const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
+ const deadline = timeoutMs == null ? undefined : AbortSignal.timeout(timeoutMs);
+ const combined = signal && deadline ? AbortSignal.any([signal, deadline]) : signal ?? deadline ?? new AbortController().signal;
try {
combined.throwIfAborted();
return await request(combined);
} catch (error) {
if (signal?.aborted && !isTimeout(signal.reason)) throw signal.reason;
- if (isTimeout(error) || (combined.aborted && isTimeout(combined.reason))) {
+ if (timeoutMs != null && (isTimeout(error) || (combined.aborted && isTimeout(combined.reason)))) {
const diagnosis = await describeServerReadiness(baseUrl);
- throw new Error(`${operation}超时(已等待 15 秒)。${diagnosis}`, { cause: error });
+ throw new Error(`${operation}超时(已等待 ${timeoutMs / 1000} 秒)。${diagnosis}`, { cause: error });
}
if (error instanceof TypeError || (error instanceof DOMException && error.name === "NetworkError")) {
throw new Error(`${operation}失败:无法连接后端服务。请检查网络和后端服务状态后重试。`, { cause: error });
diff --git a/src/features/workbench/api/realtime-flow-client.ts b/src/features/workbench/api/realtime-flow-client.ts
index 8d3eebb..6247943 100644
--- a/src/features/workbench/api/realtime-flow-client.ts
+++ b/src/features/workbench/api/realtime-flow-client.ts
@@ -1,8 +1,7 @@
+import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import type { WorkbenchAccess } from "./request-context";
-import { env } from "@/shared/config/env";
import type { PipeFlowCalculation } from "../map/flow-overlay";
-import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const realtimeLinkSchema = z.object({
time: z.string().datetime({ offset: true }),
@@ -11,7 +10,6 @@ const realtimeLinkSchema = z.object({
});
const realtimeLinksSchema = z.array(realtimeLinkSchema);
-const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const FLOW_LOOKBACK_MS = 15 * 60 * 1000;
export type RealtimePipeFlowSnapshot = {
@@ -24,22 +22,13 @@ export async function fetchRealtimePipeFlows(
signal?: AbortSignal,
now = new Date()
): Promise {
- const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const query = new URLSearchParams({
start_time: new Date(now.getTime() - FLOW_LOOKBACK_MS).toISOString(),
end_time: now.toISOString()
});
- const response = await fetch(
- `${SERVER_API_BASE_URL}/api/v1/timeseries/realtime/links?${query.toString()}`,
- {
- headers: createWorkbenchHeaders(accessToken, project.project_id),
- signal
- }
- );
- const payload: unknown = await response.json().catch(() => null);
- if (!response.ok) {
- throw new Error(getServerError(payload, response.status, "实时管道流速请求"));
- }
+ const payload = await requestWorkbenchJson(`/api/v1/timeseries/realtime/links?${query}`, "实时管道流速请求", {
+ access: getAccessToken, init: { signal }
+ });
const rows = realtimeLinksSchema.parse(payload);
const latestByPipe = new Map>();
diff --git a/src/features/workbench/api/request-json.test.ts b/src/features/workbench/api/request-json.test.ts
new file mode 100644
index 0000000..37cacf5
--- /dev/null
+++ b/src/features/workbench/api/request-json.test.ts
@@ -0,0 +1,60 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { findOwnedWorkbenchProject } from "./project-client";
+import { requestWorkbenchJson } from "./request-json";
+
+vi.mock("./project-client", async (original) => ({
+ ...await original(), findOwnedWorkbenchProject: vi.fn()
+}));
+afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.clearAllMocks(); });
+
+describe("project JSON transport", () => {
+ it("reuses resolved credentials and preserves request headers without choosing another project", async () => {
+ const fetcher = vi.fn().mockResolvedValue(Response.json({ ok: true }));
+ vi.stubGlobal("fetch", fetcher);
+ await expect(requestWorkbenchJson("/api/v1/probe", "查询", {
+ credentials: { projectId: "project-a", accessToken: "token-a" },
+ init: { headers: { "X-Request-Id": "request-a" } }
+ })).resolves.toEqual({ ok: true });
+ const headers = new Headers(fetcher.mock.calls[0][1].headers);
+ expect(headers.get("Authorization")).toBe("Bearer token-a");
+ expect(headers.get("X-Project-Id")).toBe("project-a");
+ expect(headers.get("X-Request-Id")).toBe("request-a");
+ expect(findOwnedWorkbenchProject).not.toHaveBeenCalled();
+ });
+
+ it("rejects an explicitly empty project instead of falling back to a default", async () => {
+ const fetcher = vi.fn(); vi.stubGlobal("fetch", fetcher);
+ await expect(requestWorkbenchJson("/api/v1/probe", "查询", { projectId: "" })).rejects.toThrow("所属项目不能为空");
+ expect(findOwnedWorkbenchProject).not.toHaveBeenCalled();
+ expect(fetcher).not.toHaveBeenCalled();
+ });
+
+ it("preserves caller cancellation before fetching and while reading the body", async () => {
+ const controller = new AbortController();
+ const reason = new DOMException("changed project", "AbortError");
+ const fetcher = vi.fn().mockImplementation(async () => ({ ok: true, json: async () => {
+ controller.abort(reason); throw reason;
+ } }));
+ vi.stubGlobal("fetch", fetcher);
+ const options = { credentials: { projectId: "project-a", accessToken: null }, init: { signal: controller.signal } };
+ await expect(requestWorkbenchJson("/api/v1/probe", "查询", options)).rejects.toBe(reason);
+ await expect(requestWorkbenchJson("/api/v1/probe", "查询", options)).rejects.toBe(reason);
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps deadlines opt-in and never retries a failed write", async () => {
+ const deadline = vi.spyOn(AbortSignal, "timeout");
+ const fetcher = vi.fn().mockResolvedValue(Response.json({ detail: "服务繁忙" }, { status: 503 }));
+ vi.stubGlobal("fetch", fetcher);
+ await expect(requestWorkbenchJson("/api/v1/probe", "提交", {
+ credentials: { projectId: "project-a", accessToken: null }, init: { method: "POST", body: "{}" }
+ })).rejects.toThrow("服务繁忙");
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ expect(deadline).not.toHaveBeenCalled();
+ fetcher.mockResolvedValue(Response.json({}));
+ await requestWorkbenchJson("/api/v1/probe", "地名搜索", {
+ credentials: { projectId: "project-a", accessToken: null }, timeoutMs: 20_000
+ });
+ expect(deadline).toHaveBeenCalledWith(20_000);
+ });
+});
diff --git a/src/features/workbench/api/request-json.ts b/src/features/workbench/api/request-json.ts
new file mode 100644
index 0000000..ff9d98f
--- /dev/null
+++ b/src/features/workbench/api/request-json.ts
@@ -0,0 +1,38 @@
+import { env } from "@/shared/config/env";
+import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
+import { withProjectRequest } from "./project-request";
+import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
+
+type ProjectCredentials = { projectId: string | undefined; accessToken: string | null | undefined };
+
+/** Project-scoped JSON transport. Business validation and write retries belong to callers. */
+export async function requestWorkbenchJson(path: string, operation: string, options: {
+ access?: WorkbenchAccess;
+ projectId?: string | null;
+ credentials?: ProjectCredentials;
+ init?: RequestInit;
+ timeoutMs?: number;
+} = {}): Promise {
+ const baseUrl = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
+ return withProjectRequest(baseUrl, operation, async (signal) => {
+ let credentials = options.credentials;
+ if (!credentials) {
+ if (options.projectId != null) {
+ if (!options.projectId) throw new Error(`${operation}所属项目不能为空`);
+ credentials = { projectId: options.projectId, accessToken: await getWorkbenchAccessToken(options.access) };
+ } else {
+ const { project, accessToken } = await findOwnedWorkbenchProject(options.access, signal);
+ credentials = { projectId: project.project_id, accessToken };
+ }
+ }
+ signal.throwIfAborted();
+ const headers = new Headers(options.init?.headers);
+ new Headers(createWorkbenchHeaders(credentials.accessToken, credentials.projectId)).forEach((value, key) => headers.set(key, value));
+ if (options.init?.body != null && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
+ const response = await fetch(`${baseUrl}${path}`, { ...options.init, headers, signal });
+ const payload: unknown = await response.json().catch(() => null);
+ signal.throwIfAborted();
+ if (!response.ok) throw new Error(getServerError(payload, response.status, operation));
+ return payload;
+ }, options.init?.signal ?? undefined, options.timeoutMs ?? null);
+}
diff --git a/src/features/workbench/api/scada-client.ts b/src/features/workbench/api/scada-client.ts
index a9c0444..6548705 100644
--- a/src/features/workbench/api/scada-client.ts
+++ b/src/features/workbench/api/scada-client.ts
@@ -1,8 +1,8 @@
+import { requestWorkbenchJson } from "./request-json";
import type { Feature, FeatureCollection, Point } from "geojson";
import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
-import { env } from "@/shared/config/env";
import { z } from "zod";
-import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
+import { findOwnedWorkbenchProject } from "./project-client";
const scadaDeviceSchema = z.object({
device_id: z.string().trim().min(1),
@@ -68,7 +68,6 @@ export type ScadaSnapshot = {
telemetryStatus: "ready" | "empty" | "unavailable";
};
-const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const SCADA_LOOKBACK_MS = 15 * 60 * 1000;
const SCADA_READING_BATCH_SIZE = 100;
@@ -244,11 +243,5 @@ async function requestJson(
projectId: string | undefined,
signal: AbortSignal | undefined
) {
- const headers = createWorkbenchHeaders(accessToken, projectId);
- const response = await fetch(`${SERVER_API_BASE_URL}${path}`, { headers, signal });
- const payload: unknown = await response.json().catch(() => null);
- if (!response.ok) {
- throw new Error(getServerError(payload, response.status, "SCADA API请求"));
- }
- return payload;
+ return requestWorkbenchJson(path, "SCADA API请求", { credentials: { accessToken, projectId }, init: { signal } });
}
diff --git a/src/features/workbench/api/scada-replay-client.ts b/src/features/workbench/api/scada-replay-client.ts
index 479a5ea..1465179 100644
--- a/src/features/workbench/api/scada-replay-client.ts
+++ b/src/features/workbench/api/scada-replay-client.ts
@@ -1,6 +1,6 @@
+import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
-import { env } from "@/shared/config/env";
-import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
+import { findOwnedWorkbenchProject } from "./project-client";
import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
const ruleSchema = z.object({
@@ -128,14 +128,10 @@ export class ScadaReplayClient {
this.projectId ??= findOwnedWorkbenchProject(this.access).then(({ project }) => project.project_id)
.catch((error) => { this.projectId = null; throw error; });
const [projectId, token] = await Promise.all([this.projectId, getWorkbenchAccessToken(this.access)]);
- const headers = createWorkbenchHeaders(token, projectId);
- if (body !== undefined) headers.set("Content-Type", "application/json");
- const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/scada-replays${path}`, {
- method: body === undefined ? "GET" : "POST", headers, signal,
- ...(body === undefined ? {} : { body: JSON.stringify(body) })
+ return requestWorkbenchJson(`/api/v1/scada-replays${path}`, "SCADA 回放", {
+ credentials: { projectId, accessToken: token },
+ init: { method: body === undefined ? "GET" : "POST", signal,
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }) }
});
- const payload: unknown = await response.json().catch(() => null);
- if (!response.ok) throw new Error(getServerError(payload, response.status, "SCADA 回放"));
- return payload;
}
}
diff --git a/src/features/workbench/api/workbench-analysis-history-client.ts b/src/features/workbench/api/workbench-analysis-history-client.ts
index b72beb1..512d3f5 100644
--- a/src/features/workbench/api/workbench-analysis-history-client.ts
+++ b/src/features/workbench/api/workbench-analysis-history-client.ts
@@ -1,7 +1,6 @@
+import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import type { WorkbenchAccess } from "./request-context";
-import { env } from "@/shared/config/env";
-import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const analysisHistoryItemSchema = z.object({
run_id: z.string().trim().min(1),
@@ -41,7 +40,6 @@ export type WorkbenchAnalysisHistoryPage = {
nextOffset: number | null;
};
-const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchWorkbenchAnalysisHistory(
options: {
@@ -59,25 +57,13 @@ export async function fetchWorkbenchAnalysisHistory(
if (!Number.isInteger(offset) || offset < 0) {
throw new Error("分析成果历史偏移量无效");
}
- const { project, accessToken } = await findOwnedWorkbenchProject(
- options.getAccessToken,
- options.signal
- );
const search = new URLSearchParams({
limit: String(limit),
offset: String(offset)
});
- const response = await fetch(
- `${SERVER_API_BASE_URL}/api/v1/workbench/analysis-runs?${search}`,
- {
- headers: createWorkbenchHeaders(accessToken, project.project_id),
- signal: options.signal
- }
- );
- const payload: unknown = await response.json().catch(() => null);
- if (!response.ok) {
- throw new Error(getServerError(payload, response.status, "分析成果历史请求"));
- }
+ const payload = await requestWorkbenchJson(`/api/v1/workbench/analysis-runs?${search}`, "分析成果历史请求", {
+ access: options.getAccessToken, init: { signal: options.signal }
+ });
const page = analysisHistoryPageSchema.parse(payload);
return {
items: page.items.map((item) => ({
diff --git a/src/features/workbench/api/workbench-runtime-client.ts b/src/features/workbench/api/workbench-runtime-client.ts
index f3d7c84..2a3d30a 100644
--- a/src/features/workbench/api/workbench-runtime-client.ts
+++ b/src/features/workbench/api/workbench-runtime-client.ts
@@ -1,7 +1,7 @@
+import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import type { FeatureCollection, Geometry, Position } from "geojson";
import type { WorkbenchAccess } from "./request-context";
-import { env } from "@/shared/config/env";
import { formatDateTime } from "@/shared/utils/date-time";
import type {
ScheduledConditionRecord,
@@ -10,7 +10,6 @@ import type {
WorkbenchScenario
} from "../types";
import type { AnalysisArtifact, ArtifactLifecycle, ArtifactType } from "../workspace/workspace-model";
-import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
// Keep legacy analysis values parseable while old servers are rolling forward;
// they are filtered before the operational workbench model is created.
@@ -159,7 +158,6 @@ export type WorkbenchAnalysisProjection = {
simulationOverlay: FeatureCollection | null;
};
-const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntimeSnapshot {
const snapshot = runtimeSnapshotSchema.parse(payload);
@@ -264,18 +262,9 @@ export async function fetchWorkbenchAnalysisRun(
): Promise {
const normalizedRunId = runId.trim();
if (!normalizedRunId) throw new Error("分析运行 ID 不能为空");
- const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
- const response = await fetch(
- `${SERVER_API_BASE_URL}/api/v1/workbench/analysis-runs/${encodeURIComponent(normalizedRunId)}`,
- {
- headers: createWorkbenchHeaders(accessToken, project.project_id),
- signal
- }
- );
- const payload: unknown = await response.json().catch(() => null);
- if (!response.ok) {
- throw new Error(getServerError(payload, response.status, "分析运行成果请求"));
- }
+ const payload = await requestWorkbenchJson(`/api/v1/workbench/analysis-runs/${encodeURIComponent(normalizedRunId)}`, "分析运行成果请求", {
+ access: getAccessToken, init: { signal }
+ });
const analysis = runtimeLatestAnalysisSchema.parse(payload);
if (analysis.run_id !== normalizedRunId) {
throw new Error("分析运行成果与请求 ID 不一致");
diff --git a/src/features/workbench/api/workbench-sync-client.ts b/src/features/workbench/api/workbench-sync-client.ts
index c8a4309..71f6d74 100644
--- a/src/features/workbench/api/workbench-sync-client.ts
+++ b/src/features/workbench/api/workbench-sync-client.ts
@@ -1,3 +1,4 @@
+import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
@@ -63,13 +64,9 @@ export async function fetchWorkbenchSyncSnapshot(
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise {
- const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
- const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/workbench/sync-snapshot`, {
- headers: createWorkbenchHeaders(accessToken, project.project_id),
- signal
+ const payload = await requestWorkbenchJson("/api/v1/workbench/sync-snapshot", "工作台同步快照请求", {
+ access: getAccessToken, init: { signal }
});
- const payload: unknown = await response.json().catch(() => null);
- if (!response.ok) throw new Error(getServerError(payload, response.status, "工作台同步快照请求"));
const envelope = snapshotEnvelopeSchema.parse(payload);
return {
eventCursor: envelope.event_cursor,
diff --git a/src/features/workbench/components/agent-execution-plan-panel.tsx b/src/features/workbench/components/agent-execution-plan-panel.tsx
index 93285c2..c87c637 100644
--- a/src/features/workbench/components/agent-execution-plan-panel.tsx
+++ b/src/features/workbench/components/agent-execution-plan-panel.tsx
@@ -1,3 +1,4 @@
+import { getReportPresentationState, snapshotRound } from "../utils/report-presentation-state";
import { InvestigationReportView } from "./investigation-report-view";
import { formatDateTime as formatDisplayDateTime } from "@/shared/utils/date-time";
import { InvestigationTaskReport } from "./investigation-task-report";
@@ -110,18 +111,8 @@ export function AgentExecutionPlanPanel({
/>;
const plan = task.latest_plan?.plan;
const report = task.latest_report?.report;
- const planGeneration = task.latest_plan?.execution_generation;
- const planState = planGeneration == null
- ? "historical"
- : planGeneration === task.execution_generation
- ? "current"
- : "previous";
- const reportGeneration = task.latest_report?.execution_generation;
- const reportState = reportGeneration == null
- ? "historical"
- : reportGeneration === task.execution_generation
- ? "current"
- : "previous";
+ const planState = snapshotRound(task.latest_plan?.execution_generation, task.execution_generation);
+ const { round: reportState } = getReportPresentationState(task, task.latest_report);
const visiblePlanNodeIds = plan && plan.schema === "execution-plan@2"
? activePlanGraph(plan).visible : null;
const visiblePlanNodes = plan && visiblePlanNodeIds
diff --git a/src/features/workbench/components/investigation-report-view.tsx b/src/features/workbench/components/investigation-report-view.tsx
index 67eeb09..cc7fbcc 100644
--- a/src/features/workbench/components/investigation-report-view.tsx
+++ b/src/features/workbench/components/investigation-report-view.tsx
@@ -1,3 +1,4 @@
+import { getReportPresentationState } from "../utils/report-presentation-state";
import { FileText } from "lucide-react";
import { StatusBadge } from "@/shared/ui/status";
import { formatDateTime } from "@/shared/utils/date-time";
@@ -7,9 +8,7 @@ import type { OperationalTask } from "../api/operational-task-client";
export function InvestigationReportView({ task, reportLabel }: { task: OperationalTask; reportLabel: string }) {
const snapshot = task.latest_report;
const report = snapshot?.report;
- const insufficient = report?.outcome === "insufficient_data";
- const current = snapshot?.execution_generation === task.execution_generation;
- const ended = current && task.lifecycle_status === "completed";
+ const { insufficient, current, ended } = getReportPresentationState(task, snapshot);
const insufficientLabel = ended ? "本轮已结束 · 无法形成结论"
: task.lifecycle_status === "failed" ? "证据不足 · 本轮执行失败"
: task.lifecycle_status === "cancelled" ? "证据不足 · 本轮已取消"
diff --git a/src/features/workbench/components/investigation-task-report.tsx b/src/features/workbench/components/investigation-task-report.tsx
index 814b10c..e2adb49 100644
--- a/src/features/workbench/components/investigation-task-report.tsx
+++ b/src/features/workbench/components/investigation-task-report.tsx
@@ -1,3 +1,4 @@
+import { getReportPresentationState } from "../utils/report-presentation-state";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { InvestigationQuestions } from "./investigation-questions";
import { History, LoaderCircle, MessageSquare } from "lucide-react";
@@ -179,22 +180,13 @@ export function InvestigationTaskReport({
// A current final report owns its archived graph. During investigation, a
// progress snapshot owns its whole graph, including an explicit null. A prior
// round's final report must never hide the new round's progress.
- const currentFinalReport = snapshot?.execution_generation === task.execution_generation
- && report?.stage === "final";
+ const { currentFinalReport, previousRound, olderRevision, insufficient } = getReportPresentationState(task, snapshot);
const diagram = live
? progress && !currentFinalReport ? progress.progress.diagram : investigationDiagram(task, snapshot)
: selection.diagram;
const diagramRunning = Boolean(live && !currentFinalReport && progress && task.active_run?.run_id === progress.run_id && task.active_run.status === "running");
- const previousRound = Boolean(
- snapshot && snapshot.execution_generation !== task.execution_generation
- );
- const olderRevision = Boolean(
- snapshot && task.latest_report && snapshot.revision !== task.latest_report.revision
- );
const latestAvailable = !live;
- const insufficient = report?.outcome === "insufficient_data";
- const currentInsufficient = task.latest_report?.execution_generation === task.execution_generation
- && task.latest_report?.report.outcome === "insufficient_data";
+ const { currentInsufficient } = getReportPresentationState(task, task.latest_report);
const pendingActions = task.actions.filter(isPendingInvestigationAction);
const completedActions = task.actions.filter((action) => !isPendingInvestigationAction(action));
const evidence = task.evidence.filter((item) => report?.evidence_refs.includes(item.evidence_id));
diff --git a/src/features/workbench/hooks/agent-interaction-replies.test.ts b/src/features/workbench/hooks/agent-interaction-replies.test.ts
new file mode 100644
index 0000000..ec147dc
--- /dev/null
+++ b/src/features/workbench/hooks/agent-interaction-replies.test.ts
@@ -0,0 +1,25 @@
+import { expect, it, vi } from "vitest";
+import type { AgentQuestionRequest } from "@/features/agent";
+import { createAgentInteractionReplies } from "./agent-interaction-replies";
+import type { AgentSessionRuntime } from "./agent-session-runtime";
+import { WorkbenchSessionResources } from "./workbench-session-resources";
+
+it("keeps a reply bound to its original session when the user switches conversations", async () => {
+ let resolve: () => void = () => {};
+ const request = vi.fn(() => new Promise((done) => { resolve = done; }));
+ const original = { sessionId: "original", client: { replyQuestion: request }, questionOverrides: {} } as unknown as AgentSessionRuntime;
+ const next = { sessionId: "next", questionOverrides: {} } as AgentSessionRuntime;
+ const resources = new WorkbenchSessionResources(); resources.activeAgent = original;
+ const setters = { permission: vi.fn(), question: vi.fn() };
+ const replies = createAgentInteractionReplies(resources, setters);
+ const question = { sessionId: "original", requestId: "question" } as AgentQuestionRequest;
+ const submitted = replies.replyQuestion(question, [["A"]]);
+ expect(original.questionOverrides.question.status).toBe("submitting");
+ resources.activeAgent = next; setters.question.mockClear();
+ resolve(); await submitted;
+ expect(original.questionOverrides.question).toEqual({ status: "answered", answers: [["A"]] });
+ expect(next.questionOverrides).toEqual({});
+ expect(setters.question).not.toHaveBeenCalled();
+ await replies.replyQuestion(question, [["B"]]);
+ expect(request).toHaveBeenCalledTimes(1);
+});
diff --git a/src/features/workbench/hooks/agent-interaction-replies.ts b/src/features/workbench/hooks/agent-interaction-replies.ts
new file mode 100644
index 0000000..ad3ff17
--- /dev/null
+++ b/src/features/workbench/hooks/agent-interaction-replies.ts
@@ -0,0 +1,60 @@
+import type { AgentPermissionReply, AgentPermissionRequest, AgentQuestionRequest } from "@/features/agent";
+import { showMapNotice } from "@/features/map/core";
+import { toPermissionStatus, type PermissionOverride, type QuestionOverride } from "./agent-session-message-state";
+import type { AgentSessionRuntime } from "./agent-session-runtime";
+import type { WorkbenchSessionResources } from "./workbench-session-resources";
+
+export function createAgentInteractionReplies(resources: WorkbenchSessionResources, setters: {
+ permission: (value: Record) => void;
+ question: (value: Record) => void;
+}) {
+ const permissionUpdate = (target: AgentSessionRuntime, id: string, value: PermissionOverride) => {
+ target.permissionOverrides = { ...target.permissionOverrides, [id]: value };
+ if (resources.activeAgent === target) setters.permission(target.permissionOverrides);
+ resources.notify();
+ };
+ const questionUpdate = (target: AgentSessionRuntime, id: string, value: QuestionOverride) => {
+ target.questionOverrides = { ...target.questionOverrides, [id]: value };
+ if (resources.activeAgent === target) setters.question(target.questionOverrides);
+ resources.notify();
+ };
+ const submit = async (
+ request: { sessionId: string; requestId: string },
+ update: (target: AgentSessionRuntime, id: string, value: T | { status: "submitting" } | { status: "error"; error: string }) => void,
+ execute: (target: AgentSessionRuntime) => Promise,
+ success: T,
+ failure: { id: string; title: string; message: string }
+ ) => {
+ const target = resources.activeAgent;
+ if (!target || target.sessionId !== request.sessionId) return;
+ const apply = (value: T | { status: "submitting" } | { status: "error"; error: string }) => update(target, request.requestId, value);
+ apply({ status: "submitting" });
+ try {
+ await execute(target);
+ apply(success);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : failure.message;
+ apply({ status: "error", error: message });
+ showMapNotice({ id: failure.id, tone: "error", title: failure.title, message });
+ }
+ };
+ const questionFailure = { id: "agent-question-status", title: "Agent 问题提交失败", message: "问题回复提交失败。" };
+ return {
+ replyPermission: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => submit(
+ permission, permissionUpdate,
+ (target) => target.client.replyPermission(permission.requestId, { sessionId: permission.sessionId, reply }),
+ { status: toPermissionStatus(reply) },
+ { id: "agent-permission-status", title: "Agent 权限提交失败", message: "权限请求提交失败。" }
+ ),
+ replyQuestion: (question: AgentQuestionRequest, answers: string[][]) => submit(
+ question, questionUpdate,
+ (target) => target.client.replyQuestion(question.requestId, { sessionId: question.sessionId, answers }),
+ { status: "answered", answers }, questionFailure
+ ),
+ rejectQuestion: (question: AgentQuestionRequest) => submit(
+ question, questionUpdate,
+ (target) => target.client.rejectQuestion(question.requestId, { sessionId: question.sessionId }),
+ { status: "rejected" }, { ...questionFailure, message: "问题跳过失败。" }
+ )
+ };
+}
diff --git a/src/features/workbench/hooks/agent-session-message-state.test.ts b/src/features/workbench/hooks/agent-session-message-state.test.ts
index 107561d..bd63cbf 100644
--- a/src/features/workbench/hooks/agent-session-message-state.test.ts
+++ b/src/features/workbench/hooks/agent-session-message-state.test.ts
@@ -125,3 +125,76 @@ it("keeps a malformed persisted data payload from crashing the conversation", ()
expect(toAgentChatMessages(messages, {}, {})[0].content).toBe("这段结构化内容无法显示。");
}
});
+
+it("restores an aborted run's terminal state from canonical history", () => {
+ const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [
+ { type: "text", text: "已核查的部分结果" },
+ { type: "data-progress", data: { id: "query", title: "查询", status: "running" } },
+ { type: "data-run_status", data: { status: "aborted", message: "请求已中断" } }
+ ] }]);
+ expect(toAgentChatMessages(messages, {}, {})[0]).toMatchObject({ content: "已核查的部分结果", progress: [{ status: "cancelled" }] });
+});
+
+describe("terminal tool progress", () => {
+ const parts = [
+ { type: "text", text: "已核查的部分结果" },
+ { type: "data-progress", data: { id: "tool", phase: "tool", title: "正在处理", status: "running", tool: { name: "query", input: { id: 12 } } } },
+ { type: "data-todo_update", data: { session_id: "s", todos: [{ id: "todo", content: "核查", status: "in_progress" }] } },
+ { type: "data-permission_request", data: { request_id: "permission", session_id: "s", permission: "read", patterns: [] } },
+ { type: "data-question_request", data: { request_id: "question", session_id: "s", questions: [{ question: "继续吗?", options: [] }] } }
+ ];
+ const initial = () => toAgentUiMessages([{ id: "answer", role: "assistant", parts }]);
+
+ it.each(["done", "error", "aborted"] as const)("settles running tool rows after resumed %s", (type) => {
+ const messages = applySessionStreamEvent(initial(), { type, data: { message: "模型请求失败" } });
+ const answer = toAgentChatMessages(messages, {}, {})[0];
+ expect(answer.progress?.[0]).toMatchObject({ status: type === "done" ? "completed" : type === "aborted" ? "cancelled" : "error", tool: { name: "query", input: { id: 12 } } });
+ expect(answer.todos?.todos[0].status).toBe("cancelled");
+ expect(answer.content).toBe("已核查的部分结果");
+ if (type === "error") expect(answer.runError).toBe("模型请求失败");
+ });
+
+ it("preserves the reason when reopening a failed answer with partial structured output", () => {
+ const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [...parts,
+ { type: "data-final_answer", data: { content: "已核查的部分结果" } },
+ { type: "data-run_status", data: { status: "error", message: "模型请求失败" } }
+ ] }]);
+ const answer = toAgentChatMessages(messages, {}, {})[0];
+ expect(answer).toMatchObject({ content: "已核查的部分结果", runError: "模型请求失败", progress: [{ status: "error", detail: "模型请求失败" }] });
+ });
+
+ it("does not let an old reply override revive stopped cards", () => {
+ const messages = applySessionStreamEvent(initial(), { type: "run_status", data: { status: "aborted" } });
+ const answer = toAgentChatMessages(messages, { permission: { status: "error", error: "late HTTP response" } },
+ { question: { status: "submitting" } })[0];
+ expect(answer.permissions?.[0].status).toBe("aborted");
+ expect(answer.questions?.[0].status).toBe("rejected");
+ const stopped = applySessionStreamEvent(messages, { type: "error", data: { message: "请求已中断" } });
+ expect(toAgentChatMessages(stopped, {}, {})[0].runError).toBeUndefined();
+ expect(toAgentChatMessages(stopped, {}, {})[0].progress?.[0].status).toBe("cancelled");
+ });
+
+ it("settles legacy history using the session status while leaving active tools running", () => {
+ for (const status of ["completed", "aborted", "error", "waiting_input", "running"] as const) {
+ const messages = toAgentUiMessages(initial(), { runStatus: status, isStreaming: status === "running" });
+ expect(toAgentChatMessages(messages, {}, {})[0].progress?.[0].status)
+ .toBe(status === "running" ? "running" : status === "error" ? "error" : status === "aborted" ? "cancelled" : "completed");
+ }
+ const state = applySessionStreamEvent(initial(), { type: "state", sessionId: "s", isStreaming: false, messages: initial() });
+ expect(toAgentChatMessages(state, {}, {})[0].progress?.[0].status).toBe("completed");
+ });
+
+ it("keeps a saved failure authoritative when a legacy session defaults to completed", () => {
+ const saved = applySessionStreamEvent(initial(), { type: "error", data: { message: "历史错误" } });
+ const answer = toAgentChatMessages(toAgentUiMessages(saved, { runStatus: "completed", isStreaming: false }), {}, {})[0];
+ expect(answer.runError).toBe("历史错误");
+ });
+
+ it("routes historical question replies to the owning assistant without changing the current turn", () => {
+ const messages = [...initial(), ...toAgentUiMessages([{ id: "current", role: "assistant", parts: [{ type: "text", text: "新一轮" }] }])];
+ const updated = applySessionStreamEvent(messages, { type: "question_response", data: { request_id: "question", rejected: true } });
+ const answers = toAgentChatMessages(updated, {}, {});
+ expect(answers[0].questions?.[0].status).toBe("rejected");
+ expect(updated[1]).toEqual(messages[1]);
+ });
+});
diff --git a/src/features/workbench/hooks/agent-session-message-state.ts b/src/features/workbench/hooks/agent-session-message-state.ts
index 44a67c8..5026b0b 100644
--- a/src/features/workbench/hooks/agent-session-message-state.ts
+++ b/src/features/workbench/hooks/agent-session-message-state.ts
@@ -2,6 +2,10 @@ import type { UIMessage } from "ai";
import type { Dispatch, SetStateAction } from "react";
import {
parseAnswerUiBlock,
+ completeRunningProgress,
+ cancelRunningTodos,
+ finalizeAssistantMessageAfterAbort,
+ finalizeAssistantMessageAfterError,
readInteractionReply,
getFinalAnswerTranscript,
type AnswerUiPart,
@@ -17,7 +21,8 @@ import {
type AgentPermissionStatus,
type AgentQuestionRequest,
type AgentSessionStreamEvent,
- type AgentStreamRenderState
+ type AgentStreamRenderState,
+ type AgentRunStatus
} from "@/features/agent";
type AgentUiDataParts = {
@@ -26,6 +31,7 @@ type AgentUiDataParts = {
task_context: Record;
execution_plan: Record;
progress: Record;
+ run_status: Record;
final_answer: Record;
credential_refresh_required: Record;
credential_refreshed: Record;
@@ -137,11 +143,14 @@ export function toAgentChatMessages(
...(answerParts ? { answerParts } : {})
};
+ let runState: Record | undefined;
for (const part of message.parts) {
if (!isAgentDataPart(part)) {
continue;
}
- if (part.type === "data-interaction_reply" && message.role === "user") {
+ if (part.type === "data-run_status") {
+ runState = part.data;
+ } else if (part.type === "data-interaction_reply" && message.role === "user") {
const reply = readInteractionReply(part.data, message.id);
if (reply) next = { ...next, interactionReply: reply };
} else if (part.type === "data-task_context") {
@@ -193,23 +202,44 @@ export function toAgentChatMessages(
if (next.finalAnswer) {
next.content = getFinalAnswerTranscript(next);
}
+ if (runState?.status === "aborted") {
+ next = finalizeAssistantMessageAfterAbort(next);
+ } else if (runState?.status === "error") {
+ next = finalizeAssistantMessageAfterError(next, getDataString(runState, "message") ?? "Agent response failed");
+ } else if (runState?.status === "completed" || runState?.status === "waiting_input") {
+ next = { ...next, progress: completeRunningProgress(next.progress),
+ ...(runState.status === "completed" ? { todos: cancelRunningTodos(next.todos) } : {}) };
+ }
return [applyQuestionOverrides(applyPermissionOverrides(next, permissionOverrides), questionOverrides)];
});
}
-export function toAgentUiMessages(messages: unknown[]): AgentUiMessage[] {
- return messages.flatMap((message) => {
+export function toAgentUiMessages(
+ messages: unknown[],
+ session?: { runStatus?: AgentRunStatus; isStreaming?: boolean }
+): AgentUiMessage[] {
+ const normalized = messages.flatMap((message) => {
if (isAgentUiMessage(message)) {
return [message];
}
const legacyMessage = toAgentUiMessageFromLegacy(message);
return legacyMessage ? [legacyMessage] : [];
});
+ if (!session) return normalized;
+ const lastAssistant = normalized.reduce((last, message, index) => message.role === "assistant" ? index : last, -1);
+ return normalized.map((message, index) => {
+ if (message.role !== "assistant" || message.parts.some((part) => part.type === "data-run_status")) return message;
+ const status = index === lastAssistant
+ ? session.runStatus ?? (session.isStreaming === false ? "completed" : undefined)
+ : "completed";
+ if (status === "completed" && !message.parts.some((part) => part.type === "data-progress" || part.type === "data-todo_update")) return message;
+ return status && status !== "running" ? upsertUiDataPart(message, "run_status", { id: "run-status", status }) : message;
+ });
}
export function applySessionStreamEvent(messages: AgentUiMessage[], event: AgentSessionStreamEvent): AgentUiMessage[] {
if (event.type === "state") {
- return toAgentUiMessages(event.messages);
+ return toAgentUiMessages(event.messages, event);
}
if (event.type === "user_input") {
@@ -226,15 +256,34 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
return token ? updateLastAssistantUiMessage(messages, (message) => appendTextToUiMessage(message, token)) : messages;
}
+ if (event.type === "done" || event.type === "error" || event.type === "aborted") {
+ return updateLastAssistantUiMessage(messages, (message) => {
+ const previous = [...message.parts].reverse().find((part) => part.type === "data-run_status");
+ const data = previous && isAgentDataPart(previous) ? previous.data : {};
+ return upsertUiDataPart(message, "run_status", {
+ ...data, id: "run-status",
+ status: data.status === "aborted" || event.type === "aborted" || event.data.status === "aborted" ? "aborted"
+ : event.type === "error" ? "error" : event.data.reason === "waiting_input" ? "waiting_input" : "completed",
+ ...(event.type === "error" || event.type === "aborted" ? { message: getDataString(event.data, "message") ?? getDataString(data, "message") ?? "Agent stream failed" } : {})
+ });
+ });
+ }
+
+ if (event.type === "permission_response" || event.type === "question_response") {
+ const requestType = event.type === "permission_response" ? "data-permission_request" : "data-question_request";
+ return messages.map((message) => message.role === "assistant" && message.parts.some((part) =>
+ part.type === requestType && isAgentDataPart(part) && part.data.request_id === event.data.request_id)
+ ? upsertUiDataPart(message, event.type, event.data) : message);
+ }
+
if (
+ event.type === "run_status" ||
event.type === "final_answer" ||
event.type === "progress" ||
event.type === "execution_plan" ||
event.type === "todo_update" ||
event.type === "permission_request" ||
- event.type === "permission_response" ||
event.type === "question_request" ||
- event.type === "question_response" ||
event.type === "answer_ui" ||
event.type === "ui_envelope" ||
event.type === "session_title"
@@ -244,13 +293,6 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
);
}
- if (event.type === "error") {
- const message = getDataString(event.data, "message") ?? "Agent stream failed";
- return updateLastAssistantUiMessage(messages, (item) =>
- appendTextToUiMessage(item, item.parts.some((part) => part.type === "text") ? `\n\n错误:${message}` : `错误:${message}`)
- );
- }
-
return messages;
}
@@ -478,7 +520,7 @@ function applyPermissionOverrides(
...message,
permissions: message.permissions.map((permission) => {
const override = permissionOverrides[permission.requestId];
- return override
+ return override && ["pending", "submitting", "error"].includes(permission.status)
? {
...permission,
status: override.status,
@@ -502,7 +544,7 @@ function applyQuestionOverrides(
...message,
questions: message.questions.map((question) => {
const override = questionOverrides[question.requestId];
- return override
+ return override && ["checking", "pending", "submitting", "error"].includes(question.status)
? {
...question,
status: override.status,
diff --git a/src/features/workbench/hooks/agent-session-runtime.test.ts b/src/features/workbench/hooks/agent-session-runtime.test.ts
index cd925bc..6c51da1 100644
--- a/src/features/workbench/hooks/agent-session-runtime.test.ts
+++ b/src/features/workbench/hooks/agent-session-runtime.test.ts
@@ -3,11 +3,42 @@ import type { ChatTransport, UIMessageChunk } from "ai";
import { AgentSessionStreamError, AgentStreamDisconnectedError, type AgentApiClient } from "@/features/agent";
import { WorkbenchSessionResources } from "./workbench-session-resources";
import { createAgentRuntime, mergeHistoryMessages, resumeAgentRuntime, runtimeRunning } from "./agent-session-runtime";
-import type { AgentUiMessage } from "./agent-session-message-state";
+import { toAgentChatMessages, type AgentUiMessage } from "./agent-session-message-state";
const message = (id: string, text = id): AgentUiMessage => ({ id, role: "assistant", parts: [{ type: "text", text }] });
describe("independent conversation runtimes", () => {
+ it.each(["completed", "error", "aborted"] as const)("settles direct tool progress when the response is %s without a final progress event", async (status) => {
+ const resources = new WorkbenchSessionResources();
+ let controller!: ReadableStreamDefaultController;
+ const runtime = createAgentRuntime(resources, "project:a", {} as AgentApiClient, {
+ sendMessages: async ({ abortSignal }) => new ReadableStream({ start(stream) {
+ controller = stream;
+ abortSignal?.addEventListener("abort", () => stream.error(new DOMException("Aborted", "AbortError")), { once: true });
+ } }),
+ reconnectToStream: async () => null,
+ });
+ resources.activeAgent = runtime;
+ try {
+ const request = runtime.chat.sendMessage({ text: "核查工具状态" });
+ await vi.waitFor(() => expect(controller).toBeDefined());
+ controller.enqueue({ type: "start", messageId: "answer" });
+ controller.enqueue({ type: "data-progress", data: { id: "tool", phase: "tool", title: "正在处理", status: "running" } });
+ await vi.waitFor(() => expect(toAgentChatMessages(runtime.chat.messages, {}, {}).at(-1)?.progress?.[0].status).toBe("running"));
+ if (status === "aborted") await runtime.chat.stop();
+ else {
+ if (status === "error") controller.enqueue({ type: "error", errorText: "工具执行失败" });
+ controller.enqueue({ type: "finish", finishReason: status === "error" ? "error" : "stop" });
+ controller.close();
+ }
+ await request;
+ const answer = toAgentChatMessages(runtime.chat.messages, {}, {}).at(-1);
+ expect(answer?.progress?.[0].status).toBe(status === "aborted" ? "cancelled" : status);
+ expect(runtime.runStatus).toBe(status);
+ if (status === "error") expect(answer?.runError).toBe("工具执行失败");
+ } finally { resources.dispose(); }
+ });
+
it("retries a disconnected subscription and preserves the draft, pages and reading position", async () => {
vi.useFakeTimers();
const resources = new WorkbenchSessionResources();
@@ -141,7 +172,9 @@ describe("independent conversation runtimes", () => {
});
resumeAgentRuntime(resources, runtime);
await vi.waitFor(() => expect(runtime.controller).toBeUndefined());
- expect(runtime.chat.messages).toEqual([message("a"), message("b", "new"), message("c")]);
+ expect(toAgentChatMessages(runtime.chat.messages, {}, {})).toMatchObject([
+ { id: "a", content: "a" }, { id: "b", content: "new" }, { id: "c", content: "c" }
+ ]);
expect(submit).toHaveBeenCalledWith("a", "action-a", expect.objectContaining({
status: "failed", error: expect.objectContaining({ code: "SESSION_NOT_ACTIVE" }),
}));
diff --git a/src/features/workbench/hooks/agent-session-runtime.ts b/src/features/workbench/hooks/agent-session-runtime.ts
index 3071c12..4c4a94e 100644
--- a/src/features/workbench/hooks/agent-session-runtime.ts
+++ b/src/features/workbench/hooks/agent-session-runtime.ts
@@ -91,7 +91,7 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
const active = () => resources.activeAgent === runtime;
const notify = () => queueMicrotask(() => resources.notify());
runtime.chat = new Chat({
- id: runtime.key, transport, messages: toAgentUiMessages(loaded?.messages ?? []),
+ id: runtime.key, transport, messages: toAgentUiMessages(loaded?.messages ?? [], loaded),
onData: (part) => {
if (resources.disposed) return;
const data = part as AgentDataPart;
@@ -99,6 +99,10 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
if (sessionId && runtime.sessionId && sessionId !== runtime.sessionId) return;
if (sessionId) runtime.sessionId = sessionId;
if (data.type === "data-interaction_request") runtime.runStatus = "waiting_input";
+ if (data.type === "data-run_status") {
+ const status = data.data.status;
+ if (status === "completed" || status === "error" || status === "aborted" || status === "waiting_input") runtime.runStatus = status;
+ }
if (data.type === "data-session_title") runtime.title = getDataString(data.data, "title") ?? runtime.title;
if (data.type === "data-credential_refresh_required") {
void refreshRuntimeCredential(runtime, data.data);
@@ -118,10 +122,16 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
if (resources.disposed) return;
runtime.loadedAt = Date.now();
runtime.updatedAt = Date.now();
- runtime.runStatus = isAbort ? "aborted" : isError
+ runtime.runStatus = isAbort || runtime.runStatus === "aborted" ? "aborted" : isError
? isPendingInteractionError(runtime.error) ? "waiting_input" : "error"
: runtime.runStatus === "waiting_input" ? "waiting_input" : "completed";
const recovering = isError && !isAbort && runtime.transportInterrupted && runtime.sessionId;
+ if (!recovering) {
+ runtime.chat.messages = applySessionStreamEvent(runtime.chat.messages, {
+ type: runtime.runStatus === "error" || runtime.runStatus === "aborted" ? "error" : "done",
+ data: { status: runtime.runStatus, reason: runtime.runStatus, ...(runtime.error ? { message: runtime.error } : {}) }
+ });
+ }
if (active()) { if (!recovering) runtime.handlers?.onFinish(runtime.runStatus); }
else if (!recovering) notifyBackgroundCompletion(runtime);
if (recovering) {
@@ -173,7 +183,7 @@ export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime
const sessionId = event.type === "state" ? event.sessionId : event.sessionId ?? getDataString(event.data, "session_id");
if (sessionId && sessionId !== runtime.sessionId) return;
if (event.type === "state") {
- const tail = toAgentUiMessages(event.messages);
+ const tail = toAgentUiMessages(event.messages, event);
const prior = runtime.chat.messages;
// Start filling a disconnected interval before reconciling the live tail.
if (prior.length && tail.length && !tail.some((message) => prior.some((old) => old.id === message.id))) {
@@ -191,11 +201,16 @@ export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime
if (event.type === "credential_refresh_required") void refreshRuntimeCredential(runtime, event.data);
else if (resources.activeAgent !== runtime && event.type === "frontend_action") void rejectBackgroundAction(resources, runtime, event.data);
runtime.chat.messages = applySessionStreamEvent(runtime.chat.messages, event);
+ if (event.type === "run_status") {
+ const status = event.data.status;
+ if (status === "aborted" || status === "error" || status === "completed" || status === "waiting_input") runtime.runStatus = status;
+ }
if (event.type === "session_title") runtime.title = getDataString(event.data, "title") ?? runtime.title;
- if (event.type === "done" || event.type === "error") {
+ if (event.type === "done" || event.type === "error" || event.type === "aborted") {
terminal = true;
runtime.resumed = false;
- runtime.runStatus = event.type === "error" ? "error" : event.data.reason === "waiting_input" ? "waiting_input" : "completed";
+ runtime.runStatus = event.type === "aborted" || event.data.status === "aborted" || runtime.runStatus === "aborted" ? "aborted"
+ : event.type === "error" ? "error" : event.data.reason === "waiting_input" ? "waiting_input" : "completed";
runtime.updatedAt = Date.now();
}
}
diff --git a/src/features/workbench/hooks/use-workbench-agent.ts b/src/features/workbench/hooks/use-workbench-agent.ts
index c20731a..336e127 100644
--- a/src/features/workbench/hooks/use-workbench-agent.ts
+++ b/src/features/workbench/hooks/use-workbench-agent.ts
@@ -1,3 +1,4 @@
+import { createAgentInteractionReplies } from "./agent-interaction-replies";
import { getWorkbenchAccessToken, getWorkbenchTokenProvider, getWorkbenchProject } from "../api/request-context";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useChat } from "@ai-sdk/react";
@@ -33,9 +34,6 @@ import {
type AgentEventTriggerContext,
type AgentModelOption,
type AgentInputDelivery,
- type AgentPermissionReply,
- type AgentPermissionRequest,
- type AgentQuestionRequest,
type AgentSessionStreamEvent,
type AgentSessionPurpose,
type AgentStreamRenderState,
@@ -52,7 +50,6 @@ import {
readBodyString,
toAgentChatMessages,
toAgentUiMessages,
- toPermissionStatus,
type AgentDataPart,
type AgentUiMessage,
type PermissionOverride,
@@ -200,7 +197,7 @@ export function useWorkbenchAgent({
try {
const loaded = await target.client.loadSession(owningSessionId, { messageLimit: 40 });
if (loaded && !resources.disposed && target.client === scopeClient) {
- target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages));
+ target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages, loaded));
resources.notify();
}
} catch { /* A failed reconciliation leaves the accepted answer intact. */ }
@@ -681,9 +678,9 @@ export function useWorkbenchAgent({
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setStatusLabel(event.data.reason === "waiting_input" ? "等待补充信息" : "Agent 分析完成");
runtimeRef.current!.loadedAt = Date.now();
- } else if (event.type === "error") {
+ } else if (event.type === "aborted" || event.type === "error") {
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
- setStatusLabel("Agent 后端请求失败");
+ setStatusLabel(event.type === "aborted" || event.data.status === "aborted" ? "Agent 已停止" : "Agent 后端请求失败");
runtimeRef.current!.loadedAt = Date.now();
}
@@ -732,7 +729,7 @@ export function useWorkbenchAgent({
if (generation !== loadingGenerationRef.current) return false;
if (!target) target = createAgentRuntime(resources, scopeKey, scopeClient, transport, loaded);
else {
- target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages));
+ target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages, loaded));
target.title = loaded.title; target.topicId = loaded.topicId; target.loadedAt = Date.now(); target.runStatus = loaded.runStatus;
target.error = undefined;
target.messagePage ??= loaded.messagePage;
@@ -827,7 +824,7 @@ export function useWorkbenchAgent({
);
} else {
target.client = projectClient;
- target.chat.messages = toAgentUiMessages(loaded.messages);
+ target.chat.messages = toAgentUiMessages(loaded.messages, loaded);
target.title = loaded.title;
target.purpose = "task_execution";
target.taskId = normalizedTaskId;
@@ -877,7 +874,7 @@ export function useWorkbenchAgent({
refreshing = false;
return;
}
- target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages));
+ target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages, loaded));
target.title = loaded.title;
target.updatedAt = loaded.updatedAt;
target.loadedAt = Date.now();
@@ -1045,7 +1042,7 @@ export function useWorkbenchAgent({
target.purpose = "task_collaboration";
target.taskId = binding.taskId;
target.taskExecutionHistory = undefined;
- target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages));
+ target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages, loaded));
target.messagePage = loaded.messagePage;
target.loadedAt = Date.now();
target.updatedAt = loaded.updatedAt;
@@ -1139,62 +1136,9 @@ export function useWorkbenchAgent({
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
}, [chat, interactionRequests, refreshPendingInputs, stopSessionStreamSubscription]);
- const replyPermission = useCallback(async (permission: AgentPermissionRequest, reply: AgentPermissionReply) => {
- const target = resources.activeAgent;
- if (!target || target.sessionId !== permission.sessionId) return;
- const update = (value: PermissionOverride) => {
- target.permissionOverrides = { ...target.permissionOverrides, [permission.requestId]: value };
- if (resources.activeAgent === target) setPermissionOverrides(target.permissionOverrides);
- resources.notify();
- };
- update({ status: "submitting" });
- try {
- await target.client.replyPermission(permission.requestId, { sessionId: permission.sessionId, reply });
- update({ status: toPermissionStatus(reply) });
- } catch (error) {
- const message = error instanceof Error ? error.message : "权限请求提交失败。";
- update({ status: "error", error: message });
- showMapNotice({ id: "agent-permission-status", tone: "error", title: "Agent 权限提交失败", message });
- }
- }, [resources]);
-
- const replyQuestion = useCallback(async (question: AgentQuestionRequest, answers: string[][]) => {
- const target = resources.activeAgent;
- if (!target || target.sessionId !== question.sessionId) return;
- const update = (value: QuestionOverride) => {
- target.questionOverrides = { ...target.questionOverrides, [question.requestId]: value };
- if (resources.activeAgent === target) setQuestionOverrides(target.questionOverrides);
- resources.notify();
- };
- update({ status: "submitting" });
- try {
- await target.client.replyQuestion(question.requestId, { sessionId: question.sessionId, answers });
- update({ status: "answered", answers });
- } catch (error) {
- const message = error instanceof Error ? error.message : "问题回复提交失败。";
- update({ status: "error", error: message });
- showMapNotice({ id: "agent-question-status", tone: "error", title: "Agent 问题提交失败", message });
- }
- }, [resources]);
-
- const rejectQuestion = useCallback(async (question: AgentQuestionRequest) => {
- const target = resources.activeAgent;
- if (!target || target.sessionId !== question.sessionId) return;
- const update = (value: QuestionOverride) => {
- target.questionOverrides = { ...target.questionOverrides, [question.requestId]: value };
- if (resources.activeAgent === target) setQuestionOverrides(target.questionOverrides);
- resources.notify();
- };
- update({ status: "submitting" });
- try {
- await target.client.rejectQuestion(question.requestId, { sessionId: question.sessionId });
- update({ status: "rejected" });
- } catch (error) {
- const message = error instanceof Error ? error.message : "问题跳过失败。";
- update({ status: "error", error: message });
- showMapNotice({ id: "agent-question-status", tone: "error", title: "Agent 问题提交失败", message });
- }
- }, [resources]);
+ const { replyPermission, replyQuestion, rejectQuestion } = useMemo(() => createAgentInteractionReplies(resources, {
+ permission: setPermissionOverrides, question: setQuestionOverrides
+ }), [resources]);
return {
apiClient: clientRef.current,
diff --git a/src/features/workbench/peripheral-sample/scada-monitoring-notice.tsx b/src/features/workbench/peripheral-sample/scada-monitoring-notice.tsx
index ec5c5d2..4b734a0 100644
--- a/src/features/workbench/peripheral-sample/scada-monitoring-notice.tsx
+++ b/src/features/workbench/peripheral-sample/scada-monitoring-notice.tsx
@@ -1,3 +1,4 @@
+import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowUpRight, CheckCircle2, CircleHelp, Clock3, LoaderCircle, TriangleAlert } from "lucide-react";
@@ -54,7 +55,7 @@ export function ScadaMonitoringNotice({ result, animationId, processing, onOpenI
调查监测
{result ? : 尚无监测结果}
diff --git a/src/features/workbench/peripheral-sample/scada-monitoring-rules.tsx b/src/features/workbench/peripheral-sample/scada-monitoring-rules.tsx
index f040a54..b7c6b01 100644
--- a/src/features/workbench/peripheral-sample/scada-monitoring-rules.tsx
+++ b/src/features/workbench/peripheral-sample/scada-monitoring-rules.tsx
@@ -1,3 +1,4 @@
+import { formatLocalMonthDayTime as dateTime } from "@/shared/utils/date-time";
import type { ScadaMonitoringCalibration, ScadaMonitoringConfig } from "../api/scada-replay-client";
export function ScadaMonitoringRules({ config, calibration, frozen }: {
@@ -68,7 +69,3 @@ function thresholdLabel(ids: string[], profiles: ScadaMonitoringCalibration["pre
const range = lower === upper ? `${direction}阈值 ${lower}%` : `${direction}阈值 ${lower}%~${upper}%(逐测点)`;
return thresholds.length < ids.length ? `${range} · 已就绪 ${thresholds.length}/${ids.length} 个测点` : range;
}
-
-function dateTime(value: string) {
- return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
-}
diff --git a/src/features/workbench/peripheral-sample/scada-replay-section.tsx b/src/features/workbench/peripheral-sample/scada-replay-section.tsx
index 2607109..d33d917 100644
--- a/src/features/workbench/peripheral-sample/scada-replay-section.tsx
+++ b/src/features/workbench/peripheral-sample/scada-replay-section.tsx
@@ -1,3 +1,4 @@
+import { formatLocalMonthDayTime as stamp } from "@/shared/utils/date-time";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Activity,
@@ -430,9 +431,6 @@ export function ScadaReplaySection({
);
}
-function stamp(value: string) {
- return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
-}
function localTime(time: number) {
const date = new Date(time);
diff --git a/src/features/workbench/utils/report-presentation-state.test.ts b/src/features/workbench/utils/report-presentation-state.test.ts
new file mode 100644
index 0000000..53cb3ec
--- /dev/null
+++ b/src/features/workbench/utils/report-presentation-state.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { getReportPresentationState, snapshotRound } from "./report-presentation-state";
+import type { OperationalTaskReport } from "../api/operational-task-client";
+
+const snapshot = (generation: number | null, revision = 1) => ({
+ revision, execution_generation: generation, report: { stage: "final", outcome: "insufficient_data" }
+}) as OperationalTaskReport;
+
+describe("report presentation state", () => {
+ it("keeps historical unknown generations distinct from current and previous rounds", () => {
+ expect(snapshotRound(null, 2)).toBe("historical");
+ expect(snapshotRound(undefined, 2)).toBe("historical");
+ expect(snapshotRound(1, 2)).toBe("previous");
+ expect(snapshotRound(2, 2)).toBe("current");
+ });
+ it("does not let a previous final report own the current round's progress or conclusion", () => {
+ const previous = snapshot(1);
+ const task = { execution_generation: 2, latest_report: previous, lifecycle_status: "completed" as const };
+ expect(getReportPresentationState(task, previous)).toMatchObject({ previousRound: true, currentFinalReport: false, currentInsufficient: false, ended: false });
+ expect(getReportPresentationState(task, snapshot(null))).toMatchObject({ round: "historical", previousRound: true, currentFinalReport: false });
+ expect(getReportPresentationState(task, null)).toMatchObject({ previousRound: false, current: false });
+ });
+ it("distinguishes an older revision within the current round and an ended insufficient result", () => {
+ const task = { execution_generation: 2, latest_report: snapshot(2, 3), lifecycle_status: "completed" as const };
+ expect(getReportPresentationState(task, snapshot(2, 1))).toMatchObject({ olderRevision: true, currentFinalReport: true, currentInsufficient: true, ended: true });
+ });
+});
diff --git a/src/features/workbench/utils/report-presentation-state.ts b/src/features/workbench/utils/report-presentation-state.ts
new file mode 100644
index 0000000..1920690
--- /dev/null
+++ b/src/features/workbench/utils/report-presentation-state.ts
@@ -0,0 +1,23 @@
+import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
+
+export function snapshotRound(generation: number | null | undefined, currentGeneration: number) {
+ return generation == null ? "historical" : generation === currentGeneration ? "current" : "previous";
+}
+
+export function getReportPresentationState(
+ task: Pick,
+ snapshot: OperationalTaskReport | null | undefined
+) {
+ const round = snapshotRound(snapshot?.execution_generation, task.execution_generation);
+ const current = round === "current";
+ const insufficient = snapshot?.report.outcome === "insufficient_data";
+ return {
+ round, current, insufficient,
+ previousRound: Boolean(snapshot && !current),
+ olderRevision: Boolean(snapshot && task.latest_report && snapshot.revision !== task.latest_report.revision),
+ currentFinalReport: current && snapshot?.report.stage === "final",
+ currentInsufficient: current && insufficient,
+ ended: current && task.lifecycle_status === "completed",
+ stopped: task.lifecycle_status === "failed" || task.lifecycle_status === "cancelled"
+ };
+}
diff --git a/tests/browser/agent-tool-terminal.e2e.ts b/tests/browser/agent-tool-terminal.e2e.ts
new file mode 100644
index 0000000..f0cc2e5
--- /dev/null
+++ b/tests/browser/agent-tool-terminal.e2e.ts
@@ -0,0 +1,69 @@
+import { expect, test } from "@playwright/test";
+import { createServer } from "node:http";
+import type { AddressInfo } from "node:net";
+import { mockAgentApi } from "./support/mock-agent-api";
+import { mockWorkbenchContext } from "./support/mock-workbench-context";
+
+for (const status of ["completed", "error", "aborted"] as const) {
+ test(`tool progress settles after reconnect and reopening (${status})`, async ({ page }, testInfo) => {
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await mockWorkbenchContext(page);
+ const sessionId = `tool-terminal-${status}`;
+ const title = `工具终态核查 ${status}`;
+ await mockAgentApi(page, { sessions: [{ session_id: sessionId, title, created_at: Date.now(), updated_at: Date.now() }] });
+ const headers = { "Access-Control-Allow-Origin": "*" };
+ const messages = [{ id: "tool-answer", role: "assistant", parts: [
+ { type: "text", text: "已查到部分压力记录。" },
+ { type: "data-progress", data: { id: "tool", phase: "tool", title: "正在处理", status: "running", tool: { name: "query_pressure", input: { node_id: 12 } } } },
+ ] }];
+ let settled = false;
+ let finish: (() => void) | undefined;
+ const server = createServer((_request, response) => {
+ response.writeHead(200, { ...headers, "Content-Type": "text/event-stream" });
+ response.write(`event: state\ndata: ${JSON.stringify({ session_id: sessionId, messages, is_streaming: true, run_status: "running" })}\n\n`);
+ finish = () => {
+ settled = true;
+ const type = status === "completed" ? "done" : status === "aborted" ? "aborted" : "error";
+ const data = { session_id: sessionId, status, message: status === "error" ? "压力查询连接失败" : "请求已中断" };
+ // Legacy subscriptions may terminate without a separate run_status event.
+ response.end(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
+ };
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ await page.route(`**/api/v1/session/${sessionId}?**`, (route) => route.fulfill({ headers, json: {
+ session_id: sessionId, title, purpose: "ordinary_chat", status: "active",
+ is_streaming: !settled, run_status: settled ? status : "running", messages,
+ } }));
+ await page.route(`**/api/v1/session/${sessionId}/stream**`, (route) => route.continue({ url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }));
+ const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
+ const answer = panel.locator('[data-agent-message-id="tool-answer"]');
+ const icon = answer.locator('[data-agent-tool] [data-progress-status]');
+ const open = async () => {
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
+ await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click();
+ await page.locator("button.agent-history-session-trigger").filter({ hasText: title }).click();
+ };
+ try {
+ await open();
+ await expect(icon).toHaveAttribute("data-progress-status", "running");
+ await expect.poll(() => Boolean(finish)).toBe(true);
+ finish!();
+ await answer.getByRole("button", { name: "查看处理过程", exact: true }).click();
+ const expectedStatus = status === "aborted" ? "cancelled" : status;
+ await expect(icon).toHaveAttribute("data-progress-status", expectedStatus);
+ await expect(answer.locator('[data-progress-status="running"]')).toHaveCount(0);
+ if (status === "error") await expect(answer.getByRole("status").filter({ hasText: "压力查询连接失败" })).toBeVisible();
+ await answer.screenshot({ path: testInfo.outputPath(`${status}-settled.png`), animations: "disabled" });
+ await open();
+ await answer.getByRole("button", { name: "查看处理过程", exact: true }).click();
+ await expect(icon).toHaveAttribute("data-progress-status", expectedStatus);
+ await expect(answer.locator('[data-progress-status="running"]')).toHaveCount(0);
+ if (status === "aborted") await expect(icon).toHaveAttribute("aria-label", "已中止");
+ await answer.screenshot({ path: testInfo.outputPath(`${status}-reopened.png`), animations: "disabled" });
+ } finally {
+ server.closeAllConnections();
+ await new Promise((resolve) => server.close(() => resolve()));
+ }
+ });
+}