diff --git a/backend/packages/harness/deerflow/config/extensions_config.py b/backend/packages/harness/deerflow/config/extensions_config.py index 425da12b84..18b8cd5e2d 100644 --- a/backend/packages/harness/deerflow/config/extensions_config.py +++ b/backend/packages/harness/deerflow/config/extensions_config.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from deerflow.config.runtime_paths import existing_project_file @@ -47,6 +47,24 @@ class McpServerConfig(BaseModel): description: str = Field(default="", description="Human-readable description of what this MCP server provides") model_config = ConfigDict(extra="allow") + @model_validator(mode="before") + @classmethod + def _accept_transport_alias(cls, data: Any) -> Any: + """Accept the MCP-spec ``transport`` field as an alias for ``type``. + + The official MCP configuration schema uses ``transport`` to indicate + the transport mechanism (``stdio``/``sse``/``http``). Earlier versions + of this project only honored ``type``, which caused remote SSE/HTTP + servers configured with just ``transport`` to be incorrectly treated as + ``stdio`` (the default). This validator normalizes the two so either + spelling works, with ``type`` taking precedence when both are provided. + """ + if isinstance(data, dict): + transport = data.get("transport") + if transport and not data.get("type"): + data = {**data, "type": transport} + return data + class SkillStateConfig(BaseModel): """Configuration for a single skill's state.""" diff --git a/backend/tests/test_mcp_client_config.py b/backend/tests/test_mcp_client_config.py index ca4d0de597..3216b59fa8 100644 --- a/backend/tests/test_mcp_client_config.py +++ b/backend/tests/test_mcp_client_config.py @@ -83,6 +83,41 @@ def test_build_server_params_rejects_unsupported_transport(): build_server_params("bad-transport", config) +@pytest.mark.parametrize("transport", ["sse", "http"]) +def test_mcp_server_config_accepts_transport_alias(transport: str): + """The MCP-spec ``transport`` field should be accepted as an alias for ``type``. + + Regression test for https://github.com/bytedance/deer-flow/issues/3238 — a + remote MCP server configured with only ``transport: sse`` was previously + misidentified as ``stdio`` (the default for ``type``). + """ + config = McpServerConfig.model_validate( + { + "transport": transport, + "url": "https://example.com/mcp", + } + ) + + assert config.type == transport + + params = build_server_params("aliased-server", config) + assert params["transport"] == transport + assert params["url"] == "https://example.com/mcp" + + +def test_mcp_server_config_type_takes_precedence_over_transport(): + """When both ``type`` and ``transport`` are provided, ``type`` wins.""" + config = McpServerConfig.model_validate( + { + "type": "http", + "transport": "sse", + "url": "https://example.com/mcp", + } + ) + + assert config.type == "http" + + def test_build_servers_config_returns_empty_when_no_enabled_servers(): extensions = ExtensionsConfig( mcp_servers={ diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 1c927c5ffc..62479061ee 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -125,6 +125,24 @@ export function mergeMessages( ]); } +export function mergeThreadValues( + previousValues: Partial | undefined, + nextValues: Partial | undefined, +): Partial | undefined { + if (!previousValues) { + return nextValues; + } + if (!nextValues) { + return previousValues; + } + + return { + ...previousValues, + ...nextValues, + todos: nextValues.todos ?? previousValues.todos, + }; +} + function getMessagesAfterBaseline( messages: Message[], baselineMessageIds: ReadonlySet, @@ -382,6 +400,8 @@ export function useThreadStream({ const latestMessageCountsRef = useRef({ humanMessageCount }); const sendInFlightRef = useRef(false); const messagesRef = useRef([]); + const valuesRef = useRef | undefined>(thread.values); + const valuesThreadIdRef = useRef(threadId ?? null); const summarizedRef = useRef>(null); // Track human message count before sending to prevent clearing optimistic // messages before the server's human message arrives (e.g. when AI messages @@ -661,9 +681,17 @@ export function useThreadStream({ // Merge history, live stream, and optimistic messages for display // History messages may overlap with thread.messages; thread.messages take precedence + const activeThreadId = onStreamThreadId ?? threadId ?? null; + if (valuesThreadIdRef.current !== activeThreadId) { + valuesThreadIdRef.current = activeThreadId; + valuesRef.current = thread.values; + } + const mergedValues = mergeThreadValues(valuesRef.current, thread.values); + valuesRef.current = mergedValues; const mergedThread = { ...thread, messages: mergedMessages, + values: mergedValues, } as typeof thread; return { diff --git a/frontend/tests/unit/core/threads/message-merge.test.ts b/frontend/tests/unit/core/threads/message-merge.test.ts index 2afca1eef8..f560c4632c 100644 --- a/frontend/tests/unit/core/threads/message-merge.test.ts +++ b/frontend/tests/unit/core/threads/message-merge.test.ts @@ -4,6 +4,7 @@ import { expect, test } from "vitest"; import { getVisibleOptimisticMessages, mergeMessages, + mergeThreadValues, } from "@/core/threads/hooks"; test("mergeMessages removes duplicate messages already present in history", () => { @@ -155,3 +156,35 @@ test("getVisibleOptimisticMessages hides optimistic user input after later serve optimisticHuman, ]); }); + +test("mergeThreadValues preserves todos when a later state omits them", () => { + expect( + mergeThreadValues( + { + title: "Thread", + todos: [{ content: "Keep me", status: "in_progress" }], + }, + { + title: "Thread", + }, + ), + ).toEqual({ + title: "Thread", + todos: [{ content: "Keep me", status: "in_progress" }], + }); +}); + +test("mergeThreadValues allows explicit todo clearing", () => { + expect( + mergeThreadValues( + { + todos: [{ content: "Done", status: "completed" }], + }, + { + todos: [], + }, + ), + ).toEqual({ + todos: [], + }); +});