refactor: consolidate requests and finalize agent tool states

Share project JSON transport, interaction replies and report selectors.
Settle tool progress consistently for direct streams, reconnects and history, preserving failure reasons and stopped state.
This commit is contained in:
2026-11-09 17:55:00 +08:00
parent ad1f7ca7a9
commit 692bbf609c
38 changed files with 605 additions and 280 deletions
@@ -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<void>((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);
});
@@ -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<string, PermissionOverride>) => void;
question: (value: Record<string, QuestionOverride>) => 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 <T extends PermissionOverride | QuestionOverride>(
request: { sessionId: string; requestId: string },
update: (target: AgentSessionRuntime, id: string, value: T | { status: "submitting" } | { status: "error"; error: string }) => void,
execute: (target: AgentSessionRuntime) => Promise<unknown>,
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<PermissionOverride>(
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<QuestionOverride>(
question, questionUpdate,
(target) => target.client.replyQuestion(question.requestId, { sessionId: question.sessionId, answers }),
{ status: "answered", answers }, questionFailure
),
rejectQuestion: (question: AgentQuestionRequest) => submit<QuestionOverride>(
question, questionUpdate,
(target) => target.client.rejectQuestion(question.requestId, { sessionId: question.sessionId }),
{ status: "rejected" }, { ...questionFailure, message: "问题跳过失败。" }
)
};
}
@@ -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]);
});
});
@@ -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<string, unknown>;
execution_plan: Record<string, unknown>;
progress: Record<string, unknown>;
run_status: Record<string, unknown>;
final_answer: Record<string, unknown>;
credential_refresh_required: Record<string, unknown>;
credential_refreshed: Record<string, unknown>;
@@ -137,11 +143,14 @@ export function toAgentChatMessages(
...(answerParts ? { answerParts } : {})
};
let runState: Record<string, unknown> | 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,
@@ -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<UIMessageChunk>;
const runtime = createAgentRuntime(resources, "project:a", {} as AgentApiClient, {
sendMessages: async ({ abortSignal }) => new ReadableStream<UIMessageChunk>({ 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" }),
}));
@@ -91,7 +91,7 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
const active = () => resources.activeAgent === runtime;
const notify = () => queueMicrotask(() => resources.notify());
runtime.chat = new Chat<AgentUiMessage>({
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();
}
}
@@ -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,