diff --git a/backend/packages/harness/deerflow/agents/middlewares/artifact_reconcile_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/artifact_reconcile_middleware.py index 1e6b1e91..1e7d3a5a 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/artifact_reconcile_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/artifact_reconcile_middleware.py @@ -87,6 +87,9 @@ class ArtifactReconcileMiddleware(AgentMiddleware[ArtifactReconcileState]): if not isinstance(artifact, str): changed = True continue + if artifact == ARTIFACTS_REPLACE_SENTINEL: + changed = True + continue actual_path = self._to_outputs_file(artifact, outputs_dir) if actual_path is None: diff --git a/backend/packages/harness/deerflow/agents/thread_state.py b/backend/packages/harness/deerflow/agents/thread_state.py index 004cb8dc..7d4e4c1d 100644 --- a/backend/packages/harness/deerflow/agents/thread_state.py +++ b/backend/packages/harness/deerflow/agents/thread_state.py @@ -22,14 +22,22 @@ class ViewedImageData(TypedDict): def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]: """Reducer for artifacts list - merges and deduplicates artifacts.""" + def _clean(values: list[str] | None) -> list[str]: + if not values: + return [] + return [v for v in values if isinstance(v, str) and v != ARTIFACTS_REPLACE_SENTINEL] + + cleaned_existing = _clean(existing) + cleaned_new = _clean(new) + if new and new[0] == ARTIFACTS_REPLACE_SENTINEL: - return list(dict.fromkeys(new[1:])) + return list(dict.fromkeys(cleaned_new)) if existing is None: - return new or [] + return cleaned_new if new is None: - return existing + return cleaned_existing # Use dict.fromkeys to deduplicate while preserving order - return list(dict.fromkeys(existing + new)) + return list(dict.fromkeys(cleaned_existing + cleaned_new)) def merge_viewed_images(existing: dict[str, ViewedImageData] | None, new: dict[str, ViewedImageData] | None) -> dict[str, ViewedImageData]: diff --git a/backend/tests/test_artifact_reconcile_middleware.py b/backend/tests/test_artifact_reconcile_middleware.py index ef54f896..128b92d0 100644 --- a/backend/tests/test_artifact_reconcile_middleware.py +++ b/backend/tests/test_artifact_reconcile_middleware.py @@ -87,3 +87,25 @@ def test_before_model_discovers_outputs_when_artifacts_empty(tmp_path): assert result == { "artifacts": [ARTIFACTS_REPLACE_SENTINEL, "/mnt/user-data/outputs/report.md"] } + + +def test_before_model_drops_leaked_replace_sentinel(tmp_path): + outputs_dir = tmp_path / "outputs" + outputs_dir.mkdir() + keep = outputs_dir / "keep.md" + keep.write_text("ok", encoding="utf-8") + + middleware = ArtifactReconcileMiddleware() + state = { + "thread_data": {"outputs_path": str(outputs_dir)}, + "artifacts": [ + ARTIFACTS_REPLACE_SENTINEL, + "/mnt/user-data/outputs/keep.md", + ], + } + + result = middleware.before_model(state, runtime=SimpleNamespace(context={})) + + assert result == { + "artifacts": [ARTIFACTS_REPLACE_SENTINEL, "/mnt/user-data/outputs/keep.md"] + } diff --git a/backend/tests/test_thread_state_artifacts_reducer.py b/backend/tests/test_thread_state_artifacts_reducer.py index 2f2e45ce..8926a85e 100644 --- a/backend/tests/test_thread_state_artifacts_reducer.py +++ b/backend/tests/test_thread_state_artifacts_reducer.py @@ -32,3 +32,35 @@ def test_merge_artifacts_supports_replace_sentinel(): "/mnt/user-data/outputs/b.md", "/mnt/user-data/outputs/c.md", ] + + +def test_merge_artifacts_always_strips_sentinel_from_existing(): + existing = [ + "/mnt/user-data/outputs/a.md", + ARTIFACTS_REPLACE_SENTINEL, + "/mnt/user-data/outputs/b.md", + ] + + result = merge_artifacts(existing, None) + + assert result == [ + "/mnt/user-data/outputs/a.md", + "/mnt/user-data/outputs/b.md", + ] + + +def test_merge_artifacts_strips_sentinel_from_non_replace_payload(): + existing = ["/mnt/user-data/outputs/a.md"] + new = [ + "/mnt/user-data/outputs/b.md", + ARTIFACTS_REPLACE_SENTINEL, + "/mnt/user-data/outputs/c.md", + ] + + result = merge_artifacts(existing, new) + + assert result == [ + "/mnt/user-data/outputs/a.md", + "/mnt/user-data/outputs/b.md", + "/mnt/user-data/outputs/c.md", + ] diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index da97445c..a4dfdce7 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -32,6 +32,7 @@ import { Tooltip } from "@/components/workspace/tooltip"; import { useSpecificChatMode } from "@/components/workspace/use-chat-mode"; import { Welcome } from "@/components/workspace/welcome"; import { getAPIClient } from "@/core/api"; +import { sanitizeArtifactPaths } from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages"; import { useNotification } from "@/core/notification/hooks"; @@ -210,6 +211,10 @@ export default function ChatPage() { const result = thread.values?.title ?? ""; return result === "Untitled" ? "" : result; }, [thread.values?.title]); + const sanitizedArtifacts = useMemo( + () => sanitizeArtifactPaths(thread.values.artifacts), + [thread.values.artifacts], + ); const [hasSubmitted, setHasSubmitted] = useState(false); const [historyCutoff, setHistoryCutoff] = useState(null); @@ -258,21 +263,21 @@ export default function ChatPage() { const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); useEffect(() => { - setArtifacts(thread.values.artifacts); + setArtifacts(sanitizedArtifacts); if ( env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && autoSelectFirstArtifact ) { - if (thread?.values?.artifacts?.length > 0) { + if (sanitizedArtifacts.length > 0) { setAutoSelectFirstArtifact(false); - selectArtifact(thread.values.artifacts[0]!); + selectArtifact(sanitizedArtifacts[0]!); } } }, [ autoSelectFirstArtifact, + sanitizedArtifacts, selectArtifact, setArtifacts, - thread.values.artifacts, ]); const artifactPanelOpen = useMemo(() => { @@ -492,7 +497,7 @@ export default function ChatPage() { ) : (
- {thread.values.artifacts?.length === 0 ? ( + {sanitizedArtifacts.length === 0 ? ( } title={t.chatPage.noArtifactSelectedTitle} @@ -518,7 +523,7 @@ export default function ChatPage() {
diff --git a/frontend/src/components/workspace/chats/chat-box.tsx b/frontend/src/components/workspace/chats/chat-box.tsx index b2e9cdac..70f1b5b8 100644 --- a/frontend/src/components/workspace/chats/chat-box.tsx +++ b/frontend/src/components/workspace/chats/chat-box.tsx @@ -5,6 +5,7 @@ import type { GroupImperativeHandle } from "react-resizable-panels"; import { ConversationEmptyState } from "@/components/ai-elements/conversation"; import { Button } from "@/components/ui/button"; +import { sanitizeArtifactPaths } from "@/core/artifacts/utils"; import { ResizableHandle, ResizablePanel, @@ -43,6 +44,10 @@ const ChatBox: React.FC<{ deselect, selectedArtifact, } = useArtifacts(); + const sanitizedArtifacts = useMemo( + () => sanitizeArtifactPaths(thread.values.artifacts), + [thread.values.artifacts], + ); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); useEffect(() => { @@ -52,7 +57,7 @@ const ChatBox: React.FC<{ } // Update artifacts from the current thread - setArtifacts(thread.values.artifacts); + setArtifacts(sanitizedArtifacts); // DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now. // if ( @@ -66,19 +71,19 @@ const ChatBox: React.FC<{ env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && autoSelectFirstArtifact ) { - if (thread?.values?.artifacts?.length > 0) { + if (sanitizedArtifacts.length > 0) { setAutoSelectFirstArtifact(false); - selectArtifact(thread.values.artifacts[0]!); + selectArtifact(sanitizedArtifacts[0]!); } } }, [ threadId, autoSelectFirstArtifact, deselect, + sanitizedArtifacts, selectArtifact, selectedArtifact, setArtifacts, - thread.values.artifacts, ]); const artifactPanelOpen = useMemo(() => { @@ -151,7 +156,7 @@ const ChatBox: React.FC<{
- {thread.values.artifacts?.length === 0 ? ( + {sanitizedArtifacts.length === 0 ? ( } title={t.chatPage.noArtifactSelectedTitle} @@ -167,7 +172,7 @@ const ChatBox: React.FC<{
diff --git a/frontend/src/core/artifacts/utils.ts b/frontend/src/core/artifacts/utils.ts index 40269650..b0c66a82 100644 --- a/frontend/src/core/artifacts/utils.ts +++ b/frontend/src/core/artifacts/utils.ts @@ -1,6 +1,8 @@ import { getBackendBaseURL } from "../config"; import type { AgentThread } from "../threads"; +const ARTIFACTS_REPLACE_SENTINEL = "__deerflow_replace_artifacts__"; + export function urlOfArtifact({ filepath, threadId, @@ -19,9 +21,13 @@ export function urlOfArtifact({ } export function extractArtifactsFromThread(thread: AgentThread) { - return thread.values.artifacts ?? []; + return sanitizeArtifactPaths(thread.values.artifacts); } export function resolveArtifactURL(absolutePath: string, threadId: string) { return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`; } + +export function sanitizeArtifactPaths(paths: string[] | undefined | null) { + return (paths ?? []).filter((path) => path !== ARTIFACTS_REPLACE_SENTINEL); +}