From fc7c7e012c444b9b8b68cbb3ea6f64ef62ed8fc8 Mon Sep 17 00:00:00 2001 From: SuKoYa07 Date: Wed, 11 Nov 2026 21:00:33 +0800 Subject: [PATCH] fix(frontend): restore handed-off artifacts from thread history (#8080) * fix(frontend): restore handed-off artifacts from thread history * fix(frontend): reject duplicate artifact handoff receipts * docs(frontend): fit artifact guidance within budget * fix(frontend): accept consistent retried artifact handoffs --------- Co-authored-by: lovesky514514-cmd <273698517+lovesky514514-cmd@users.noreply.github.com> Co-authored-by: Willem Jiang --- CHANGELOG.md | 3 + docs/guide/sandbox.md | 3 + frontend/src/AGENTS.md | 2 + .../components/workspace/chats/chat-box.tsx | 3 +- frontend/src/core/artifacts/utils.ts | 115 ++++++++++- .../tests/unit/core/artifacts/utils.test.ts | 188 ++++++++++++++++++ 6 files changed, 304 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d6da344c0..901340c19a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,9 @@ This section accumulates work toward the **2.3.0** milestone database error. The 409 points callers at `PUT` on the same route, which updates the existing feedback. ([#8064]) +- **frontend:** Reopening a conversation restores successfully handed-off shared + artifacts in the Artifacts sidebar. ([#8080](https://github.com/bytedance/deer-flow/pull/8080)) + - **sandbox:** AIO commands from independent subagents now run concurrently when they carry per-call environment variables, including Lark CLI commands. Commands within one subagent remain ordered and client cleanup drains them. diff --git a/docs/guide/sandbox.md b/docs/guide/sandbox.md index dea7e2fbfa..0990cf14cf 100644 --- a/docs/guide/sandbox.md +++ b/docs/guide/sandbox.md @@ -363,6 +363,9 @@ This remains a best-effort scan, not an atomic filesystem snapshot. `present_shared_artifact` publishes another conversation's persisted output using `origin_thread_id + path + sha256`. Same-user, runtime-home-confined reads and writes reject path traversal, symlinks and changed content; an existing destination with different content is retained, while identical concurrent handoffs succeed. Successful handoffs count as run delivery and archive receipts. The current POSIX implementation does not acquire a source sandbox or cross user boundaries. +When you reopen the destination conversation, the Artifacts sidebar restores +files from confirmed handoffs. + Files presented through `present_files` remain part of the thread's artifact state, and the Web UI restores the artifact panel and selected document after a page refresh. Long artifact lists scroll within the panel on desktop and in the mobile drawer, keeping every file reachable. When a completed response successfully presents between 2 and 50 files, its final file card also offers one ZIP download. Archive membership comes from the terminal delivery receipt rather than browser-supplied paths, and the ZIP contains the current file versions, which may have changed since the response. The currently selected formal artifact is refreshed once when the run finishes so edits become visible without a manual reload. Existing UTF-8 text artifacts under `/mnt/user-data/outputs` can also be edited and explicitly saved from the panel on Unix and Windows while the thread is idle; saves use content revisions to prevent overwriting agent changes. A delayed background read cannot replace a successfully saved revision in the editor; cancelled preview and full-file reads also abort their HTTP requests. Source previews also recognize extensionless `Dockerfile` and `Makefile` artifacts by their file names. Unknown file types, including names such as `constructor` and `__proto__`, retain the download fallback. Unsaved artifact edits survive switching chats, workspace pages, or visiting the diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 53883b12f4..5d68d08fd0 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -60,6 +60,8 @@ pnpm test:e2e # Playwright, for anything that depends on a re - **Server state is authoritative.** `ThreadState.artifacts` is the artifact list. Session storage keeps panel UI state only, and an empty first stream value must not overwrite restored state before history loads. +- Recover `present_shared_artifact` paths from history only when all matching + receipts confirm the same successful handoff. - **A truncated preview is never an editing baseline.** Its ETag can equal the full file's. Editing and saving need the complete content. - **Account preferences are scoped to the account.** A late response for a diff --git a/frontend/src/components/workspace/chats/chat-box.tsx b/frontend/src/components/workspace/chats/chat-box.tsx index cdfd6749e6..2d70bcdcb6 100644 --- a/frontend/src/components/workspace/chats/chat-box.tsx +++ b/frontend/src/components/workspace/chats/chat-box.tsx @@ -142,8 +142,7 @@ const ChatBoxContent: React.FC = ({ const browserViewOpen = browserEnabled && (browserView?.open ?? false); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); - // A reopened thread's values carry no `artifacts` (see - // collectThreadArtifactPaths); recover them from its present_files calls. + // A reopened thread can recover artifacts from its tool history. const stateArtifacts = thread.values.artifacts; const threadArtifactPaths = useMemo( () => diff --git a/frontend/src/core/artifacts/utils.ts b/frontend/src/core/artifacts/utils.ts index 977e9e7584..97fef75486 100644 --- a/frontend/src/core/artifacts/utils.ts +++ b/frontend/src/core/artifacts/utils.ts @@ -1,11 +1,13 @@ -import type { Message } from "@langchain/langgraph-sdk"; +import type { AIMessage, Message, ToolMessage } from "@langchain/langgraph-sdk"; import { getBackendBaseURL } from "../config"; +import { indexToolResults } from "../messages/tool-result"; import { extractPresentFilesFromMessage } from "../messages/utils"; import { isStaticWebsiteOnly } from "../static-mode"; import type { AgentThreadState } from "../threads"; const EMPTY_ARTIFACT_PATHS: readonly string[] = []; +const SHARED_ARTIFACT_TOOL = "present_shared_artifact"; export function areStringArraysEqual( previous: readonly string[], @@ -104,20 +106,117 @@ export function extractArtifactsFromThread(thread: { return thread.values.artifacts ?? EMPTY_ARTIFACT_PATHS; } +function normalizeSharedArtifactPath(path: string) { + if ( + path.includes("\\") || + [...path].some((char) => char.charCodeAt(0) < 32) + ) { + return; + } + + // Match the handoff's POSIX path normalization. / 与交接工具的 POSIX 路径规范化一致。 + const segments: string[] = []; + for (const segment of path.split("/")) { + if (!segment || segment === ".") continue; + if (segment === "..") { + if (segments.pop() === undefined) return; + } else { + segments.push(segment); + } + } + + const normalized = `/${segments.join("/")}`; + return normalized.startsWith("/mnt/user-data/outputs/") + ? normalized + : undefined; +} + +function extractSharedArtifactPaths( + message: AIMessage, + results: readonly ToolMessage[], +) { + const paths: string[] = []; + const calls = message.tool_calls ?? []; + for (const { id, name, args } of calls) { + if ( + name !== SHARED_ARTIFACT_TOOL || + !id || + calls.filter((call) => call.id === id).length !== 1 + ) { + continue; + } + const { path, origin_thread_id, sha256 } = args ?? {}; + if ( + typeof path !== "string" || + typeof origin_thread_id !== "string" || + !origin_thread_id || + typeof sha256 !== "string" || + sha256.length !== 64 || + /[^0-9a-f]/.test(sha256) + ) { + continue; + } + + // Match backend/packages/harness/deerflow/tools/artifact_handoff.py and + // backend/tests/test_shared_artifact_handoff.py exactly. + // Failures can also have a success status. / 失败回执也可能标为 success。 + const receipt = `Successfully presented shared artifact; origin_thread_id=${origin_thread_id}; sha256=${sha256}`; + const matching = results.filter((result) => result.tool_call_id === id); + if ( + matching.length === 0 || + matching.some((result) => { + const metadata = result.additional_kwargs?.deerflow_tool_meta; + return ( + (result.status !== undefined && result.status !== "success") || + (metadata !== undefined && + (typeof metadata !== "object" || + metadata === null || + !("status" in metadata) || + metadata.status !== "success")) || + (result.name != null && result.name !== SHARED_ARTIFACT_TOOL) || + result.content !== receipt + ); + }) + ) { + continue; + } + + const normalized = normalizeSharedArtifactPath(path); + if (normalized) paths.push(normalized); + } + return paths; +} + /** - * Adds the `/mnt/` paths passed to `present_files` in `messages` to the - * thread's artifact list. A reopened thread rebuilds its values from the - * Gateway's POST /history head, which carries messages but not `artifacts`; - * the backend fills `artifacts` from those same `present_files` calls, so the - * messages are enough to recover the list. Returns `stateArtifacts` itself - * when no message presents files. + * Recovers artifact paths from history when the Gateway omits `artifacts`. + * `present_files` contributes its paths directly. A shared artifact also needs + * a confirmed handoff receipt. Returns `stateArtifacts` itself if neither + * tool contributes paths. */ export function collectThreadArtifactPaths( stateArtifacts: readonly string[], messages: readonly Message[], ): readonly string[] { + let toolResults: ReturnType> | undefined; const presented = messages - .flatMap((message) => extractPresentFilesFromMessage(message)) + .flatMap((message, index) => { + const paths = extractPresentFilesFromMessage(message); + if ( + message.type !== "ai" || + !message.tool_calls?.some((call) => call.name === SHARED_ARTIFACT_TOOL) + ) { + return paths; + } + const results = (toolResults ??= indexToolResults(messages)).get(index); + if (!results) return paths; + return [ + ...paths, + ...extractSharedArtifactPaths( + message, + results.filter((result) => result.type === "tool"), + ), + ]; + }) .filter( (path): path is string => typeof path === "string" && path.startsWith("/mnt/"), diff --git a/frontend/tests/unit/core/artifacts/utils.test.ts b/frontend/tests/unit/core/artifacts/utils.test.ts index 9ada69b84e..9285083d28 100644 --- a/frontend/tests/unit/core/artifacts/utils.test.ts +++ b/frontend/tests/unit/core/artifacts/utils.test.ts @@ -1,3 +1,4 @@ +import type { AIMessage, Message, ToolMessage } from "@langchain/langgraph-sdk"; import { afterEach, beforeEach, @@ -452,6 +453,193 @@ describe("artifact URL helpers", () => { expect(collectThreadArtifactPaths(state, [messages[0]!])).toBe(state); }); + describe("shared artifacts in thread history", () => { + const origin = "source-thread"; + const sha256 = "a".repeat(64); + const outputs = "/mnt/user-data/outputs"; + const receipt = `Successfully presented shared artifact; origin_thread_id=${origin}; sha256=${sha256}`; + + function handoff(id: string, path: string) { + return { + id, + name: "present_shared_artifact", + args: { path, origin_thread_id: origin, sha256 }, + }; + } + + function result( + toolCallId: string, + fields: Partial = {}, + ): ToolMessage { + return { + type: "tool", + tool_call_id: toolCallId, + content: receipt, + ...fields, + }; + } + + test("restores only handoffs with their own confirmed receipt", async () => { + const { collectThreadArtifactPaths } = await loadFreshArtifactUtils(); + const delivered = `${outputs}/lesson.pptx`; + const messages: Message[] = [ + { + type: "ai", + content: "", + tool_calls: [ + handoff("delivered", delivered), + handoff("failed", `${outputs}/failed.pptx`), + handoff("pending", `${outputs}/pending.pptx`), + handoff("wrong-hash", `${outputs}/wrong-hash.pptx`), + handoff("error-status", `${outputs}/error-status.pptx`), + handoff("ambiguous", `${outputs}/ambiguous.pptx`), + handoff("ambiguous", `${outputs}/ambiguous-other.pptx`), + ], + }, + // History can serialize unnamed results with null. / 历史回执的名称可能为 null。 + { ...result("delivered"), name: null } as unknown as ToolMessage, + result("failed", { content: "Error: Source artifact changed" }), + result("unrelated"), + result("wrong-hash", { + content: receipt.replace(sha256, "b".repeat(64)), + }), + result("error-status", { status: "error" }), + result("ambiguous"), + ]; + + expect(collectThreadArtifactPaths([], messages)).toEqual([delivered]); + expect(collectThreadArtifactPaths([delivered], messages)).toEqual([ + delivered, + ]); + }); + + test("restores a handoff once with identical success receipts", async () => { + const { collectThreadArtifactPaths } = await loadFreshArtifactUtils(); + const delivered = `${outputs}/report.md`; + const success = result("duplicate", { + id: "receipt-1", + name: "present_shared_artifact", + status: "success", + additional_kwargs: { deerflow_tool_meta: { status: "success" } }, + }); + const messages: Message[] = [ + { + type: "ai", + content: "", + tool_calls: [handoff("duplicate", delivered)], + }, + success, + { ...success, id: "receipt-2" }, + ]; + + expect(collectThreadArtifactPaths([], messages)).toEqual([delivered]); + }); + + test.each([ + ["an explicit error", { status: "error" }], + [ + "different receipt text", + { content: receipt.replace(origin, "other-thread") }, + ], + ["a different tool name", { name: "present_files" }], + [ + "an unknown status", + { status: "pending" } as unknown as Partial, + ], + [ + "error metadata", + { additional_kwargs: { deerflow_tool_meta: { status: "error" } } }, + ], + [ + "partial success metadata", + { + additional_kwargs: { + deerflow_tool_meta: { status: "partial_success" }, + }, + }, + ], + ["malformed metadata", { additional_kwargs: { deerflow_tool_meta: {} } }], + ] satisfies [string, Partial][])( + "skips a handoff when a matching receipt has %s", + async (_reason, fields) => { + const { collectThreadArtifactPaths } = await loadFreshArtifactUtils(); + const messages: Message[] = [ + { + type: "ai", + content: "", + tool_calls: [handoff("conflicting", `${outputs}/report.md`)], + }, + result("conflicting", { id: "receipt-1", status: "success" }), + result("conflicting", { + id: "receipt-2", + status: "success", + ...fields, + }), + ]; + + expect(collectThreadArtifactPaths([], messages)).toEqual([]); + }, + ); + + test("skips a handoff without a matching receipt", async () => { + const { collectThreadArtifactPaths } = await loadFreshArtifactUtils(); + const messages: Message[] = [ + { + type: "ai", + content: "", + tool_calls: [handoff("pending", `${outputs}/pending.md`)], + }, + result("unrelated"), + ]; + + expect(collectThreadArtifactPaths([], messages)).toEqual([]); + }); + + test("keeps reused call IDs scoped to their assistant occurrence", async () => { + const { collectThreadArtifactPaths } = await loadFreshArtifactUtils(); + const delivered = `${outputs}/first.md`; + const first: AIMessage = { + type: "ai", + content: "", + tool_calls: [handoff("reused", delivered)], + }; + const second: AIMessage = { + type: "ai", + content: "", + tool_calls: [handoff("reused", `${outputs}/second.md`)], + }; + const messages = [ + first, + result("reused", { id: "first-receipt" }), + result("reused", { id: "retried-receipt" }), + second, + result("reused", { content: "Error: Destination already exists" }), + ]; + + expect(collectThreadArtifactPaths([], messages)).toEqual([delivered]); + }); + + test("restores the canonical output path and rejects escapes", async () => { + const { collectThreadArtifactPaths } = await loadFreshArtifactUtils(); + const messages: Message[] = [ + { + type: "ai", + content: "", + tool_calls: [ + handoff("canonicalized", `${outputs}//notes/../report%20one.md`), + handoff("escape", `${outputs}/../outside.md`), + ], + }, + result("canonicalized"), + result("escape"), + ]; + + expect(collectThreadArtifactPaths([], messages)).toEqual([ + `${outputs}/report%20one.md`, + ]); + }); + }); + test("builds encoded write-file URLs without undefined query parameters", async () => { const { buildWriteFileArtifactURL } = await loadFreshArtifactUtils(); const filepath = "/mnt/user-data/outputs/a b#c?%20.md";