Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion backend/packages/harness/deerflow/config/extensions_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""
Expand Down
35 changes: 35 additions & 0 deletions backend/tests/test_mcp_client_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/core/threads/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,24 @@ export function mergeMessages(
]);
}

export function mergeThreadValues(
previousValues: Partial<AgentThreadState> | undefined,
nextValues: Partial<AgentThreadState> | undefined,
): Partial<AgentThreadState> | undefined {
if (!previousValues) {
return nextValues;
}
if (!nextValues) {
return previousValues;
}

return {
...previousValues,
...nextValues,
todos: nextValues.todos ?? previousValues.todos,
};
}

function getMessagesAfterBaseline(
messages: Message[],
baselineMessageIds: ReadonlySet<string>,
Expand Down Expand Up @@ -382,6 +400,8 @@ export function useThreadStream({
const latestMessageCountsRef = useRef({ humanMessageCount });
const sendInFlightRef = useRef(false);
const messagesRef = useRef<Message[]>([]);
const valuesRef = useRef<Partial<AgentThreadState> | undefined>(thread.values);
const valuesThreadIdRef = useRef<string | null>(threadId ?? null);
const summarizedRef = useRef<Set<string>>(null);
// Track human message count before sending to prevent clearing optimistic
// messages before the server's human message arrives (e.g. when AI messages
Expand Down Expand Up @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions frontend/tests/unit/core/threads/message-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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: [],
});
});