From dbebd18dd7d4d58de28f49f36002e13c515dd3fa Mon Sep 17 00:00:00 2001 From: PeaceMaker-best Date: Mon, 19 Oct 2026 10:55:50 +0800 Subject: [PATCH] fix(deer-flow): retry failed agent setup saves (#6628) * fix(deer-flow): retry failed agent setup saves Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> * fix(frontend): fence superseded agent save callbacks --------- Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> Co-authored-by: Willem Jiang --- README.md | 6 + frontend/AGENTS.md | 4 + frontend/src/AGENTS.md | 2 + .../src/app/workspace/agents/new/page.tsx | 37 +- frontend/src/core/i18n/locales/en-US.ts | 4 +- frontend/src/core/i18n/locales/types.ts | 1 + frontend/src/core/i18n/locales/zh-CN.ts | 3 +- frontend/src/core/threads/hooks.ts | 51 ++- .../unit/app/new-agent-save.dom.test.tsx | 341 ++++++++++++++++++ .../unit/core/threads/tool-result.test.ts | 65 +++- 10 files changed, 497 insertions(+), 17 deletions(-) create mode 100644 frontend/tests/unit/app/new-agent-save.dom.test.tsx diff --git a/README.md b/README.md index 4e5c549ac8..8912475f3c 100644 --- a/README.md +++ b/README.md @@ -2247,6 +2247,12 @@ the stable English `name`. API callers can pass `display_name` to agent creation or update requests; an omitted update preserves it and `null` clears it. The same optional field is supported in the agent's `config.yaml`. +The new-Agent page confirms creation only after the latest `setup_agent` call +succeeds and the persisted Agent can be read back. A tool failure or an +unconfirmed write keeps the draft visible and restores **Save agent** so the +operation can be retried instead of reporting a false success. Read-back results +from a superseded Save attempt cannot change a newer attempt or its feedback. + Saving an unrelated Agent setting preserves its existing thinking and reasoning defaults, including while the model catalog is loading, unavailable, or missing the configured model. Explicit reasoning edits are saved independently; choosing diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index eb56b81d79..f3c831c306 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -156,6 +156,10 @@ previewing the proposed local name; the Gateway is authoritative for schema, name, model, and conflict validation. Export downloads must never synthesize runtime state from browser caches. +New-Agent saves need the latest `setup_agent` success and API read-back. +Failed/exhausted saves restore Save. Fence superseded or unmounted callbacks. +DOM regression: `new-agent-save.dom.test.tsx`. + - **Imports**: Enforced ordering (builtin → external → internal → parent → sibling), alphabetized, newlines between groups. Use inline type imports: `import { type Foo }`. - **Unused variables**: Prefix with `_`. - **Class names**: Use `cn()` from `@/lib/utils` for conditional Tailwind classes. diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index e413e67dbd..0770083fae 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -116,6 +116,8 @@ Tests: `html-assets.dom.test.ts`, `artifact-file-preview.dom.test.tsx`, prompt/error, never permission to overwrite the existing Agent. Import errors join FastAPI 422 validation messages into readable text while preserving string conflict details; unusable error bodies use a status fallback. + New-Agent saves require the latest `setup_agent` success and read-back. + ToolMessage/`deerflow_tool_meta.status` errors or exhausted retries restore Save. 6. Components subscribe to thread state and render updates AI message grouping uses `extractContentFromMessage()` to identify visible answer content. A non-empty content array may contain only Anthropic thinking blocks; keep it in `assistant:processing` until answer content arrives. Cover both streamed snapshots in `tests/unit/core/messages/utils.test.ts`. diff --git a/frontend/src/app/workspace/agents/new/page.tsx b/frontend/src/app/workspace/agents/new/page.tsx index 7f2d4ee4cd..aae536c6be 100644 --- a/frontend/src/app/workspace/agents/new/page.tsx +++ b/frontend/src/app/workspace/agents/new/page.tsx @@ -9,7 +9,7 @@ import { SaveIcon, } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { @@ -44,7 +44,7 @@ import { type HumanInputResponse, } from "@/core/messages/human-input"; import { safeLocalStorage } from "@/core/settings/local"; -import { hasToolResult, useThreadStream } from "@/core/threads/hooks"; +import { getToolResultStatus, useThreadStream } from "@/core/threads/hooks"; import { uuid } from "@/core/utils/uuid"; import { isIMEComposing } from "@/lib/ime"; import { cn } from "@/lib/utils"; @@ -90,6 +90,15 @@ export default function NewAgentPage() { const [setupAgentStatus, setSetupAgentStatus] = useState("idle"); + // Each Save or later setup result supersedes earlier read-back callbacks. + const saveAttempt = useRef(0); + useEffect( + () => () => { + saveAttempt.current += 1; + }, + [], + ); + const threadId = useMemo(() => uuid(), []); const { thread, sendMessage } = useThreadStream({ @@ -102,17 +111,28 @@ export default function NewAgentPage() { if (agent || setupAgentStatus !== "requested") { return; } - if (!agentName || !hasToolResult(state.messages, "setup_agent")) { + const attempt = ++saveAttempt.current; + const resultStatus = getToolResultStatus(state.messages, "setup_agent"); + if (!agentName || resultStatus === "pending") { setSetupAgentStatus("idle"); return; } - setSetupAgentStatus("completed"); + if (resultStatus === "error") { + setSetupAgentStatus("idle"); + toast.error(t.agents.agentSaveFailed); + return; + } void getAgentWithRetry(agentName).then((fetched) => { + if (saveAttempt.current !== attempt) { + return; + } if (fetched) { + setSetupAgentStatus("completed"); setAgent(fetched); return; } + setSetupAgentStatus("idle"); toast.error(t.agents.agentCreatedPendingRefresh); }); }, @@ -263,6 +283,7 @@ export default function NewAgentPage() { return; } + const attempt = ++saveAttempt.current; setSetupAgentStatus("requested"); setShowSaveHint(false); try { @@ -272,8 +293,14 @@ export default function NewAgentPage() { { agent_name: agentName }, { additionalKwargs: { hide_from_ui: true } }, ); - toast.success(t.agents.saveRequested); + if (saveAttempt.current === attempt) { + toast.success(t.agents.saveRequested); + } } catch (error) { + if (saveAttempt.current !== attempt) { + return; + } + saveAttempt.current += 1; setSetupAgentStatus("idle"); toast.error(error instanceof Error ? error.message : String(error)); } diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index c9b22d4d11..3cd93da111 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -1410,8 +1410,10 @@ export const enUS: Translations = { "You can save this agent at any time from the top-right menu, even if this is only a first draft.", saveCommandMessage: "Please save this custom agent now based on everything we have discussed so far. Treat this as my explicit confirmation to save. If some details are still missing, make reasonable assumptions, generate a concise first SOUL.md in English, and call setup_agent immediately without asking me for more confirmation.", + agentSaveFailed: + "DeerFlow could not save this agent. Review the tool error and try again.", agentCreatedPendingRefresh: - "The agent was created, but DeerFlow could not load it yet. Please refresh this page in a moment.", + "DeerFlow could not confirm the saved agent. Please try saving it again.", more: "More actions", agentCreated: "Agent created!", startChatting: "Start chatting", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 0f2d6ca14a..667dade134 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -1147,6 +1147,7 @@ export interface Translations { saveRequested: string; saveHint: string; saveCommandMessage: string; + agentSaveFailed: string; agentCreatedPendingRefresh: string; more: string; agentCreated: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 28231d164b..e31e23a1a1 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -1321,8 +1321,9 @@ export const zhCN: Translations = { "你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。", saveCommandMessage: "请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。", + agentSaveFailed: "DeerFlow 未能保存这个智能体。请查看工具错误后重试。", agentCreatedPendingRefresh: - "智能体已创建,但 DeerFlow 暂时还无法读取到它。请稍后刷新当前页面。", + "DeerFlow 未能确认智能体已保存,请重新尝试保存。", more: "更多操作", agentCreated: "智能体已创建!", startChatting: "开始对话", diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index fa6177a02e..56b91d7f31 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -154,25 +154,58 @@ export type PendingPreparedReplayMask = { replacementHumanMessageId?: string; }; -export function hasToolResult(messages: Message[], toolName: string): boolean { - const matchingToolCallIds = new Set(); +export type ToolResultStatus = "pending" | "success" | "error"; + +function isErrorToolMessage(message: Message): boolean { + if (message.type !== "tool") { + return false; + } + if (message.status === "error") { + return true; + } + const metadata = message.additional_kwargs?.deerflow_tool_meta; + return ( + typeof metadata === "object" && + metadata !== null && + "status" in metadata && + metadata.status === "error" + ); +} + +export function getToolResultStatus( + messages: Message[], + toolName: string, +): ToolResultStatus { + let latestToolCallId: string | undefined; for (const message of messages) { if (message.type !== "ai") { continue; } for (const toolCall of message.tool_calls ?? []) { if (toolCall.name === toolName && toolCall.id) { - matchingToolCallIds.add(toolCall.id); + latestToolCallId = toolCall.id; } } } - return messages.some( - (message) => - message.type === "tool" && - (message.name === toolName || - matchingToolCallIds.has(message.tool_call_id)), - ); + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.type !== "tool") { + continue; + } + const matches = latestToolCallId + ? message.tool_call_id === latestToolCallId + : message.name === toolName; + if (matches) { + return isErrorToolMessage(message) ? "error" : "success"; + } + } + + return "pending"; +} + +export function hasToolResult(messages: Message[], toolName: string): boolean { + return getToolResultStatus(messages, toolName) !== "pending"; } /** diff --git a/frontend/tests/unit/app/new-agent-save.dom.test.tsx b/frontend/tests/unit/app/new-agent-save.dom.test.tsx new file mode 100644 index 0000000000..6006dcb058 --- /dev/null +++ b/frontend/tests/unit/app/new-agent-save.dom.test.tsx @@ -0,0 +1,341 @@ +import type { Message } from "@langchain/langgraph-sdk"; +import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import type { ButtonHTMLAttributes, PropsWithChildren } from "react"; + +import NewAgentPage from "@/app/workspace/agents/new/page"; +import type { Agent } from "@/core/agents"; +import { I18nContext } from "@/core/i18n/context"; +import { enUS } from "@/core/i18n/locales/en-US"; + +const mocks = rs.hoisted(() => ({ + getAgent: rs.fn(), + checkAgentName: rs.fn(), + sendMessage: rs.fn(), + toastError: rs.fn(), + toastSuccess: rs.fn(), + onFinish: undefined as ((state: { messages: Message[] }) => void) | undefined, +})); + +rs.mock("next/navigation", () => ({ useRouter: () => ({ push: rs.fn() }) })); +rs.mock("sonner", () => ({ + toast: { error: mocks.toastError, success: mocks.toastSuccess }, +})); +rs.mock("@/core/agents/api", () => ({ + ...rs.requireActual("@/core/agents/api"), + getAgent: mocks.getAgent, + checkAgentName: mocks.checkAgentName, +})); +rs.mock("@/core/threads/hooks", () => ({ + ...rs.requireActual("@/core/threads/hooks"), + useThreadStream: (options: { onFinish: typeof mocks.onFinish }) => { + mocks.onFinish = options.onFinish; + return { + thread: { isLoading: false, messages: [], values: {} }, + sendMessage: mocks.sendMessage, + }; + }, +})); +rs.mock("@/components/workspace/artifacts", () => ({ + ArtifactsProvider: ({ children }: PropsWithChildren) => <>{children}, +})); +rs.mock("@/components/workspace/messages", () => ({ MessageList: () => null })); +// Keep Save interactions accessible without depending on menu positioning. +rs.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: PropsWithChildren) => <>{children}, + DropdownMenuContent: ({ children }: PropsWithChildren) => <>{children}, + DropdownMenuTrigger: ({ children }: PropsWithChildren) => <>{children}, + DropdownMenuItem: ({ + children, + disabled, + onSelect, + }: PropsWithChildren<{ + disabled?: boolean; + onSelect: () => void; + }>) => ( + + ), +})); +rs.mock("@/components/ai-elements/prompt-input", () => ({ + PromptInput: ({ + children, + onSubmit, + }: PropsWithChildren<{ onSubmit: (input: { text: string }) => void }>) => ( +
{ + event.preventDefault(); + onSubmit({ text: "Please adjust the agent" }); + }} + > + {children} +
+ ), + PromptInputTextarea: () =>