mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-26 09:55:59 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab2c7d07a5 | |||
| edeaa84563 | |||
| 2d84ddb1ae |
@@ -1,4 +1,4 @@
|
||||
"""Load MCP tools using langchain-mcp-adapters with persistent sessions."""
|
||||
"""Load MCP tools using langchain-mcp-adapters with stdio session pooling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -173,8 +173,10 @@ def _make_session_pool_tool(
|
||||
async def get_mcp_tools() -> list[BaseTool]:
|
||||
"""Get all tools from enabled MCP servers.
|
||||
|
||||
Tools are wrapped with persistent-session logic so that consecutive
|
||||
calls within the same thread reuse the same MCP session.
|
||||
Tools using stdio transport are wrapped with persistent-session logic so
|
||||
consecutive calls within the same thread reuse the same MCP session.
|
||||
HTTP/SSE tools are returned unwrapped to avoid cross-task TaskGroup
|
||||
cleanup errors.
|
||||
|
||||
Returns:
|
||||
List of LangChain tools from all enabled MCP servers.
|
||||
@@ -251,6 +253,9 @@ async def get_mcp_tools() -> list[BaseTool]:
|
||||
logger.info(f"Successfully loaded {len(tools)} tool(s) from MCP servers")
|
||||
|
||||
# Wrap each tool with persistent-session logic.
|
||||
# Only pool stdio sessions. HTTP/SSE transports use anyio TaskGroups
|
||||
# internally which cannot be closed from a different async task, so
|
||||
# pooling them causes RuntimeError on cleanup (see #3203).
|
||||
wrapped_tools: list[BaseTool] = []
|
||||
for tool in tools:
|
||||
tool_server: str | None = None
|
||||
@@ -260,7 +265,11 @@ async def get_mcp_tools() -> list[BaseTool]:
|
||||
break
|
||||
|
||||
if tool_server is not None:
|
||||
wrapped_tools.append(_make_session_pool_tool(tool, tool_server, servers_config[tool_server], tool_interceptors))
|
||||
transport = servers_config[tool_server].get("transport", "stdio")
|
||||
if transport == "stdio":
|
||||
wrapped_tools.append(_make_session_pool_tool(tool, tool_server, servers_config[tool_server], tool_interceptors))
|
||||
else:
|
||||
wrapped_tools.append(tool)
|
||||
else:
|
||||
wrapped_tools.append(tool)
|
||||
|
||||
|
||||
@@ -407,3 +407,80 @@ def test_session_pool_tool_sync_wrapper_path_is_safe():
|
||||
wrapped.func(url="https://example.com")
|
||||
|
||||
mock_session.call_tool.assert_called_once_with("navigate", {"url": "https://example.com"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_mcp_tools: HTTP transport should NOT be pooled
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_transport_tools_not_pooled():
|
||||
"""HTTP/SSE transport tools should NOT be wrapped with the session pool."""
|
||||
from langchain_core.tools import StructuredTool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from deerflow.mcp.tools import get_mcp_tools
|
||||
|
||||
class Args(BaseModel):
|
||||
query: str = Field(..., description="query")
|
||||
|
||||
http_tool = StructuredTool(
|
||||
name="myserver_search",
|
||||
description="Search tool",
|
||||
args_schema=Args,
|
||||
coroutine=AsyncMock(),
|
||||
response_format="content_and_artifact",
|
||||
)
|
||||
|
||||
stdio_tool = StructuredTool(
|
||||
name="playwright_navigate",
|
||||
description="Navigate browser",
|
||||
args_schema=Args,
|
||||
coroutine=AsyncMock(),
|
||||
response_format="content_and_artifact",
|
||||
)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
extensions_config = MagicMock()
|
||||
extensions_config.get_enabled_mcp_servers.return_value = {
|
||||
"myserver": MagicMock(type="http", url="http://localhost:8000/mcp", headers=None, command=None, args=[], env=None),
|
||||
"playwright": MagicMock(type="stdio", command="npx", args=["-y", "@anthropic/mcp-server-playwright"], env=None, url=None, headers=None),
|
||||
}
|
||||
extensions_config.model_extra = {}
|
||||
|
||||
servers_config = {
|
||||
"myserver": {"transport": "http", "url": "http://localhost:8000/mcp"},
|
||||
"playwright": {"transport": "stdio", "command": "npx", "args": ["-y", "@anthropic/mcp-server-playwright"]},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("deerflow.mcp.tools.ExtensionsConfig.from_file", return_value=extensions_config),
|
||||
patch("deerflow.mcp.tools.build_servers_config", return_value=servers_config),
|
||||
patch("deerflow.mcp.tools.get_initial_oauth_headers", return_value={}),
|
||||
patch("deerflow.mcp.tools.build_oauth_tool_interceptor", return_value=None),
|
||||
patch("langchain_mcp_adapters.client.MultiServerMCPClient") as MockClient,
|
||||
patch("langchain_mcp_adapters.sessions.create_session", return_value=mock_cm),
|
||||
):
|
||||
mock_client_instance = MockClient.return_value
|
||||
mock_client_instance.get_tools = AsyncMock(return_value=[http_tool, stdio_tool])
|
||||
|
||||
tools = await get_mcp_tools()
|
||||
|
||||
pool = get_session_pool()
|
||||
# Tool discovery is lazy: no pooled sessions are created until a wrapped tool is invoked.
|
||||
assert list(pool._entries.keys()) == []
|
||||
|
||||
# Verify the HTTP tool was NOT wrapped with the pool (it's the original tool).
|
||||
http_tools = [t for t in tools if t.name == "myserver_search"]
|
||||
assert len(http_tools) == 1
|
||||
assert http_tools[0].coroutine is http_tool.coroutine
|
||||
|
||||
# Verify the stdio tool WAS wrapped with the pool.
|
||||
stdio_tools = [t for t in tools if t.name == "playwright_navigate"]
|
||||
assert len(stdio_tools) == 1
|
||||
assert stdio_tools[0].coroutine is not stdio_tool.coroutine
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { writeTextToClipboard } from "@/core/clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import {
|
||||
@@ -147,20 +146,20 @@ export const CodeBlockCopyButton = ({
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = () => {
|
||||
void (async () => {
|
||||
const didCopy = await writeTextToClipboard(code);
|
||||
if (!didCopy) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setIsCopied(false), timeout);
|
||||
})().catch((error) => {
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
|
||||
} from "@/core/artifacts/preview";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { writeTextToClipboard } from "@/core/clipboard";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { findToolCallResult } from "@/core/messages/utils";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
@@ -238,20 +237,14 @@ export function ArtifactFileDetail({
|
||||
icon={CopyIcon}
|
||||
label={t.clipboard.copyToClipboard}
|
||||
disabled={!content}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
const didCopy = await writeTextToClipboard(
|
||||
visibleContent ?? "",
|
||||
);
|
||||
if (!didCopy) {
|
||||
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||
return;
|
||||
}
|
||||
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(visibleContent ?? "");
|
||||
toast.success(t.clipboard.copiedToClipboard);
|
||||
})().catch(() => {
|
||||
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Failed to copy to clipboard");
|
||||
console.error(error);
|
||||
}
|
||||
}}
|
||||
tooltip={t.clipboard.copyToClipboard}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { useCallback, useState, type ComponentProps } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { writeTextToClipboard } from "@/core/clipboard";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import { Tooltip } from "./tooltip";
|
||||
@@ -17,19 +15,10 @@ export function CopyButton({
|
||||
const { t } = useI18n();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = useCallback(() => {
|
||||
void (async () => {
|
||||
const didCopy = await writeTextToClipboard(clipboardData);
|
||||
if (!didCopy) {
|
||||
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||
return;
|
||||
}
|
||||
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
})().catch(() => {
|
||||
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||
});
|
||||
}, [clipboardData, t.clipboard.failedToCopyToClipboard]);
|
||||
void navigator.clipboard.writeText(clipboardData);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [clipboardData]);
|
||||
return (
|
||||
<Tooltip content={t.clipboard.copyToClipboard}>
|
||||
<Button
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { writeTextToClipboard } from "@/core/clipboard";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
exportThreadAsJSON,
|
||||
@@ -127,12 +126,7 @@ export function RecentChatList() {
|
||||
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
|
||||
const shareUrl = `${baseUrl}${pathOfThread(thread)}`;
|
||||
try {
|
||||
const didCopy = await writeTextToClipboard(shareUrl);
|
||||
if (!didCopy) {
|
||||
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||
return;
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
toast.success(t.clipboard.linkCopied);
|
||||
} catch {
|
||||
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
export async function writeTextToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
const clipboard = globalThis.navigator?.clipboard;
|
||||
if (clipboard?.writeText) {
|
||||
await clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
const document = globalThis.document;
|
||||
if (!document?.body?.appendChild || !document.execCommand) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "-9999px";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
return document.execCommand("copy");
|
||||
} finally {
|
||||
textarea.remove();
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -266,42 +266,22 @@ export function extractTextFromMessage(message: Message) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const THINK_OPEN_TAG = "<think>";
|
||||
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
|
||||
|
||||
function splitInlineReasoning(content: string) {
|
||||
const reasoningParts: string[] = [];
|
||||
|
||||
// First pass: strip every fully closed `<think>...</think>` pair and
|
||||
// collect its body as reasoning.
|
||||
let cleaned = content.replace(THINK_TAG_RE, (_, reasoning: string) => {
|
||||
const normalized = reasoning.trim();
|
||||
if (normalized) {
|
||||
reasoningParts.push(normalized);
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
// Streaming-safe pass: a `<think>` opener whose `</think>` has not arrived
|
||||
// yet means the rest of the chunk is reasoning in flight. Route it into the
|
||||
// reasoning slot instead of letting it render as message content (the
|
||||
// raw-HTML markdown pipeline would otherwise paint the inner text on
|
||||
// screen until the closing tag lands).
|
||||
//
|
||||
// Skip when the opener sits right after a backtick — that is the model
|
||||
// talking about `<think>` literally inside markdown inline code, not
|
||||
// actually streaming reasoning.
|
||||
const openTagIndex = cleaned.indexOf(THINK_OPEN_TAG);
|
||||
if (openTagIndex !== -1 && cleaned[openTagIndex - 1] !== "`") {
|
||||
const tail = cleaned.slice(openTagIndex + THINK_OPEN_TAG.length).trim();
|
||||
if (tail) {
|
||||
reasoningParts.push(tail);
|
||||
}
|
||||
cleaned = cleaned.slice(0, openTagIndex);
|
||||
}
|
||||
const cleaned = content
|
||||
.replace(THINK_TAG_RE, (_, reasoning: string) => {
|
||||
const normalized = reasoning.trim();
|
||||
if (normalized) {
|
||||
reasoningParts.push(normalized);
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.trim();
|
||||
|
||||
return {
|
||||
content: cleaned.trim(),
|
||||
content: cleaned,
|
||||
reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { writeTextToClipboard } from "@/core/clipboard";
|
||||
|
||||
const originalNavigator = globalThis.navigator;
|
||||
const hadOriginalNavigator = "navigator" in globalThis;
|
||||
const originalDocument = globalThis.document;
|
||||
const hadOriginalDocument = "document" in globalThis;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (!hadOriginalNavigator) {
|
||||
Reflect.deleteProperty(globalThis, "navigator");
|
||||
} else {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: originalNavigator,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hadOriginalDocument) {
|
||||
Reflect.deleteProperty(globalThis, "document");
|
||||
} else {
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: originalDocument,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("writes text with the Clipboard API when available", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: {
|
||||
clipboard: {
|
||||
writeText,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(writeTextToClipboard("hello")).resolves.toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
test("returns false when Clipboard API is unavailable", async () => {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: {},
|
||||
});
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test("falls back to execCommand when Clipboard API is unavailable", async () => {
|
||||
const textarea = {
|
||||
remove: vi.fn(),
|
||||
select: vi.fn(),
|
||||
setAttribute: vi.fn(),
|
||||
style: {},
|
||||
value: "",
|
||||
};
|
||||
const appendChild = vi.fn();
|
||||
const execCommand = vi.fn().mockReturnValue(true);
|
||||
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: {},
|
||||
});
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: {
|
||||
body: {
|
||||
appendChild,
|
||||
},
|
||||
createElement: vi.fn().mockReturnValue(textarea),
|
||||
execCommand,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(writeTextToClipboard("hello")).resolves.toBe(true);
|
||||
expect(textarea.value).toBe("hello");
|
||||
expect(appendChild).toHaveBeenCalledWith(textarea);
|
||||
expect(textarea.select).toHaveBeenCalled();
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
expect(textarea.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns false when execCommand fallback fails", async () => {
|
||||
const textarea = {
|
||||
remove: vi.fn(),
|
||||
select: vi.fn(),
|
||||
setAttribute: vi.fn(),
|
||||
style: {},
|
||||
value: "",
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: {},
|
||||
});
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: {
|
||||
body: {
|
||||
appendChild: vi.fn(),
|
||||
},
|
||||
createElement: vi.fn().mockReturnValue(textarea),
|
||||
execCommand: vi.fn().mockReturnValue(false),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
|
||||
expect(textarea.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns false when navigator is unavailable", async () => {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when Clipboard API rejects", async () => {
|
||||
const writeText = vi.fn().mockRejectedValue(new Error("denied"));
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: {
|
||||
clipboard: {
|
||||
writeText,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
|
||||
});
|
||||
@@ -1,26 +1,14 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
getAssistantTurnCopyData,
|
||||
getAssistantTurnUsageMessages,
|
||||
getMessageGroups,
|
||||
getStreamingMessageLookup,
|
||||
hasContent,
|
||||
hasReasoning,
|
||||
isAssistantMessageGroupStreaming,
|
||||
} from "@/core/messages/utils";
|
||||
|
||||
function aiMessage(content: string): Message {
|
||||
return {
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content,
|
||||
} as Message;
|
||||
}
|
||||
|
||||
test("aggregates token usage messages once per assistant turn", () => {
|
||||
const messages = [
|
||||
{
|
||||
@@ -79,100 +67,6 @@ test("aggregates token usage messages once per assistant turn", () => {
|
||||
).toEqual([null, null, ["ai-1", "ai-2"], null, ["ai-3"]]);
|
||||
});
|
||||
|
||||
describe("inline <think> tag splitting", () => {
|
||||
test("strips a fully closed <think> block from AI content", () => {
|
||||
const message = aiMessage("<think>internal reasoning</think>final answer");
|
||||
expect(extractContentFromMessage(message)).toBe("final answer");
|
||||
expect(extractReasoningContentFromMessage(message)).toBe(
|
||||
"internal reasoning",
|
||||
);
|
||||
});
|
||||
|
||||
test("strips multiple closed <think> blocks and joins their reasoning", () => {
|
||||
const message = aiMessage(
|
||||
"<think>step one</think>between<think>step two</think>after",
|
||||
);
|
||||
expect(extractContentFromMessage(message)).toBe("betweenafter");
|
||||
expect(extractReasoningContentFromMessage(message)).toBe(
|
||||
"step one\n\nstep two",
|
||||
);
|
||||
});
|
||||
|
||||
test("during streaming, an unclosed <think> tag does not leak its tail into content", () => {
|
||||
// Simulates accumulated content mid-stream, before </think> arrives.
|
||||
const message = aiMessage(
|
||||
"<think>I need to analyze the user's question step by",
|
||||
);
|
||||
expect(extractContentFromMessage(message)).toBe("");
|
||||
expect(extractContentFromMessage(message)).not.toContain("<think>");
|
||||
expect(extractReasoningContentFromMessage(message)).toBe(
|
||||
"I need to analyze the user's question step by",
|
||||
);
|
||||
});
|
||||
|
||||
test("preamble before an unclosed <think> stays in content", () => {
|
||||
const message = aiMessage(
|
||||
"Here is part of the answer.<think>but wait, let me reconsider",
|
||||
);
|
||||
expect(extractContentFromMessage(message)).toBe(
|
||||
"Here is part of the answer.",
|
||||
);
|
||||
expect(extractReasoningContentFromMessage(message)).toBe(
|
||||
"but wait, let me reconsider",
|
||||
);
|
||||
});
|
||||
|
||||
test("closed <think> followed by a trailing unclosed <think> merges both into reasoning", () => {
|
||||
const message = aiMessage(
|
||||
"<think>first step</think>partial answer<think>second step still streaming",
|
||||
);
|
||||
expect(extractContentFromMessage(message)).toBe("partial answer");
|
||||
expect(extractReasoningContentFromMessage(message)).toBe(
|
||||
"first step\n\nsecond step still streaming",
|
||||
);
|
||||
});
|
||||
|
||||
test("hasReasoning recognises an unclosed <think> tag mid-stream", () => {
|
||||
expect(hasReasoning(aiMessage("<think>thinking in progress"))).toBe(true);
|
||||
});
|
||||
|
||||
test("hasContent excludes an unclosed <think> tail when no preamble exists", () => {
|
||||
expect(hasContent(aiMessage("<think>thinking in progress"))).toBe(false);
|
||||
});
|
||||
|
||||
test("hasContent stays true when preamble precedes an unclosed <think>", () => {
|
||||
expect(hasContent(aiMessage("preamble<think>still thinking"))).toBe(true);
|
||||
});
|
||||
|
||||
test("a lone <think> open tag with no body yields no reasoning and no content", () => {
|
||||
const message = aiMessage("<think>");
|
||||
expect(extractContentFromMessage(message)).toBe("");
|
||||
expect(extractReasoningContentFromMessage(message)).toBeNull();
|
||||
expect(hasReasoning(message)).toBe(false);
|
||||
});
|
||||
|
||||
test("a literal <think> inside markdown inline code is not treated as reasoning", () => {
|
||||
const message = aiMessage(
|
||||
"Use `<think>` markers to delimit reasoning sections.",
|
||||
);
|
||||
expect(extractContentFromMessage(message)).toBe(
|
||||
"Use `<think>` markers to delimit reasoning sections.",
|
||||
);
|
||||
expect(extractReasoningContentFromMessage(message)).toBeNull();
|
||||
expect(hasReasoning(message)).toBe(false);
|
||||
});
|
||||
|
||||
test("a backtick-prefixed <think> mid-stream is not split into reasoning", () => {
|
||||
// Simulates the moment the model has emitted the opening backtick and
|
||||
// `<think>` for a literal documentation reference, before the closing
|
||||
// backtick arrives. The pre-fix behaviour would have permanently
|
||||
// truncated the content here.
|
||||
const message = aiMessage("Documentation: `<think>");
|
||||
expect(extractContentFromMessage(message)).toBe("Documentation: `<think>");
|
||||
expect(extractReasoningContentFromMessage(message)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("hides internal todo reminder messages from message groups", () => {
|
||||
const messages = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user