mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 01:45:58 +00:00
Merge remote-tracking branch 'origin/main' into codex/im-channel-connections
# Conflicts: # backend/app/channels/discord.py # backend/app/channels/manager.py # backend/app/channels/slack.py # backend/app/channels/telegram.py
This commit is contained in:
@@ -32,7 +32,8 @@ REPLAY_MODEL_BLOCK = """\
|
||||
- name: scenario-model
|
||||
display_name: Scenario Model
|
||||
use: replay_provider:ReplayChatModel
|
||||
model: replay"""
|
||||
model: replay
|
||||
supports_thinking: true"""
|
||||
|
||||
|
||||
def real_model_block(model: str) -> str:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Regression anchors: the custom-agent router must not block the event loop.
|
||||
|
||||
``app.gateway.routers.agents.create_agent_endpoint`` and ``delete_agent`` are
|
||||
async route handlers that resolve the agent directory (``Paths.base_dir`` calls
|
||||
``Path.resolve``), probe it (``Path.exists``), and create/remove it (``mkdir``,
|
||||
config/SOUL writes, ``shutil.rmtree``) — all blocking IO. Both offload that work
|
||||
via ``asyncio.to_thread``; if any of it regresses back onto the event loop, the
|
||||
strict Blockbuster gate raises ``BlockingError`` and these tests fail.
|
||||
|
||||
Imports live at module scope so the one-time FastAPI app construction (which
|
||||
reads files while building OpenAPI schemas) happens at collection time, not on
|
||||
the event loop under test. Test-side path resolution is itself offloaded with
|
||||
``asyncio.to_thread`` (matching ``test_uploads_middleware``) so only the
|
||||
handlers' own filesystem access is exercised on the loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.gateway.routers.agents import AgentCreateRequest, create_agent_endpoint, delete_agent
|
||||
from deerflow.config.agents_api_config import load_agents_api_config_from_dict
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_create_agent_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
||||
monkeypatch.setattr("deerflow.config.paths._paths", None)
|
||||
load_agents_api_config_from_dict({"enabled": True})
|
||||
try:
|
||||
response = await create_agent_endpoint(AgentCreateRequest(name="loop-make-agent", soul="You are a test agent."))
|
||||
assert response is not None
|
||||
|
||||
user_id = get_effective_user_id()
|
||||
# test-side check (resolution offloaded; not exercised on the loop)
|
||||
agent_dir = await asyncio.to_thread(get_paths().user_agent_dir, user_id, "loop-make-agent")
|
||||
assert await asyncio.to_thread((agent_dir / "config.yaml").exists)
|
||||
finally:
|
||||
load_agents_api_config_from_dict({})
|
||||
|
||||
|
||||
async def test_delete_agent_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
||||
monkeypatch.setattr("deerflow.config.paths._paths", None)
|
||||
load_agents_api_config_from_dict({"enabled": True})
|
||||
try:
|
||||
user_id = get_effective_user_id()
|
||||
user_id = get_effective_user_id()
|
||||
# test-side seeding (resolution offloaded; not exercised on the loop)
|
||||
agent_dir = await asyncio.to_thread(get_paths().user_agent_dir, user_id, "loop-test-agent")
|
||||
await asyncio.to_thread(agent_dir.mkdir, parents=True, exist_ok=True)
|
||||
await asyncio.to_thread((agent_dir / "config.yaml").write_text, "name: loop-test-agent\n", encoding="utf-8")
|
||||
|
||||
await delete_agent("loop-test-agent")
|
||||
|
||||
assert not await asyncio.to_thread(agent_dir.exists)
|
||||
finally:
|
||||
load_agents_api_config_from_dict({})
|
||||
+16
-6
@@ -12,7 +12,9 @@
|
||||
},
|
||||
"turns": [
|
||||
{
|
||||
"input_hash": "9c50eda6ab7e8593dabccbdeadc70a4a7bf778b2c0c3f275f1f96cf2c8ab58db",
|
||||
"caller": "lead_agent",
|
||||
"conversation_hash": "9c50eda6ab7e8593dabccbdeadc70a4a7bf778b2c0c3f275f1f96cf2c8ab58db",
|
||||
"input_hash": "27aeb4c11bff2c3ebc182fe52a06556823c21928620a400c7f26be9733c31f3f",
|
||||
"output": {
|
||||
"type": "ai",
|
||||
"data": {
|
||||
@@ -56,7 +58,9 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"input_hash": "3598aeb87e221ca8f554e4d61ce6d5e8801754606fa5c95a89c38bd6cb623045",
|
||||
"caller": "middleware:title",
|
||||
"conversation_hash": "3598aeb87e221ca8f554e4d61ce6d5e8801754606fa5c95a89c38bd6cb623045",
|
||||
"input_hash": "75101f9faa453b1a35deff920b1e3c1a9f0b013a7627fbbaa03436752776b953",
|
||||
"output": {
|
||||
"type": "ai",
|
||||
"data": {
|
||||
@@ -89,7 +93,9 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"input_hash": "6af134379b2a9efa01b4f63032f88211d5f38f459f8bed621eb6c65e8e05c1f9",
|
||||
"caller": "lead_agent",
|
||||
"conversation_hash": "6af134379b2a9efa01b4f63032f88211d5f38f459f8bed621eb6c65e8e05c1f9",
|
||||
"input_hash": "f7468603a43d301fcc0167c2f7cd10e53137bfc584f1b3d776614b7a612ed7a6",
|
||||
"output": {
|
||||
"type": "ai",
|
||||
"data": {
|
||||
@@ -132,7 +138,9 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"input_hash": "04751c4f7b0107b78b5c97d417063883fd586f5ebcbc4acf79be6cb3c0cdaec1",
|
||||
"caller": "lead_agent",
|
||||
"conversation_hash": "04751c4f7b0107b78b5c97d417063883fd586f5ebcbc4acf79be6cb3c0cdaec1",
|
||||
"input_hash": "218645dabc6926a1dbdf45dd20fba8a41e1e690cef78d7752566db3acf5a36ce",
|
||||
"output": {
|
||||
"type": "ai",
|
||||
"data": {
|
||||
@@ -165,7 +173,9 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"input_hash": "8b98ebdbb53e88f000556c4753adede8eaa076ff6fd7b8a1285bfd18aee8144d",
|
||||
"caller": "suggest_agent",
|
||||
"conversation_hash": "8b98ebdbb53e88f000556c4753adede8eaa076ff6fd7b8a1285bfd18aee8144d",
|
||||
"input_hash": "dcd855d389d7179a1e4bc7074fa9ba7ce697570af8947225d6bacb538f14a0cb",
|
||||
"output": {
|
||||
"type": "ai",
|
||||
"data": {
|
||||
@@ -230,4 +240,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
record/replay e2e (mirrors open-design's ``mocks/`` golden traces).
|
||||
|
||||
A fixture is a JSON file capturing the *real* model calls of one scenario,
|
||||
keyed by a normalized hash of the **input** each call received::
|
||||
keyed by a normalized hash of the **caller + input** each call received::
|
||||
|
||||
{
|
||||
"scenario": "write_read_file",
|
||||
"mode": "ultra",
|
||||
"model": "gpt-5.5",
|
||||
"turns": [
|
||||
{"input_hash": "<sha256>", "input_preview": "...", "output": <message dict>},
|
||||
{
|
||||
"caller": "lead_agent",
|
||||
"conversation_hash": "<sha256>",
|
||||
"input_hash": "<sha256>",
|
||||
"output": <message dict>,
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
@@ -21,8 +26,11 @@ A real run makes model calls from several callers — the lead agent's own turns
|
||||
and their count/order is not something we want a replay to depend on. Matching by
|
||||
a normalized hash of the *input messages* means each call gets back exactly the
|
||||
output that was recorded for that input, regardless of order or which middleware
|
||||
issued it. That keeps the in-graph, deterministic title call part of the
|
||||
recording; memory/summarization, by contrast, are disabled in the replay config
|
||||
issued it. The caller name (``lead_agent``, ``middleware:title``,
|
||||
``suggest_agent``, ``subagent:*``, ...) is included so two different model
|
||||
callers with the same conversation text do not compete for the same replay
|
||||
bucket. That keeps the in-graph, deterministic title call part of the recording;
|
||||
memory/summarization, by contrast, are disabled in the replay config
|
||||
(``_replay_fixture.py``) because their background, debounced timing is not
|
||||
reproducible across runs.
|
||||
|
||||
@@ -67,7 +75,7 @@ from collections import deque
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.callbacks import CallbackManagerForLLMRun
|
||||
from langchain_core.callbacks import BaseCallbackHandler, CallbackManagerForLLMRun
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, messages_from_dict
|
||||
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
|
||||
@@ -75,6 +83,14 @@ from langchain_core.runnables import Runnable
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
_FIXTURE_ENV = "DEERFLOW_REPLAY_FIXTURE"
|
||||
_DEFAULT_CALLER = "lead_agent"
|
||||
_CALLER_TAG_PREFIXES = ("middleware:", "subagent:")
|
||||
_CALLER_NAME_ALIASES = {
|
||||
# TitleMiddleware uses this run_name and tags the call as middleware:title.
|
||||
# Some execution paths do not preserve the tag down to the model callback,
|
||||
# so keep the run_name and tag in the same replay namespace.
|
||||
"title_agent": "middleware:title",
|
||||
}
|
||||
|
||||
# Process-wide record of replay misses. A miss raises inside the model, but the
|
||||
# gateway's LLMErrorHandlingMiddleware swallows it into a normal assistant error
|
||||
@@ -94,6 +110,30 @@ def reset_replay_misses() -> None:
|
||||
_replay_misses.clear()
|
||||
|
||||
|
||||
def _normalize_caller(caller: str | None) -> str:
|
||||
value = _normalize_text(str(caller or "").strip())
|
||||
if not value:
|
||||
return _DEFAULT_CALLER
|
||||
return _CALLER_NAME_ALIASES.get(value, value)
|
||||
|
||||
|
||||
def _caller_from_tags(tags: list[str] | None) -> str | None:
|
||||
for tag in tags or []:
|
||||
if isinstance(tag, str) and (tag == _DEFAULT_CALLER or tag.startswith(_CALLER_TAG_PREFIXES)):
|
||||
return tag
|
||||
return None
|
||||
|
||||
|
||||
def caller_identity(*, name: str | None = None, tags: list[str] | None = None) -> str:
|
||||
"""Stable model-caller identity shared by record and replay.
|
||||
|
||||
Tags win because graph middleware and subagents already use them as the
|
||||
explicit caller marker. ``run_name`` is exposed to callbacks as ``name`` and
|
||||
covers route-level callers such as ``suggest_agent``.
|
||||
"""
|
||||
return _normalize_caller(_caller_from_tags(tags) or name)
|
||||
|
||||
|
||||
# Volatile substrings that differ between a recording run and a replay run but
|
||||
# carry no semantic weight for matching. Normalized to stable placeholders
|
||||
# before hashing so the same logical input hashes identically across processes.
|
||||
@@ -172,10 +212,30 @@ def _canonical_messages(messages: list[BaseMessage]) -> str:
|
||||
|
||||
|
||||
def hash_messages(messages: list[BaseMessage]) -> str:
|
||||
"""Stable hash of a model call's input. Shared by recorder and replayer."""
|
||||
"""Legacy stable hash of only a model call's conversation input."""
|
||||
return hashlib.sha256(_canonical_messages(messages).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def hash_replay_input(messages: list[BaseMessage], *, caller: str | None) -> str:
|
||||
"""Stable replay key for a caller-specific model input."""
|
||||
return hash_input_key(hash_messages(messages), caller=caller)
|
||||
|
||||
|
||||
def hash_input_key(conversation_hash: str, *, caller: str | None) -> str:
|
||||
"""Namespace a conversation hash by caller identity.
|
||||
|
||||
Keeping this as ``hash(caller + legacy_conversation_hash)`` lets existing
|
||||
fixtures migrate without a live-model re-record: their old ``input_hash`` is
|
||||
exactly the conversation hash.
|
||||
"""
|
||||
payload = json.dumps(
|
||||
{"caller": _normalize_caller(caller), "conversation_hash": conversation_hash},
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _load_fixture(fixture_path: str) -> dict[str, deque[AIMessage]]:
|
||||
with open(fixture_path, encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
@@ -199,24 +259,54 @@ class ReplayChatModel(BaseChatModel):
|
||||
|
||||
_table: dict[str, deque] = PrivateAttr(default_factory=dict)
|
||||
_fixture_path: str = PrivateAttr(default="")
|
||||
_run_callers: dict[str, str] = PrivateAttr(default_factory=dict)
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
# Ignore provider noise the factory forwards from config (model, api_key,
|
||||
# base_url, ...). Fixture path comes from the ``fixture`` kwarg or env.
|
||||
fixture_path = kwargs.pop("fixture", None) or os.environ.get(_FIXTURE_ENV)
|
||||
super().__init__()
|
||||
callbacks = kwargs.pop("callbacks", None)
|
||||
super().__init__(callbacks=callbacks)
|
||||
if not fixture_path:
|
||||
raise ValueError(f"ReplayChatModel needs a fixture path via the ``fixture`` kwarg or ${_FIXTURE_ENV}")
|
||||
self._fixture_path = fixture_path
|
||||
self._table = _load_fixture(fixture_path)
|
||||
self.callbacks = [*(self.callbacks or []), _ReplayCallerCapture(self._run_callers)]
|
||||
|
||||
@property
|
||||
def _llm_type(self) -> str:
|
||||
return "deerflow-replay"
|
||||
|
||||
def _match(self, messages: list[BaseMessage]) -> AIMessage:
|
||||
key = hash_messages(messages)
|
||||
def _caller_from_run_manager(self, run_manager: CallbackManagerForLLMRun | None) -> str:
|
||||
if run_manager is None:
|
||||
if len(self._run_callers) == 1:
|
||||
# Some async LangGraph paths fire on_chat_model_start with the
|
||||
# caller metadata but invoke the model implementation without a
|
||||
# run_manager. When there is only one pending start event, it is
|
||||
# the current call; use it so record/replay share the same
|
||||
# caller key.
|
||||
return self._run_callers.pop(next(iter(self._run_callers)))
|
||||
return _DEFAULT_CALLER
|
||||
run_id = str(getattr(run_manager, "run_id", ""))
|
||||
caller = self._run_callers.pop(run_id, None)
|
||||
if caller:
|
||||
return caller
|
||||
return caller_identity(
|
||||
name=getattr(run_manager, "run_name", None) or getattr(run_manager, "name", None),
|
||||
tags=getattr(run_manager, "tags", None),
|
||||
)
|
||||
|
||||
def _match(self, messages: list[BaseMessage], run_manager: CallbackManagerForLLMRun | None = None) -> AIMessage:
|
||||
caller = self._caller_from_run_manager(run_manager)
|
||||
key = hash_replay_input(messages, caller=caller)
|
||||
bucket = self._table.get(key)
|
||||
if not bucket:
|
||||
# Backward compatibility for fixtures recorded before caller-aware
|
||||
# keys. New recordings write caller-aware ``input_hash`` values.
|
||||
legacy_key = hash_messages(messages)
|
||||
bucket = self._table.get(legacy_key)
|
||||
if bucket:
|
||||
key = legacy_key
|
||||
if not bucket:
|
||||
_replay_misses.append(key)
|
||||
preview = _canonical_messages(messages)
|
||||
@@ -224,6 +314,7 @@ class ReplayChatModel(BaseChatModel):
|
||||
f"replay miss: no recorded output for input hash {key} in {self._fixture_path!r}. "
|
||||
"The replayed run diverged from the recording (graph changed, a non-deterministic tool result "
|
||||
"altered a downstream input, or a volatile field slipped past normalization). "
|
||||
f"Caller: {caller!r}. "
|
||||
f"Known hashes: {sorted(self._table)}. "
|
||||
f"Normalized input (first 800 chars): {preview[:800]!r}"
|
||||
)
|
||||
@@ -236,7 +327,7 @@ class ReplayChatModel(BaseChatModel):
|
||||
run_manager: CallbackManagerForLLMRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ChatResult:
|
||||
return ChatResult(generations=[ChatGeneration(message=self._match(messages))])
|
||||
return ChatResult(generations=[ChatGeneration(message=self._match(messages, run_manager))])
|
||||
|
||||
def _stream(
|
||||
self,
|
||||
@@ -245,9 +336,16 @@ class ReplayChatModel(BaseChatModel):
|
||||
run_manager: CallbackManagerForLLMRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[ChatGenerationChunk]:
|
||||
turn = self._match(messages)
|
||||
turn = self._match(messages, run_manager)
|
||||
text = turn.content if isinstance(turn.content, str) else ""
|
||||
chunk = ChatGenerationChunk(message=AIMessageChunk(content=turn.content, tool_calls=turn.tool_calls, additional_kwargs=turn.additional_kwargs, id=turn.id))
|
||||
chunk = ChatGenerationChunk(
|
||||
message=AIMessageChunk(
|
||||
content=turn.content,
|
||||
tool_calls=turn.tool_calls,
|
||||
additional_kwargs=turn.additional_kwargs,
|
||||
id=turn.id,
|
||||
)
|
||||
)
|
||||
if run_manager is not None and text:
|
||||
run_manager.on_llm_new_token(text, chunk=chunk)
|
||||
yield chunk
|
||||
@@ -256,5 +354,31 @@ class ReplayChatModel(BaseChatModel):
|
||||
return self
|
||||
|
||||
|
||||
class _ReplayCallerCapture(BaseCallbackHandler):
|
||||
def __init__(self, run_callers: dict[str, str]) -> None:
|
||||
self._run_callers = run_callers
|
||||
|
||||
def on_chat_model_start(
|
||||
self,
|
||||
serialized: dict,
|
||||
messages: list[list[BaseMessage]],
|
||||
*,
|
||||
run_id: Any = None,
|
||||
tags: list[str] | None = None,
|
||||
name: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
if run_id is not None:
|
||||
self._run_callers[str(run_id)] = caller_identity(name=name, tags=tags)
|
||||
|
||||
|
||||
# Re-export so the recorder shares the exact hashing logic.
|
||||
__all__ = ["ReplayChatModel", "hash_messages", "replay_misses", "reset_replay_misses"]
|
||||
__all__ = [
|
||||
"ReplayChatModel",
|
||||
"caller_identity",
|
||||
"hash_input_key",
|
||||
"hash_messages",
|
||||
"hash_replay_input",
|
||||
"replay_misses",
|
||||
"reset_replay_misses",
|
||||
]
|
||||
|
||||
@@ -140,6 +140,57 @@ def test_app_config_defaults_empty_database_to_sqlite(tmp_path, monkeypatch):
|
||||
assert config.database.sqlite_dir == ".deer-flow/data"
|
||||
|
||||
|
||||
def test_app_config_coerces_commented_out_list_sections(tmp_path, monkeypatch):
|
||||
"""Commenting out every entry under a list key makes PyYAML parse it as None.
|
||||
|
||||
Regression for the documented ``cp config.example.yaml config.yaml`` flow
|
||||
(issue #1444): such a config must load with empty lists instead of raising
|
||||
``Input should be a valid list``.
|
||||
"""
|
||||
config_path = tmp_path / "config.yaml"
|
||||
extensions_path = tmp_path / "extensions_config.json"
|
||||
_write_extensions_config(extensions_path)
|
||||
config_path.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
||||
"models": None,
|
||||
"tools": None,
|
||||
"tool_groups": None,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
||||
|
||||
config = AppConfig.from_file(str(config_path))
|
||||
|
||||
assert config.models == []
|
||||
assert config.tools == []
|
||||
assert config.tool_groups == []
|
||||
|
||||
|
||||
def test_app_config_warns_when_no_models_configured(tmp_path, monkeypatch, caplog):
|
||||
config_path = tmp_path / "config.yaml"
|
||||
extensions_path = tmp_path / "extensions_config.json"
|
||||
_write_extensions_config(extensions_path)
|
||||
config_path.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
||||
"models": None,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
||||
|
||||
with caplog.at_level("WARNING", logger="deerflow.config.app_config"):
|
||||
AppConfig.from_file(str(config_path))
|
||||
|
||||
assert "No models are configured" in caplog.text
|
||||
|
||||
|
||||
def test_get_app_config_reloads_when_file_changes(tmp_path, monkeypatch):
|
||||
config_path = tmp_path / "config.yaml"
|
||||
extensions_path = tmp_path / "extensions_config.json"
|
||||
|
||||
@@ -4,6 +4,7 @@ import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.gateway.auth_middleware import AuthMiddleware, _is_public
|
||||
from app.gateway.csrf_middleware import CSRFMiddleware
|
||||
|
||||
# ── _is_public unit tests ─────────────────────────────────────────────────
|
||||
|
||||
@@ -92,7 +93,9 @@ def test_unknown_api_path_is_protected():
|
||||
|
||||
def _make_app():
|
||||
"""Create a minimal FastAPI app with AuthMiddleware for testing."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(AuthMiddleware)
|
||||
@@ -102,8 +105,16 @@ def _make_app():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/api/v1/auth/me")
|
||||
async def auth_me():
|
||||
return {"id": "1", "email": "test@test.com"}
|
||||
async def auth_me(request: Request):
|
||||
from app.gateway.deps import get_current_user_from_request
|
||||
|
||||
user = await get_current_user_from_request(request)
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"system_role": user.system_role,
|
||||
"needs_setup": user.needs_setup,
|
||||
}
|
||||
|
||||
@app.get("/api/v1/auth/setup-status")
|
||||
async def setup_status():
|
||||
@@ -113,6 +124,29 @@ def _make_app():
|
||||
async def models_get():
|
||||
return {"models": []}
|
||||
|
||||
@app.get("/api/whoami")
|
||||
async def whoami(request: Request):
|
||||
user = request.state.user
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"email": getattr(user, "email", None),
|
||||
"system_role": getattr(user, "system_role", None),
|
||||
"context_user_id": get_effective_user_id(),
|
||||
}
|
||||
|
||||
@app.get("/api/current-user-from-dep")
|
||||
async def current_user_from_dep(request: Request):
|
||||
from app.gateway.deps import get_current_user_from_request
|
||||
|
||||
user = await get_current_user_from_request(request)
|
||||
state_user = request.state.user
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"state_id": str(state_user.id),
|
||||
"auth_source": request.state.auth_source,
|
||||
"context_user_id": get_effective_user_id(),
|
||||
}
|
||||
|
||||
@app.put("/api/mcp/config")
|
||||
async def mcp_put():
|
||||
return {"ok": True}
|
||||
@@ -136,8 +170,24 @@ def _make_app():
|
||||
return app
|
||||
|
||||
|
||||
def _make_auth_csrf_app():
|
||||
"""Create a minimal app with production middleware ordering."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(AuthMiddleware)
|
||||
app.add_middleware(CSRFMiddleware)
|
||||
|
||||
@app.post("/api/threads/abc/runs/stream")
|
||||
async def protected_mutation():
|
||||
return {"ok": True}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
def client(monkeypatch):
|
||||
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
|
||||
return TestClient(_make_app())
|
||||
|
||||
|
||||
@@ -165,6 +215,139 @@ def test_protected_path_no_cookie_returns_401(client):
|
||||
assert body["detail"]["code"] == "not_authenticated"
|
||||
|
||||
|
||||
def test_auth_disabled_allows_protected_path_without_cookie(monkeypatch):
|
||||
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
|
||||
client = TestClient(_make_app())
|
||||
|
||||
res = client.get("/api/models")
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"models": []}
|
||||
|
||||
|
||||
def test_auth_disabled_stamps_e2e_admin_user_without_cookie(monkeypatch):
|
||||
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
|
||||
client = TestClient(_make_app())
|
||||
|
||||
res = client.get("/api/whoami")
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {
|
||||
"id": "e2e-user",
|
||||
"email": "e2e@test.local",
|
||||
"system_role": "admin",
|
||||
"context_user_id": "e2e-user",
|
||||
}
|
||||
|
||||
|
||||
def test_auth_disabled_auth_me_reuses_middleware_user_without_cookie(monkeypatch):
|
||||
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
|
||||
client = TestClient(_make_app())
|
||||
|
||||
res = client.get("/api/v1/auth/me")
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {
|
||||
"id": "e2e-user",
|
||||
"email": "e2e@test.local",
|
||||
"system_role": "admin",
|
||||
"needs_setup": False,
|
||||
}
|
||||
|
||||
|
||||
def test_auth_disabled_does_not_clobber_valid_session_cookie(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
async def fake_current_user(request):
|
||||
return SimpleNamespace(
|
||||
id="session-user",
|
||||
email="session@test.local",
|
||||
system_role="user",
|
||||
needs_setup=False,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
|
||||
monkeypatch.setattr("app.gateway.deps.get_current_user_from_request", fake_current_user)
|
||||
client = TestClient(_make_app())
|
||||
|
||||
res = client.get("/api/whoami", cookies={"access_token": "valid-session"})
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {
|
||||
"id": "session-user",
|
||||
"email": "session@test.local",
|
||||
"system_role": "user",
|
||||
"context_user_id": "session-user",
|
||||
}
|
||||
|
||||
|
||||
def test_auth_disabled_does_not_clobber_internal_auth_identity(monkeypatch):
|
||||
from app.gateway.internal_auth import create_internal_auth_headers
|
||||
from deerflow.runtime.user_context import DEFAULT_USER_ID
|
||||
|
||||
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
|
||||
client = TestClient(_make_app())
|
||||
|
||||
res = client.get(
|
||||
"/api/current-user-from-dep",
|
||||
headers=create_internal_auth_headers(),
|
||||
)
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {
|
||||
"id": DEFAULT_USER_ID,
|
||||
"state_id": DEFAULT_USER_ID,
|
||||
"auth_source": "internal",
|
||||
"context_user_id": DEFAULT_USER_ID,
|
||||
}
|
||||
|
||||
|
||||
def test_auth_disabled_skips_csrf_for_state_changing_requests(monkeypatch):
|
||||
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
|
||||
client = TestClient(_make_auth_csrf_app())
|
||||
|
||||
res = client.post("/api/threads/abc/runs/stream")
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_auth_disabled_is_ignored_in_explicit_production_env(monkeypatch):
|
||||
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
|
||||
monkeypatch.setenv("DEER_FLOW_ENV", "production")
|
||||
client = TestClient(_make_app())
|
||||
|
||||
res = client.get("/api/models")
|
||||
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
def test_auth_disabled_startup_warning_when_effective(monkeypatch, caplog):
|
||||
from app.gateway.auth_disabled import warn_if_auth_disabled_enabled
|
||||
|
||||
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
|
||||
monkeypatch.delenv("DEER_FLOW_ENV", raising=False)
|
||||
monkeypatch.delenv("ENVIRONMENT", raising=False)
|
||||
|
||||
with caplog.at_level("WARNING", logger="app.gateway.auth_disabled"):
|
||||
warn_if_auth_disabled_enabled()
|
||||
|
||||
assert "authentication is bypassed" in caplog.text
|
||||
assert "e2e-user" in caplog.text
|
||||
|
||||
|
||||
def test_auth_disabled_startup_warning_suppressed_in_explicit_production_env(monkeypatch, caplog):
|
||||
from app.gateway.auth_disabled import warn_if_auth_disabled_enabled
|
||||
|
||||
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
|
||||
monkeypatch.setenv("ENVIRONMENT", "production")
|
||||
|
||||
with caplog.at_level("WARNING", logger="app.gateway.auth_disabled"):
|
||||
warn_if_auth_disabled_enabled()
|
||||
|
||||
assert "authentication is bypassed" not in caplog.text
|
||||
|
||||
|
||||
def test_protected_path_with_junk_cookie_rejected(client):
|
||||
"""Junk cookie → 401. Middleware strictly validates the JWT now
|
||||
(AUTH_TEST_PLAN test 7.5.8); it no longer silently passes bad
|
||||
|
||||
@@ -21,6 +21,42 @@ from app.channels.message_bus import (
|
||||
ResolvedAttachment,
|
||||
)
|
||||
from app.channels.store import ChannelStore
|
||||
from deerflow.skills.types import Skill, SkillCategory
|
||||
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
|
||||
|
||||
|
||||
def test_known_channel_command_detection_only_matches_control_commands():
|
||||
from app.channels.commands import is_known_channel_command
|
||||
|
||||
assert is_known_channel_command("/new")
|
||||
assert is_known_channel_command("/HELP now")
|
||||
assert not is_known_channel_command("/mnt/user-data/uploads/report.pdf")
|
||||
assert not is_known_channel_command("/data-analysis analyze uploads/foo.csv")
|
||||
assert not is_known_channel_command(" /new")
|
||||
|
||||
|
||||
def _make_channel_skill(tmp_path: Path, name: str, *, enabled: bool = True) -> Skill:
|
||||
skill_dir = tmp_path / name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
skill_file.write_text(f"# {name}\n", encoding="utf-8")
|
||||
return Skill(
|
||||
name=name,
|
||||
description=f"Description for {name}",
|
||||
license="MIT",
|
||||
skill_dir=skill_dir,
|
||||
skill_file=skill_file,
|
||||
relative_path=Path(name),
|
||||
category=SkillCategory.CUSTOM,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
|
||||
def _make_channel_skill_storage(skills: list[Skill]):
|
||||
return SimpleNamespace(
|
||||
load_skills=lambda *, enabled_only: [skill for skill in skills if skill.enabled] if enabled_only else skills,
|
||||
get_container_root=lambda: "/mnt/skills",
|
||||
)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -1345,6 +1381,496 @@ class TestChannelManager:
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_handle_command_blank_text_is_reported_without_running_agent(self):
|
||||
from app.channels.manager import ChannelManager
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
manager = ChannelManager(bus=bus, store=store)
|
||||
|
||||
mock_client = _make_mock_langgraph_client()
|
||||
manager._client = mock_client
|
||||
|
||||
outbound_received = []
|
||||
|
||||
async def capture_outbound(msg):
|
||||
outbound_received.append(msg)
|
||||
|
||||
bus.subscribe_outbound(capture_outbound)
|
||||
await manager.start()
|
||||
|
||||
inbound = InboundMessage(
|
||||
channel_name="test",
|
||||
chat_id="chat1",
|
||||
user_id="user1",
|
||||
text=" ",
|
||||
msg_type=InboundMessageType.COMMAND,
|
||||
)
|
||||
await bus.publish_inbound(inbound)
|
||||
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||
await manager.stop()
|
||||
|
||||
mock_client.runs.wait.assert_not_called()
|
||||
assert outbound_received[0].text.startswith("Unknown command.")
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_handle_command_rejects_multi_slash_control_command(self):
|
||||
from app.channels.manager import ChannelManager
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
manager = ChannelManager(bus=bus, store=store)
|
||||
|
||||
mock_client = _make_mock_langgraph_client()
|
||||
manager._client = mock_client
|
||||
|
||||
outbound_received = []
|
||||
|
||||
async def capture_outbound(msg):
|
||||
outbound_received.append(msg)
|
||||
|
||||
bus.subscribe_outbound(capture_outbound)
|
||||
await manager.start()
|
||||
|
||||
inbound = InboundMessage(
|
||||
channel_name="test",
|
||||
chat_id="chat1",
|
||||
user_id="user1",
|
||||
text="//help",
|
||||
msg_type=InboundMessageType.COMMAND,
|
||||
)
|
||||
await bus.publish_inbound(inbound)
|
||||
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||
await manager.stop()
|
||||
|
||||
mock_client.runs.wait.assert_not_called()
|
||||
assert outbound_received[0].text.startswith("Unknown command: //help.")
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_handle_command_requires_control_command_at_start(self):
|
||||
from app.channels.manager import ChannelManager
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
manager = ChannelManager(bus=bus, store=store)
|
||||
|
||||
mock_client = _make_mock_langgraph_client(thread_id="new-thread-456")
|
||||
manager._client = mock_client
|
||||
|
||||
outbound_received = []
|
||||
|
||||
async def capture_outbound(msg):
|
||||
outbound_received.append(msg)
|
||||
|
||||
bus.subscribe_outbound(capture_outbound)
|
||||
await manager.start()
|
||||
|
||||
inbound = InboundMessage(
|
||||
channel_name="test",
|
||||
chat_id="chat1",
|
||||
user_id="user1",
|
||||
text=" /new",
|
||||
msg_type=InboundMessageType.COMMAND,
|
||||
)
|
||||
await bus.publish_inbound(inbound)
|
||||
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||
await manager.stop()
|
||||
|
||||
mock_client.threads.create.assert_not_called()
|
||||
assert store.get_thread_id("test", "chat1") is None
|
||||
assert outbound_received[0].text.startswith("Unknown command: /new.")
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_handle_command_outbound_thread_id_uses_topic_thread(self):
|
||||
from app.channels.manager import ChannelManager
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
manager = ChannelManager(bus=bus, store=store)
|
||||
store.set_thread_id("test", "chat1", "base-thread")
|
||||
store.set_thread_id("test", "chat1", "topic-thread", topic_id="topic-1")
|
||||
|
||||
outbound_received = []
|
||||
|
||||
async def capture_outbound(msg):
|
||||
outbound_received.append(msg)
|
||||
|
||||
bus.subscribe_outbound(capture_outbound)
|
||||
await manager.start()
|
||||
|
||||
inbound = InboundMessage(
|
||||
channel_name="test",
|
||||
chat_id="chat1",
|
||||
user_id="user1",
|
||||
text="/status",
|
||||
msg_type=InboundMessageType.COMMAND,
|
||||
topic_id="topic-1",
|
||||
)
|
||||
await bus.publish_inbound(inbound)
|
||||
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||
await manager.stop()
|
||||
|
||||
assert outbound_received[0].text == "Active thread: topic-thread"
|
||||
assert outbound_received[0].thread_id == "topic-thread"
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_handle_command_slash_skill_routes_to_chat(self, tmp_path):
|
||||
from app.channels.manager import ChannelManager
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
manager = ChannelManager(bus=bus, store=store)
|
||||
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis")])
|
||||
|
||||
mock_client = _make_mock_langgraph_client()
|
||||
manager._client = mock_client
|
||||
|
||||
outbound_received = []
|
||||
|
||||
async def capture_outbound(msg):
|
||||
outbound_received.append(msg)
|
||||
|
||||
bus.subscribe_outbound(capture_outbound)
|
||||
await manager.start()
|
||||
|
||||
inbound = InboundMessage(
|
||||
channel_name="test",
|
||||
chat_id="chat1",
|
||||
user_id="user1",
|
||||
text="/data-analysis analyze uploads/foo.csv",
|
||||
msg_type=InboundMessageType.COMMAND,
|
||||
)
|
||||
await bus.publish_inbound(inbound)
|
||||
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||
await manager.stop()
|
||||
|
||||
mock_client.runs.wait.assert_called_once()
|
||||
call_args = mock_client.runs.wait.call_args
|
||||
assert call_args[1]["input"]["messages"][0]["content"] == "/data-analysis analyze uploads/foo.csv"
|
||||
assert outbound_received[0].text == "Hello from agent!"
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_handle_command_slash_skill_with_attachment_preserves_original_content(self, monkeypatch, tmp_path):
|
||||
from app.channels.manager import ChannelManager
|
||||
|
||||
async def fake_ingest(thread_id, msg):
|
||||
return [
|
||||
{
|
||||
"filename": "report.pdf",
|
||||
"size": 12,
|
||||
"path": "/mnt/user-data/uploads/report.pdf",
|
||||
"is_image": False,
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr("app.channels.manager._ingest_inbound_files", fake_ingest)
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
manager = ChannelManager(bus=bus, store=store)
|
||||
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis")])
|
||||
|
||||
mock_client = _make_mock_langgraph_client()
|
||||
manager._client = mock_client
|
||||
|
||||
outbound_received = []
|
||||
|
||||
async def capture_outbound(msg):
|
||||
outbound_received.append(msg)
|
||||
|
||||
bus.subscribe_outbound(capture_outbound)
|
||||
await manager.start()
|
||||
|
||||
original_text = "/data-analysis analyze report.pdf"
|
||||
inbound = InboundMessage(
|
||||
channel_name="test",
|
||||
chat_id="chat1",
|
||||
user_id="user1",
|
||||
text=original_text,
|
||||
files=[{"filename": "report.pdf"}],
|
||||
msg_type=InboundMessageType.COMMAND,
|
||||
)
|
||||
await bus.publish_inbound(inbound)
|
||||
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||
await manager.stop()
|
||||
|
||||
mock_client.runs.wait.assert_called_once()
|
||||
human_message = mock_client.runs.wait.call_args[1]["input"]["messages"][0]
|
||||
assert human_message["content"].startswith("<uploaded_files>")
|
||||
assert original_text in human_message["content"]
|
||||
assert human_message["additional_kwargs"][ORIGINAL_USER_CONTENT_KEY] == original_text
|
||||
assert outbound_received[0].text == "Hello from agent!"
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_streaming_slash_skill_with_attachment_preserves_original_content(self, monkeypatch, tmp_path):
|
||||
from app.channels.manager import ChannelManager
|
||||
|
||||
async def fake_ingest(thread_id, msg):
|
||||
return [
|
||||
{
|
||||
"filename": "report.pdf",
|
||||
"size": 12,
|
||||
"path": "/mnt/user-data/uploads/report.pdf",
|
||||
"is_image": False,
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr("app.channels.manager._ingest_inbound_files", fake_ingest)
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
manager = ChannelManager(bus=bus, store=store)
|
||||
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis")])
|
||||
|
||||
mock_client = _make_mock_langgraph_client()
|
||||
mock_client.runs.stream = MagicMock(
|
||||
return_value=_make_async_iterator(
|
||||
[
|
||||
_make_stream_part(
|
||||
"values",
|
||||
{"messages": [{"type": "ai", "content": "streamed response"}]},
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
manager._client = mock_client
|
||||
|
||||
outbound_received = []
|
||||
|
||||
async def capture_outbound(msg):
|
||||
outbound_received.append(msg)
|
||||
|
||||
bus.subscribe_outbound(capture_outbound)
|
||||
await manager.start()
|
||||
|
||||
original_text = "/data-analysis analyze report.pdf"
|
||||
inbound = InboundMessage(
|
||||
channel_name="feishu",
|
||||
chat_id="chat1",
|
||||
user_id="user1",
|
||||
text=original_text,
|
||||
files=[{"filename": "report.pdf"}],
|
||||
msg_type=InboundMessageType.COMMAND,
|
||||
)
|
||||
await bus.publish_inbound(inbound)
|
||||
await _wait_for(lambda: any(message.is_final for message in outbound_received))
|
||||
await manager.stop()
|
||||
|
||||
mock_client.runs.stream.assert_called_once()
|
||||
human_message = mock_client.runs.stream.call_args[1]["input"]["messages"][0]
|
||||
assert human_message["content"].startswith("<uploaded_files>")
|
||||
assert original_text in human_message["content"]
|
||||
assert human_message["additional_kwargs"][ORIGINAL_USER_CONTENT_KEY] == original_text
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_handle_command_slash_skill_requires_command_at_start(self, tmp_path):
|
||||
from app.channels.manager import ChannelManager
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
manager = ChannelManager(bus=bus, store=store)
|
||||
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis")])
|
||||
|
||||
mock_client = _make_mock_langgraph_client()
|
||||
manager._client = mock_client
|
||||
|
||||
outbound_received = []
|
||||
|
||||
async def capture_outbound(msg):
|
||||
outbound_received.append(msg)
|
||||
|
||||
bus.subscribe_outbound(capture_outbound)
|
||||
await manager.start()
|
||||
|
||||
inbound = InboundMessage(
|
||||
channel_name="test",
|
||||
chat_id="chat1",
|
||||
user_id="user1",
|
||||
text=" /data-analysis analyze uploads/foo.csv",
|
||||
msg_type=InboundMessageType.COMMAND,
|
||||
)
|
||||
await bus.publish_inbound(inbound)
|
||||
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||
await manager.stop()
|
||||
|
||||
mock_client.runs.wait.assert_not_called()
|
||||
assert outbound_received[0].text.startswith("Unknown command: /data-analysis.")
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_handle_command_slash_skill_respects_custom_agent_skill_whitelist(self, monkeypatch, tmp_path):
|
||||
from app.channels.manager import ChannelManager
|
||||
|
||||
monkeypatch.setattr("app.channels.manager.load_agent_config", lambda name: SimpleNamespace(skills=["frontend-design"]))
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
manager = ChannelManager(
|
||||
bus=bus,
|
||||
store=store,
|
||||
default_session={"assistant_id": "analyst-agent"},
|
||||
)
|
||||
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis")])
|
||||
|
||||
mock_client = _make_mock_langgraph_client()
|
||||
manager._client = mock_client
|
||||
|
||||
outbound_received = []
|
||||
|
||||
async def capture_outbound(msg):
|
||||
outbound_received.append(msg)
|
||||
|
||||
bus.subscribe_outbound(capture_outbound)
|
||||
await manager.start()
|
||||
|
||||
inbound = InboundMessage(
|
||||
channel_name="test",
|
||||
chat_id="chat1",
|
||||
user_id="user1",
|
||||
text="/data-analysis analyze uploads/foo.csv",
|
||||
msg_type=InboundMessageType.COMMAND,
|
||||
)
|
||||
await bus.publish_inbound(inbound)
|
||||
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||
await manager.stop()
|
||||
|
||||
mock_client.runs.wait.assert_not_called()
|
||||
assert outbound_received[0].text == "Skill `/data-analysis` is not available for this agent."
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_handle_command_slash_skill_reports_disabled_skill(self, tmp_path):
|
||||
from app.channels.manager import ChannelManager
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
manager = ChannelManager(bus=bus, store=store)
|
||||
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis", enabled=False)])
|
||||
|
||||
mock_client = _make_mock_langgraph_client()
|
||||
manager._client = mock_client
|
||||
|
||||
outbound_received = []
|
||||
|
||||
async def capture_outbound(msg):
|
||||
outbound_received.append(msg)
|
||||
|
||||
bus.subscribe_outbound(capture_outbound)
|
||||
await manager.start()
|
||||
|
||||
inbound = InboundMessage(
|
||||
channel_name="test",
|
||||
chat_id="chat1",
|
||||
user_id="user1",
|
||||
text="/data-analysis analyze uploads/foo.csv",
|
||||
msg_type=InboundMessageType.COMMAND,
|
||||
)
|
||||
await bus.publish_inbound(inbound)
|
||||
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||
await manager.stop()
|
||||
|
||||
mock_client.runs.wait.assert_not_called()
|
||||
assert outbound_received[0].text == "Skill `/data-analysis` is installed but disabled. Enable it before using slash activation."
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_handle_command_uninstalled_slash_skill_stays_unknown_command(self, tmp_path):
|
||||
from app.channels.manager import ChannelManager
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
manager = ChannelManager(bus=bus, store=store)
|
||||
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "frontend-design")])
|
||||
|
||||
mock_client = _make_mock_langgraph_client()
|
||||
manager._client = mock_client
|
||||
|
||||
outbound_received = []
|
||||
|
||||
async def capture_outbound(msg):
|
||||
outbound_received.append(msg)
|
||||
|
||||
bus.subscribe_outbound(capture_outbound)
|
||||
await manager.start()
|
||||
|
||||
inbound = InboundMessage(
|
||||
channel_name="test",
|
||||
chat_id="chat1",
|
||||
user_id="user1",
|
||||
text="/data-analysis analyze uploads/foo.csv",
|
||||
msg_type=InboundMessageType.COMMAND,
|
||||
)
|
||||
await bus.publish_inbound(inbound)
|
||||
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||
await manager.stop()
|
||||
|
||||
mock_client.runs.wait.assert_not_called()
|
||||
assert outbound_received[0].text.startswith("Unknown command: /data-analysis.")
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_handle_command_slash_skill_resolution_error_is_reported(self, monkeypatch):
|
||||
from app.channels.manager import ChannelManager, SlashSkillCommandResolutionError
|
||||
|
||||
def fail_resolution(text, available_skills=None, storage=None):
|
||||
raise SlashSkillCommandResolutionError("Failed to resolve slash skill command. Please check the skill configuration.")
|
||||
|
||||
monkeypatch.setattr("app.channels.manager._resolve_slash_skill_command", fail_resolution)
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
manager = ChannelManager(bus=bus, store=store)
|
||||
store.set_thread_id("test", "chat1", "base-thread")
|
||||
store.set_thread_id("test", "chat1", "topic-thread", topic_id="topic-1")
|
||||
|
||||
mock_client = _make_mock_langgraph_client()
|
||||
manager._client = mock_client
|
||||
|
||||
outbound_received = []
|
||||
|
||||
async def capture_outbound(msg):
|
||||
outbound_received.append(msg)
|
||||
|
||||
bus.subscribe_outbound(capture_outbound)
|
||||
await manager.start()
|
||||
|
||||
inbound = InboundMessage(
|
||||
channel_name="test",
|
||||
chat_id="chat1",
|
||||
user_id="user1",
|
||||
text="/data-analysis analyze uploads/foo.csv",
|
||||
msg_type=InboundMessageType.COMMAND,
|
||||
topic_id="topic-1",
|
||||
)
|
||||
await bus.publish_inbound(inbound)
|
||||
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||
await manager.stop()
|
||||
|
||||
mock_client.runs.wait.assert_not_called()
|
||||
assert outbound_received[0].text == "Failed to resolve slash skill command. Please check the skill configuration."
|
||||
assert outbound_received[0].thread_id == "topic-thread"
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_handle_command_new(self):
|
||||
from app.channels.manager import ChannelManager
|
||||
|
||||
@@ -2541,6 +3067,36 @@ class TestWeComChannel:
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_publish_ws_inbound_treats_slash_prefixed_paths_as_chat(self, monkeypatch):
|
||||
from app.channels.wecom import WeComChannel
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
bus.publish_inbound = AsyncMock()
|
||||
channel = WeComChannel(bus, config={})
|
||||
channel._ws_client = SimpleNamespace(reply_stream=AsyncMock())
|
||||
|
||||
monkeypatch.setitem(
|
||||
__import__("sys").modules,
|
||||
"aibot",
|
||||
SimpleNamespace(generate_req_id=lambda prefix: "stream-1"),
|
||||
)
|
||||
|
||||
frame = {
|
||||
"body": {
|
||||
"msgid": "msg-1",
|
||||
"from": {"userid": "user-1"},
|
||||
}
|
||||
}
|
||||
|
||||
await channel._publish_ws_inbound(frame, "/mnt/user-data/uploads/report.pdf")
|
||||
|
||||
inbound = bus.publish_inbound.await_args.args[0]
|
||||
assert inbound.text == "/mnt/user-data/uploads/report.pdf"
|
||||
assert inbound.msg_type == InboundMessageType.CHAT
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_on_outbound_sends_attachment_before_clearing_context(self, tmp_path):
|
||||
from app.channels.wecom import WeComChannel
|
||||
|
||||
@@ -2976,6 +3532,219 @@ class TestSlackAllowedUsers:
|
||||
assert inbound.chat_id == "C123"
|
||||
assert inbound.text == "hello from slack"
|
||||
|
||||
def test_app_mention_strips_leading_bot_mention_before_command_detection(self):
|
||||
from app.channels.slack import SlackChannel
|
||||
|
||||
bus = MessageBus()
|
||||
bus.publish_inbound = AsyncMock()
|
||||
channel = SlackChannel(bus=bus, config={"bot_user_id": "UBOT"})
|
||||
channel._loop = MagicMock()
|
||||
channel._loop.is_running.return_value = True
|
||||
channel._add_reaction = MagicMock()
|
||||
channel._send_running_reply = MagicMock()
|
||||
|
||||
event = {
|
||||
"type": "app_mention",
|
||||
"user": "U123456",
|
||||
"text": "<@UBOT> /help",
|
||||
"channel": "C123",
|
||||
"ts": "1710000000.000100",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.channels.slack.asyncio.run_coroutine_threadsafe",
|
||||
side_effect=self._submit_coro,
|
||||
):
|
||||
channel._handle_message_event(event)
|
||||
|
||||
inbound = bus.publish_inbound.call_args.args[0]
|
||||
assert inbound.text == "/help"
|
||||
assert inbound.msg_type == InboundMessageType.COMMAND
|
||||
|
||||
def test_app_mention_strips_labelled_leading_bot_mention(self):
|
||||
from app.channels.slack import SlackChannel
|
||||
|
||||
bus = MessageBus()
|
||||
bus.publish_inbound = AsyncMock()
|
||||
channel = SlackChannel(bus=bus, config={"bot_user_id": "UBOT"})
|
||||
channel._loop = MagicMock()
|
||||
channel._loop.is_running.return_value = True
|
||||
channel._add_reaction = MagicMock()
|
||||
channel._send_running_reply = MagicMock()
|
||||
|
||||
event = {
|
||||
"type": "app_mention",
|
||||
"user": "U123456",
|
||||
"text": "<@UBOT|deerflow> /help",
|
||||
"channel": "C123",
|
||||
"ts": "1710000000.000100",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.channels.slack.asyncio.run_coroutine_threadsafe",
|
||||
side_effect=self._submit_coro,
|
||||
):
|
||||
channel._handle_message_event(event)
|
||||
|
||||
inbound = bus.publish_inbound.call_args.args[0]
|
||||
assert inbound.text == "/help"
|
||||
assert inbound.msg_type == InboundMessageType.COMMAND
|
||||
|
||||
def test_app_mention_strips_leading_bot_mention_before_slash_skill(self):
|
||||
from app.channels.slack import SlackChannel
|
||||
|
||||
bus = MessageBus()
|
||||
bus.publish_inbound = AsyncMock()
|
||||
channel = SlackChannel(bus=bus, config={"bot_user_id": "UBOT"})
|
||||
channel._loop = MagicMock()
|
||||
channel._loop.is_running.return_value = True
|
||||
channel._add_reaction = MagicMock()
|
||||
channel._send_running_reply = MagicMock()
|
||||
|
||||
event = {
|
||||
"type": "app_mention",
|
||||
"user": "U123456",
|
||||
"text": "<@UBOT> /data-analysis analyze uploads/foo.csv",
|
||||
"channel": "C123",
|
||||
"ts": "1710000000.000100",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.channels.slack.asyncio.run_coroutine_threadsafe",
|
||||
side_effect=self._submit_coro,
|
||||
):
|
||||
channel._handle_message_event(event)
|
||||
|
||||
inbound = bus.publish_inbound.call_args.args[0]
|
||||
assert inbound.text == "/data-analysis analyze uploads/foo.csv"
|
||||
assert inbound.msg_type == InboundMessageType.CHAT
|
||||
|
||||
def test_app_mention_preserves_following_user_mention(self):
|
||||
from app.channels.slack import SlackChannel
|
||||
|
||||
bus = MessageBus()
|
||||
bus.publish_inbound = AsyncMock()
|
||||
channel = SlackChannel(bus=bus, config={"bot_user_id": "UBOT"})
|
||||
channel._loop = MagicMock()
|
||||
channel._loop.is_running.return_value = True
|
||||
channel._add_reaction = MagicMock()
|
||||
channel._send_running_reply = MagicMock()
|
||||
|
||||
event = {
|
||||
"type": "app_mention",
|
||||
"user": "U123456",
|
||||
"text": "<@UBOT> <@UASSIGNEE> please review this",
|
||||
"channel": "C123",
|
||||
"ts": "1710000000.000100",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.channels.slack.asyncio.run_coroutine_threadsafe",
|
||||
side_effect=self._submit_coro,
|
||||
):
|
||||
channel._handle_message_event(event)
|
||||
|
||||
inbound = bus.publish_inbound.call_args.args[0]
|
||||
assert inbound.text == "<@UASSIGNEE> please review this"
|
||||
assert inbound.msg_type == InboundMessageType.CHAT
|
||||
|
||||
def test_app_mention_preserves_leading_non_bot_mention_when_bot_id_known(self):
|
||||
from app.channels.slack import SlackChannel
|
||||
|
||||
bus = MessageBus()
|
||||
bus.publish_inbound = AsyncMock()
|
||||
channel = SlackChannel(bus=bus, config={"bot_user_id": "UBOT"})
|
||||
channel._loop = MagicMock()
|
||||
channel._loop.is_running.return_value = True
|
||||
channel._add_reaction = MagicMock()
|
||||
channel._send_running_reply = MagicMock()
|
||||
|
||||
event = {
|
||||
"type": "app_mention",
|
||||
"user": "U123456",
|
||||
"text": "<@UASSIGNEE> <@UBOT> please review this",
|
||||
"channel": "C123",
|
||||
"ts": "1710000000.000100",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.channels.slack.asyncio.run_coroutine_threadsafe",
|
||||
side_effect=self._submit_coro,
|
||||
):
|
||||
channel._handle_message_event(event)
|
||||
|
||||
inbound = bus.publish_inbound.call_args.args[0]
|
||||
assert inbound.text == "<@UASSIGNEE> <@UBOT> please review this"
|
||||
assert inbound.msg_type == InboundMessageType.CHAT
|
||||
|
||||
def test_app_mention_preserves_leading_non_bot_mention_when_bot_id_unknown(self):
|
||||
from app.channels.slack import SlackChannel
|
||||
|
||||
bus = MessageBus()
|
||||
bus.publish_inbound = AsyncMock()
|
||||
channel = SlackChannel(bus=bus, config={})
|
||||
channel._loop = MagicMock()
|
||||
channel._loop.is_running.return_value = True
|
||||
channel._add_reaction = MagicMock()
|
||||
channel._send_running_reply = MagicMock()
|
||||
|
||||
event = {
|
||||
"type": "app_mention",
|
||||
"user": "U123456",
|
||||
"text": "<@UASSIGNEE> /help <@UBOT>",
|
||||
"channel": "C123",
|
||||
"ts": "1710000000.000100",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.channels.slack.asyncio.run_coroutine_threadsafe",
|
||||
side_effect=self._submit_coro,
|
||||
):
|
||||
channel._handle_message_event(event)
|
||||
|
||||
inbound = bus.publish_inbound.call_args.args[0]
|
||||
assert inbound.text == "<@UASSIGNEE> /help <@UBOT>"
|
||||
assert inbound.msg_type == InboundMessageType.CHAT
|
||||
|
||||
def test_socket_event_resolves_bot_user_id_before_app_mention_command_detection(self):
|
||||
from app.channels.slack import SlackChannel
|
||||
|
||||
bus = MessageBus()
|
||||
bus.publish_inbound = AsyncMock()
|
||||
channel = SlackChannel(bus=bus, config={})
|
||||
channel._SocketModeResponse = lambda envelope_id: SimpleNamespace(envelope_id=envelope_id)
|
||||
channel._loop = MagicMock()
|
||||
channel._loop.is_running.return_value = True
|
||||
channel._add_reaction = MagicMock()
|
||||
channel._send_running_reply = MagicMock()
|
||||
|
||||
client = SimpleNamespace(send_socket_mode_response=MagicMock())
|
||||
req = SimpleNamespace(
|
||||
envelope_id="env-1",
|
||||
type="events_api",
|
||||
payload={
|
||||
"authorizations": [{"user_id": "UBOT"}],
|
||||
"event": {
|
||||
"type": "app_mention",
|
||||
"user": "U123456",
|
||||
"text": "<@UBOT> /help",
|
||||
"channel": "C123",
|
||||
"ts": "1710000000.000100",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.channels.slack.asyncio.run_coroutine_threadsafe",
|
||||
side_effect=self._submit_coro,
|
||||
):
|
||||
channel._on_socket_event(client, req)
|
||||
|
||||
inbound = bus.publish_inbound.call_args.args[0]
|
||||
assert channel._bot_user_id == "UBOT"
|
||||
assert inbound.text == "/help"
|
||||
assert inbound.msg_type == InboundMessageType.COMMAND
|
||||
|
||||
def test_scalar_allowed_users_warns_and_matches_stringified_event_user_id(self, caplog):
|
||||
from app.channels.slack import SlackChannel
|
||||
|
||||
@@ -3049,6 +3818,86 @@ class TestSlackAllowedUsers:
|
||||
|
||||
|
||||
class TestTelegramSendRetry:
|
||||
def test_start_registers_known_channel_commands(self, monkeypatch):
|
||||
import sys
|
||||
from types import ModuleType
|
||||
|
||||
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
|
||||
from app.channels.telegram import TelegramChannel
|
||||
|
||||
class FakeFilter:
|
||||
def __init__(self, expr: str):
|
||||
self.expr = expr
|
||||
|
||||
def __and__(self, other):
|
||||
return FakeFilter(f"{self.expr}&{other.expr}")
|
||||
|
||||
def __invert__(self):
|
||||
return FakeFilter(f"~{self.expr}")
|
||||
|
||||
class FakeApplication:
|
||||
def __init__(self):
|
||||
self.handlers = []
|
||||
|
||||
def add_handler(self, handler):
|
||||
self.handlers.append(handler)
|
||||
|
||||
fake_app = FakeApplication()
|
||||
|
||||
class FakeApplicationBuilder:
|
||||
def token(self, token):
|
||||
assert token == "test-token"
|
||||
return self
|
||||
|
||||
def build(self):
|
||||
return fake_app
|
||||
|
||||
def fake_command_handler(command, callback):
|
||||
return SimpleNamespace(kind="command", command=command, callback=callback)
|
||||
|
||||
def fake_message_handler(filter_expr, callback):
|
||||
return SimpleNamespace(kind="message", filter_expr=filter_expr, callback=callback)
|
||||
|
||||
telegram_mod = ModuleType("telegram")
|
||||
telegram_ext_mod = ModuleType("telegram.ext")
|
||||
telegram_ext_mod.ApplicationBuilder = FakeApplicationBuilder
|
||||
telegram_ext_mod.CommandHandler = fake_command_handler
|
||||
telegram_ext_mod.MessageHandler = fake_message_handler
|
||||
telegram_ext_mod.filters = SimpleNamespace(TEXT=FakeFilter("TEXT"), COMMAND=FakeFilter("COMMAND"))
|
||||
telegram_mod.ext = telegram_ext_mod
|
||||
monkeypatch.setitem(sys.modules, "telegram", telegram_mod)
|
||||
monkeypatch.setitem(sys.modules, "telegram.ext", telegram_ext_mod)
|
||||
|
||||
class FakeThread:
|
||||
def __init__(self, *, target, daemon):
|
||||
self.target = target
|
||||
self.daemon = daemon
|
||||
|
||||
def start(self):
|
||||
return None
|
||||
|
||||
def join(self, timeout=None):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("app.channels.telegram.threading.Thread", FakeThread)
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||
|
||||
await ch.start()
|
||||
try:
|
||||
registered_commands = {handler.command for handler in fake_app.handlers if handler.kind == "command"}
|
||||
expected_commands = {command.removeprefix("/") for command in KNOWN_CHANNEL_COMMANDS}
|
||||
assert expected_commands <= registered_commands
|
||||
assert "start" in registered_commands
|
||||
message_filters = {handler.filter_expr.expr for handler in fake_app.handlers if handler.kind == "message"}
|
||||
assert {"TEXT&COMMAND", "TEXT&~COMMAND"} <= message_filters
|
||||
finally:
|
||||
await ch.stop()
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_retries_on_failure_then_succeeds(self):
|
||||
from app.channels.telegram import TelegramChannel
|
||||
|
||||
@@ -3172,6 +4021,47 @@ class TestTelegramPrivateChatThread:
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_private_chat_slash_skill_text_routes_as_chat(self):
|
||||
from app.channels.telegram import TelegramChannel
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||
ch._main_loop = asyncio.get_event_loop()
|
||||
|
||||
update = _make_telegram_update("private", message_id=12, text="/data-analysis analyze uploads/foo.csv")
|
||||
await ch._on_text(update, None)
|
||||
|
||||
msg = await asyncio.wait_for(bus.get_inbound(), timeout=2)
|
||||
assert msg.text == "/data-analysis analyze uploads/foo.csv"
|
||||
assert msg.msg_type == InboundMessageType.CHAT
|
||||
assert msg.topic_id is None
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_slash_skill_addressed_to_telegram_bot_strips_username(self):
|
||||
from app.channels.telegram import TelegramChannel
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||
ch._main_loop = asyncio.get_event_loop()
|
||||
|
||||
update = _make_telegram_update(
|
||||
"group",
|
||||
message_id=13,
|
||||
text="/data-analysis@DeerFlowBot analyze uploads/foo.csv",
|
||||
)
|
||||
context = SimpleNamespace(bot=SimpleNamespace(username="DeerFlowBot"))
|
||||
await ch._on_text(update, context)
|
||||
|
||||
msg = await asyncio.wait_for(bus.get_inbound(), timeout=2)
|
||||
assert msg.text == "/data-analysis analyze uploads/foo.csv"
|
||||
assert msg.msg_type == InboundMessageType.CHAT
|
||||
assert msg.topic_id == "13"
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_private_chat_with_reply_still_uses_none_topic(self):
|
||||
from app.channels.telegram import TelegramChannel
|
||||
|
||||
@@ -3287,6 +4177,25 @@ class TestTelegramPrivateChatThread:
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_cmd_generic_strips_addressed_telegram_bot_username(self):
|
||||
from app.channels.telegram import TelegramChannel
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||
ch._main_loop = asyncio.get_event_loop()
|
||||
|
||||
update = _make_telegram_update("group", message_id=33, text="/status@DeerFlowBot")
|
||||
context = SimpleNamespace(bot=SimpleNamespace(username="DeerFlowBot"))
|
||||
await ch._cmd_generic(update, context)
|
||||
|
||||
msg = await asyncio.wait_for(bus.get_inbound(), timeout=2)
|
||||
assert msg.text == "/status"
|
||||
assert msg.topic_id == "33"
|
||||
assert msg.msg_type == InboundMessageType.COMMAND
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
class TestTelegramProcessingOrder:
|
||||
"""Ensure 'working on it...' is sent before inbound is published."""
|
||||
|
||||
@@ -44,7 +44,8 @@ def test_entrypoint_excludes_runtime_state_from_uvicorn_reload():
|
||||
content = ENTRYPOINT.read_text(encoding="utf-8")
|
||||
|
||||
assert ': "${DEER_FLOW_HOME:=/app/backend/.deer-flow}"' in content
|
||||
assert 'mkdir -p "$DEER_FLOW_HOME" /app/backend/.deer-flow' in content
|
||||
# sandbox must be created too, not just .deer-flow (#3459 / #3454).
|
||||
assert 'mkdir -p "$DEER_FLOW_HOME" /app/backend/.deer-flow /app/backend/sandbox' in content
|
||||
assert "--reload-include='*.yaml .env'" not in content
|
||||
assert "--reload-include='*.yaml'" in content
|
||||
assert "--reload-include='.env'" in content
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.channels.discord import DiscordChannel
|
||||
from app.channels.manager import CHANNEL_CAPABILITIES
|
||||
from app.channels.message_bus import MessageBus
|
||||
from app.channels.message_bus import InboundMessageType, MessageBus
|
||||
from app.channels.service import _CHANNEL_REGISTRY
|
||||
|
||||
|
||||
@@ -21,3 +25,64 @@ def test_discord_channel_init() -> None:
|
||||
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
|
||||
|
||||
assert channel.name == "discord"
|
||||
|
||||
|
||||
def _make_discord_message(text: str):
|
||||
return SimpleNamespace(
|
||||
id=111,
|
||||
content=text,
|
||||
author=SimpleNamespace(id=123, bot=False, display_name="alice"),
|
||||
guild=SimpleNamespace(id=321),
|
||||
channel=SimpleNamespace(id=456),
|
||||
add_reaction=lambda _emoji: None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_bot_mention_slash_skill_routes_as_chat() -> None:
|
||||
bus = MessageBus()
|
||||
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
|
||||
captured = []
|
||||
channel._running = True
|
||||
channel._client = SimpleNamespace(user=SimpleNamespace(id=999, mention="<@999>"))
|
||||
channel._discord_module = SimpleNamespace(Thread=type("FakeThread", (), {}))
|
||||
channel._publish = captured.append
|
||||
|
||||
async def noop(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
channel._start_typing = noop
|
||||
channel._add_reaction = noop
|
||||
|
||||
await channel._on_message(_make_discord_message("<@999> /data-analysis analyze uploads/foo.csv"))
|
||||
|
||||
assert len(captured) == 1
|
||||
inbound = captured[0]
|
||||
assert inbound.text == "/data-analysis analyze uploads/foo.csv"
|
||||
assert inbound.msg_type == InboundMessageType.CHAT
|
||||
assert inbound.topic_id == "456"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_bot_mention_known_command_routes_as_command() -> None:
|
||||
bus = MessageBus()
|
||||
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
|
||||
captured = []
|
||||
channel._running = True
|
||||
channel._client = SimpleNamespace(user=SimpleNamespace(id=999, mention="<@999>"))
|
||||
channel._discord_module = SimpleNamespace(Thread=type("FakeThread", (), {}))
|
||||
channel._publish = captured.append
|
||||
|
||||
async def noop(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
channel._start_typing = noop
|
||||
channel._add_reaction = noop
|
||||
|
||||
await channel._on_message(_make_discord_message("<@999> /help"))
|
||||
|
||||
assert len(captured) == 1
|
||||
inbound = captured[0]
|
||||
assert inbound.text == "/help"
|
||||
assert inbound.msg_type == InboundMessageType.COMMAND
|
||||
assert inbound.topic_id == "456"
|
||||
|
||||
@@ -49,7 +49,9 @@ def test_local_dev_gateway_reload_excludes_runtime_state_with_absolute_dirs():
|
||||
assert 'export DEER_FLOW_PROJECT_ROOT="$REPO_ROOT"' in serve_sh
|
||||
assert 'BACKEND_RUNTIME_HOME="$REPO_ROOT/backend/.deer-flow"' in serve_sh
|
||||
assert 'export DEER_FLOW_HOME="$BACKEND_RUNTIME_HOME"' in serve_sh
|
||||
assert 'mkdir -p "$DEER_FLOW_HOME" "$BACKEND_RUNTIME_HOME"' in serve_sh
|
||||
# Every absolute reload-exclude must be pre-created, including backend/sandbox
|
||||
# (#3459 / #3454) — see test_uvicorn_reload_exclude.py for the mechanism.
|
||||
assert 'mkdir -p "$DEER_FLOW_HOME" "$BACKEND_RUNTIME_HOME" "$REPO_ROOT/backend/sandbox"' in serve_sh
|
||||
assert "--reload-exclude='$DEER_FLOW_HOME'" in serve_sh
|
||||
assert "--reload-exclude='$BACKEND_RUNTIME_HOME'" in serve_sh
|
||||
assert "--reload-exclude='sandbox/'" not in serve_sh
|
||||
|
||||
@@ -21,6 +21,7 @@ from langgraph_sdk import Auth
|
||||
from app.gateway.auth.config import AuthConfig, set_auth_config
|
||||
from app.gateway.auth.jwt import create_access_token, decode_token
|
||||
from app.gateway.auth.models import User
|
||||
from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID
|
||||
from app.gateway.langgraph_auth import add_owner_filter, authenticate
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
@@ -59,6 +60,14 @@ def test_no_cookie_raises_401():
|
||||
assert "Not authenticated" in str(exc.value.detail)
|
||||
|
||||
|
||||
def test_auth_disabled_skips_csrf_and_authenticates_e2e_user(monkeypatch):
|
||||
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
|
||||
|
||||
identity = asyncio.run(authenticate(_req(method="POST")))
|
||||
|
||||
assert identity == AUTH_DISABLED_USER_ID
|
||||
|
||||
|
||||
def test_invalid_jwt_raises_401():
|
||||
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
||||
asyncio.run(authenticate(_req({"access_token": "garbage"})))
|
||||
|
||||
@@ -60,6 +60,17 @@ def test_get_skills_prompt_section_returns_all_when_available_skills_is_none(mon
|
||||
assert "skill2" in result
|
||||
|
||||
|
||||
def test_get_skills_prompt_section_includes_slash_activation_guidance(monkeypatch):
|
||||
skills = [_make_skill("data-analysis")]
|
||||
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)
|
||||
|
||||
result = get_skills_prompt_section(available_skills={"data-analysis"})
|
||||
|
||||
assert "Explicit Slash Skill Activation" in result
|
||||
assert "The runtime injects the activated skill content" in result
|
||||
assert "do not call `read_file` for that SKILL.md again" in result
|
||||
|
||||
|
||||
def test_get_skills_prompt_section_includes_self_evolution_rules(monkeypatch):
|
||||
skills = [_make_skill("skill1")]
|
||||
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)
|
||||
|
||||
@@ -612,6 +612,54 @@ class TestLocalSandboxProviderMounts:
|
||||
|
||||
assert [m.container_path for m in provider._path_mappings] == ["/mnt/skills"]
|
||||
|
||||
def test_setup_path_mappings_logs_actionable_error_for_missing_host_path(self, tmp_path, caplog):
|
||||
"""Regression for #3244.
|
||||
|
||||
When ``sandbox.mounts[].host_path`` is absent from the gateway process's
|
||||
filesystem (the typical symptom in Docker production mode: host_path is a
|
||||
host machine path that is not bind-mounted into the gateway container),
|
||||
the mount is still skipped — but the failure must be a hard-to-miss ERROR
|
||||
log with explicit, actionable guidance about Docker bind mounts, not the
|
||||
old DEBUG/WARNING that buried the silent failure.
|
||||
"""
|
||||
skills_dir = tmp_path / "skills"
|
||||
skills_dir.mkdir()
|
||||
missing_host_path = tmp_path / "does-not-exist"
|
||||
|
||||
from deerflow.config.sandbox_config import SandboxConfig, VolumeMountConfig
|
||||
|
||||
sandbox_config = SandboxConfig(
|
||||
use="deerflow.sandbox.local:LocalSandboxProvider",
|
||||
mounts=[
|
||||
VolumeMountConfig(host_path=str(missing_host_path), container_path="/mnt/knowledge", read_only=True),
|
||||
],
|
||||
)
|
||||
config = SimpleNamespace(
|
||||
skills=SimpleNamespace(container_path="/mnt/skills", get_skills_path=lambda: skills_dir, use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
|
||||
sandbox=sandbox_config,
|
||||
)
|
||||
|
||||
with caplog.at_level("ERROR", logger="deerflow.sandbox.local.local_sandbox_provider"):
|
||||
with patch("deerflow.config.get_app_config", return_value=config):
|
||||
provider = LocalSandboxProvider()
|
||||
|
||||
# Silent-skip behaviour is preserved (no breaking change for existing deployments).
|
||||
assert [m.container_path for m in provider._path_mappings] == ["/mnt/skills"]
|
||||
|
||||
# The failure must be observable at ERROR level and reference the offending paths.
|
||||
error_records = [r for r in caplog.records if r.levelname == "ERROR"]
|
||||
assert error_records, "expected an ERROR log when host_path is missing"
|
||||
message = "\n".join(r.getMessage() for r in error_records)
|
||||
assert str(missing_host_path) in message
|
||||
assert "/mnt/knowledge" in message
|
||||
|
||||
# And it must include actionable Docker guidance so users don't lose hours
|
||||
# to a silent empty-mount failure in production.
|
||||
lowered = message.lower()
|
||||
assert "docker" in lowered
|
||||
assert "gateway" in lowered
|
||||
assert "docker-compose" in lowered
|
||||
|
||||
def test_write_file_resolves_container_paths_in_content(self, tmp_path):
|
||||
"""write_file should replace container paths in file content with local paths."""
|
||||
data_dir = tmp_path / "data"
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Tests for deerflow.models.patched_stepfun.PatchedChatStepFun."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage
|
||||
|
||||
|
||||
def _make_model(**kwargs):
|
||||
from deerflow.models.patched_stepfun import PatchedChatStepFun
|
||||
|
||||
return PatchedChatStepFun(
|
||||
model="step-3.7-flash",
|
||||
api_key="test-key",
|
||||
base_url="https://api.stepfun.com/v1",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_lc_serializable_returns_true():
|
||||
from deerflow.models.patched_stepfun import PatchedChatStepFun
|
||||
|
||||
assert PatchedChatStepFun.is_lc_serializable() is True
|
||||
|
||||
|
||||
def test_lc_secrets_contains_stepfun_api_key_mapping():
|
||||
model = _make_model()
|
||||
assert model.lc_secrets["api_key"] == "STEPFUN_API_KEY"
|
||||
assert model.lc_secrets["openai_api_key"] == "STEPFUN_API_KEY"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_reasoning helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_extract_reasoning_from_dict_with_reasoning():
|
||||
from deerflow.models.patched_stepfun import _extract_reasoning
|
||||
|
||||
assert _extract_reasoning({"reasoning": "thinking..."}) == "thinking..."
|
||||
|
||||
|
||||
def test_extract_reasoning_from_dict_with_reasoning_content():
|
||||
from deerflow.models.patched_stepfun import _extract_reasoning
|
||||
|
||||
assert _extract_reasoning({"reasoning_content": "thinking..."}) == "thinking..."
|
||||
|
||||
|
||||
def test_extract_reasoning_prefers_reasoning_content_over_reasoning():
|
||||
from deerflow.models.patched_stepfun import _extract_reasoning
|
||||
|
||||
result = _extract_reasoning({"reasoning_content": "deepseek", "reasoning": "native"})
|
||||
assert result == "deepseek"
|
||||
|
||||
|
||||
def test_extract_reasoning_missing_returns_sentinel():
|
||||
from deerflow.models.patched_stepfun import _MISSING, _extract_reasoning
|
||||
|
||||
assert _extract_reasoning({}) is _MISSING
|
||||
assert _extract_reasoning({"reasoning": None}) is _MISSING
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request payload replay (_get_request_payload)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reasoning_content_injected_into_assistant_tool_call_message():
|
||||
model = _make_model()
|
||||
|
||||
human = HumanMessage(content="Check Beijing weather.")
|
||||
ai = AIMessage(
|
||||
content="",
|
||||
additional_kwargs={"reasoning_content": "I need to call the weather tool."},
|
||||
)
|
||||
payload_message = {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_weather",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": '{"location":"Beijing"}'},
|
||||
}
|
||||
],
|
||||
}
|
||||
base_payload = {
|
||||
"messages": [
|
||||
{"role": "user", "content": "Check Beijing weather."},
|
||||
payload_message,
|
||||
]
|
||||
}
|
||||
|
||||
with patch.object(type(model).__bases__[0], "_get_request_payload", return_value=base_payload):
|
||||
with patch.object(model, "_convert_input") as mock_convert:
|
||||
mock_convert.return_value = MagicMock(to_messages=lambda: [human, ai])
|
||||
payload = model._get_request_payload([human, ai])
|
||||
|
||||
assert payload["messages"][1]["reasoning_content"] == "I need to call the weather tool."
|
||||
|
||||
|
||||
def test_reasoning_content_is_noop_when_missing():
|
||||
model = _make_model()
|
||||
|
||||
human = HumanMessage(content="hello")
|
||||
ai = AIMessage(content="hi", additional_kwargs={})
|
||||
base_payload = {
|
||||
"messages": [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
]
|
||||
}
|
||||
|
||||
with patch.object(type(model).__bases__[0], "_get_request_payload", return_value=base_payload):
|
||||
with patch.object(model, "_convert_input") as mock_convert:
|
||||
mock_convert.return_value = MagicMock(to_messages=lambda: [human, ai])
|
||||
payload = model._get_request_payload([human, ai])
|
||||
|
||||
assert "reasoning_content" not in payload["messages"][1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streaming reasoning capture (_convert_chunk_to_generation_chunk)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_convert_chunk_captures_reasoning_field():
|
||||
"""StepFun default format: delta.reasoning."""
|
||||
model = _make_model()
|
||||
|
||||
chunk = model._convert_chunk_to_generation_chunk(
|
||||
{"choices": [{"delta": {"role": "assistant", "reasoning": "I need "}}]},
|
||||
AIMessageChunk,
|
||||
{},
|
||||
)
|
||||
|
||||
assert chunk is not None
|
||||
assert chunk.message.additional_kwargs["reasoning_content"] == "I need "
|
||||
|
||||
|
||||
def test_convert_chunk_captures_reasoning_content_field():
|
||||
"""StepFun deepseek-style format: delta.reasoning_content."""
|
||||
model = _make_model()
|
||||
|
||||
chunk = model._convert_chunk_to_generation_chunk(
|
||||
{"choices": [{"delta": {"role": "assistant", "reasoning_content": "I need "}}]},
|
||||
AIMessageChunk,
|
||||
{},
|
||||
)
|
||||
|
||||
assert chunk is not None
|
||||
assert chunk.message.additional_kwargs["reasoning_content"] == "I need "
|
||||
|
||||
|
||||
def test_convert_chunk_streams_reasoning_then_content():
|
||||
"""Full streaming flow: reasoning deltas followed by content."""
|
||||
model = _make_model()
|
||||
|
||||
first = model._convert_chunk_to_generation_chunk(
|
||||
{"choices": [{"delta": {"role": "assistant", "reasoning": "I need "}}]},
|
||||
AIMessageChunk,
|
||||
{},
|
||||
)
|
||||
second = model._convert_chunk_to_generation_chunk(
|
||||
{"choices": [{"delta": {"reasoning": "a tool."}}]},
|
||||
AIMessageChunk,
|
||||
{},
|
||||
)
|
||||
answer = model._convert_chunk_to_generation_chunk(
|
||||
{"choices": [{"delta": {"content": "Done."}, "finish_reason": "stop"}], "model": "step-3.7-flash"},
|
||||
AIMessageChunk,
|
||||
{},
|
||||
)
|
||||
|
||||
assert first is not None
|
||||
assert second is not None
|
||||
assert answer is not None
|
||||
|
||||
combined = first.message + second.message + answer.message
|
||||
assert combined.additional_kwargs["reasoning_content"] == "I need a tool."
|
||||
assert combined.content == "Done."
|
||||
|
||||
|
||||
def test_convert_chunk_noop_when_no_reasoning():
|
||||
model = _make_model()
|
||||
|
||||
chunk = model._convert_chunk_to_generation_chunk(
|
||||
{"choices": [{"delta": {"content": "Hello."}, "finish_reason": "stop"}], "model": "step-3.7-flash"},
|
||||
AIMessageChunk,
|
||||
{},
|
||||
)
|
||||
|
||||
assert chunk is not None
|
||||
assert "reasoning_content" not in chunk.message.additional_kwargs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-streaming reasoning capture (_create_chat_result)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_chat_result_extracts_reasoning_field():
|
||||
"""StepFun default format: message.reasoning."""
|
||||
model = _make_model()
|
||||
response = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "The weather is sunny.",
|
||||
"reasoning": "The tool returned sunny weather.",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"model": "step-3.7-flash",
|
||||
}
|
||||
|
||||
result = model._create_chat_result(response)
|
||||
message = result.generations[0].message
|
||||
|
||||
assert message.content == "The weather is sunny."
|
||||
assert message.additional_kwargs["reasoning_content"] == "The tool returned sunny weather."
|
||||
|
||||
|
||||
def test_create_chat_result_extracts_reasoning_content_field():
|
||||
"""StepFun deepseek-style format: message.reasoning_content."""
|
||||
model = _make_model()
|
||||
response = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "The weather is sunny.",
|
||||
"reasoning_content": "The tool returned sunny weather.",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"model": "step-3.7-flash",
|
||||
}
|
||||
|
||||
result = model._create_chat_result(response)
|
||||
message = result.generations[0].message
|
||||
|
||||
assert message.content == "The weather is sunny."
|
||||
assert message.additional_kwargs["reasoning_content"] == "The tool returned sunny weather."
|
||||
|
||||
|
||||
def test_create_chat_result_reads_reasoning_from_sdk_object():
|
||||
"""When the response is a Pydantic model, reasoning is an attribute."""
|
||||
model = _make_model()
|
||||
|
||||
class FakeMessage:
|
||||
reasoning = "Reasoning stored on the SDK message object."
|
||||
reasoning_content = None
|
||||
model_extra = None
|
||||
|
||||
class FakeChoice:
|
||||
message = FakeMessage()
|
||||
|
||||
class FakeResponse:
|
||||
choices = [FakeChoice()]
|
||||
|
||||
def model_dump(self, **kwargs):
|
||||
return {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Answer.",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"model": "step-3.7-flash",
|
||||
}
|
||||
|
||||
result = model._create_chat_result(FakeResponse())
|
||||
assert result.generations[0].message.additional_kwargs["reasoning_content"] == "Reasoning stored on the SDK message object."
|
||||
|
||||
|
||||
def test_create_chat_result_noop_when_no_reasoning():
|
||||
model = _make_model()
|
||||
response = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Hello!",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"model": "step-3.7-flash",
|
||||
}
|
||||
|
||||
result = model._create_chat_result(response)
|
||||
assert "reasoning_content" not in result.generations[0].message.additional_kwargs
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage, messages_to_dict
|
||||
from replay_provider import ReplayChatModel, caller_identity, hash_messages, hash_replay_input
|
||||
|
||||
|
||||
def _write_fixture(path: Path, turns: list[dict]) -> None:
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"scenario": "unit",
|
||||
"mode": "unit",
|
||||
"model": "replay",
|
||||
"prompt": "unit",
|
||||
"context": {},
|
||||
"turns": turns,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_replay_key_includes_caller_identity(tmp_path: Path):
|
||||
messages = [HumanMessage(content="same conversation")]
|
||||
lead_output = AIMessage(content="lead")
|
||||
suggest_output = AIMessage(content="suggest")
|
||||
fixture_path = tmp_path / "fixture.json"
|
||||
|
||||
_write_fixture(
|
||||
fixture_path,
|
||||
[
|
||||
{
|
||||
"caller": "lead_agent",
|
||||
"conversation_hash": hash_messages(messages),
|
||||
"input_hash": hash_replay_input(messages, caller="lead_agent"),
|
||||
"output": messages_to_dict([lead_output])[0],
|
||||
},
|
||||
{
|
||||
"caller": "suggest_agent",
|
||||
"conversation_hash": hash_messages(messages),
|
||||
"input_hash": hash_replay_input(messages, caller="suggest_agent"),
|
||||
"output": messages_to_dict([suggest_output])[0],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
model = ReplayChatModel(fixture=str(fixture_path))
|
||||
|
||||
assert model.invoke(messages, config={"run_name": "suggest_agent"}).content == "suggest"
|
||||
assert model.invoke(messages, config={"run_name": "lead_agent"}).content == "lead"
|
||||
|
||||
|
||||
def test_replay_supports_legacy_conversation_only_fixture(tmp_path: Path):
|
||||
messages = [HumanMessage(content="legacy conversation")]
|
||||
fixture_path = tmp_path / "legacy.json"
|
||||
|
||||
_write_fixture(
|
||||
fixture_path,
|
||||
[
|
||||
{
|
||||
"input_hash": hash_messages(messages),
|
||||
"output": messages_to_dict([AIMessage(content="legacy")])[0],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
model = ReplayChatModel(fixture=str(fixture_path))
|
||||
|
||||
assert model.invoke(messages, config={"run_name": "suggest_agent"}).content == "legacy"
|
||||
|
||||
|
||||
def test_title_run_name_uses_middleware_caller_namespace(tmp_path: Path):
|
||||
messages = [HumanMessage(content="title prompt")]
|
||||
fixture_path = tmp_path / "fixture.json"
|
||||
|
||||
_write_fixture(
|
||||
fixture_path,
|
||||
[
|
||||
{
|
||||
"caller": "middleware:title",
|
||||
"conversation_hash": hash_messages(messages),
|
||||
"input_hash": hash_replay_input(messages, caller="middleware:title"),
|
||||
"output": messages_to_dict([AIMessage(content="generated title")])[0],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
model = ReplayChatModel(fixture=str(fixture_path))
|
||||
|
||||
assert caller_identity(name="title_agent") == "middleware:title"
|
||||
assert model.invoke(messages, config={"run_name": "title_agent"}).content == "generated title"
|
||||
|
||||
|
||||
def test_replay_uses_single_pending_capture_when_run_manager_is_missing(tmp_path: Path):
|
||||
messages = [HumanMessage(content="title prompt")]
|
||||
fixture_path = tmp_path / "fixture.json"
|
||||
|
||||
_write_fixture(
|
||||
fixture_path,
|
||||
[
|
||||
{
|
||||
"caller": "middleware:title",
|
||||
"conversation_hash": hash_messages(messages),
|
||||
"input_hash": hash_replay_input(messages, caller="middleware:title"),
|
||||
"output": messages_to_dict([AIMessage(content="generated title")])[0],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
model = ReplayChatModel(fixture=str(fixture_path))
|
||||
model._run_callers["captured-run"] = caller_identity(name="title_agent", tags=["middleware:title"])
|
||||
|
||||
assert model._match(messages, run_manager=None).content == "generated title"
|
||||
@@ -179,15 +179,16 @@ class TestLifecycleCallbacks:
|
||||
assert "run.end" in types
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nested_chain_no_run_start(self, journal_setup):
|
||||
"""Nested chains (parent_run_id set) should NOT produce run.start."""
|
||||
async def test_nested_chain_no_run_lifecycle_events(self, journal_setup):
|
||||
"""Nested chains (parent_run_id set) should NOT produce root run lifecycle events."""
|
||||
j, store = journal_setup
|
||||
parent_id = uuid4()
|
||||
j.on_chain_start({}, {}, run_id=uuid4(), parent_run_id=parent_id)
|
||||
j.on_chain_end({}, run_id=uuid4())
|
||||
j.on_chain_end({}, run_id=uuid4(), parent_run_id=parent_id)
|
||||
await j.flush()
|
||||
events = await store.list_events("t1", "r1")
|
||||
assert not any(e["event_type"] == "run.start" for e in events)
|
||||
assert not any(e["event_type"] == "run.end" for e in events)
|
||||
|
||||
|
||||
class TestToolCallbacks:
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from langchain.agents.middleware.types import ModelRequest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
|
||||
from deerflow.agents.middlewares import skill_activation_middleware as middleware_module
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware, is_slash_skill_activation_reminder
|
||||
from deerflow.skills.slash import RESERVED_SLASH_SKILL_NAMES, parse_slash_skill_reference, resolve_slash_skill
|
||||
from deerflow.skills.types import Skill, SkillCategory
|
||||
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
|
||||
|
||||
|
||||
def _make_skill(tmp_path: Path, name: str, content: str = "skill body") -> Skill:
|
||||
skill_dir = tmp_path / name
|
||||
skill_dir.mkdir()
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
skill_file.write_text(content, encoding="utf-8")
|
||||
return Skill(
|
||||
name=name,
|
||||
description=f"Description for {name}",
|
||||
license="MIT",
|
||||
skill_dir=skill_dir,
|
||||
skill_file=skill_file,
|
||||
relative_path=Path(name),
|
||||
category=SkillCategory.CUSTOM,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
def _make_storage(tmp_path: Path, skills: list[Skill]):
|
||||
return SimpleNamespace(
|
||||
load_skills=lambda *, enabled_only: [skill for skill in skills if skill.enabled] if enabled_only else skills,
|
||||
get_container_root=lambda: "/mnt/skills",
|
||||
get_skills_root_path=lambda: tmp_path,
|
||||
)
|
||||
|
||||
|
||||
def _make_model_request(messages: list[HumanMessage], *, runtime=None) -> ModelRequest:
|
||||
return ModelRequest(
|
||||
model=object(),
|
||||
messages=messages,
|
||||
state={"messages": list(messages)},
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_slash_skill_reference_extracts_name_and_remaining_text():
|
||||
parsed = parse_slash_skill_reference("/data-analysis analyze uploads/foo.csv")
|
||||
|
||||
assert parsed is not None
|
||||
assert parsed.name == "data-analysis"
|
||||
assert parsed.remaining_text == "analyze uploads/foo.csv"
|
||||
|
||||
|
||||
def test_parse_slash_skill_reference_accepts_skill_name_without_task():
|
||||
parsed = parse_slash_skill_reference("/data-analysis")
|
||||
|
||||
assert parsed is not None
|
||||
assert parsed.name == "data-analysis"
|
||||
assert parsed.remaining_text == ""
|
||||
|
||||
|
||||
def test_parse_slash_skill_reference_rejects_invalid_names():
|
||||
assert parse_slash_skill_reference("/DataAnalysis run") is None
|
||||
assert parse_slash_skill_reference("/data_analysis run") is None
|
||||
assert parse_slash_skill_reference("please use /data-analysis") is None
|
||||
assert parse_slash_skill_reference(" /data-analysis run") is None
|
||||
assert parse_slash_skill_reference("/data-analysis分析这个文档") is None
|
||||
|
||||
|
||||
def test_resolve_slash_skill_ignores_reserved_control_commands(tmp_path):
|
||||
for command in ["bootstrap", "help", "memory", "models", "new", "status"]:
|
||||
skill = _make_skill(tmp_path, command)
|
||||
|
||||
assert resolve_slash_skill(f"/{command} create an agent", [skill]) is None
|
||||
|
||||
|
||||
def test_reserved_slash_skill_names_match_channel_commands():
|
||||
assert RESERVED_SLASH_SKILL_NAMES == {command.removeprefix("/") for command in KNOWN_CHANNEL_COMMANDS}
|
||||
|
||||
|
||||
def test_resolve_slash_skill_respects_available_skill_whitelist(tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis")
|
||||
|
||||
assert resolve_slash_skill("/data-analysis run", [skill], available_skills=set()) is None
|
||||
|
||||
resolved = resolve_slash_skill("/data-analysis run", [skill], available_skills={"data-analysis"})
|
||||
assert resolved is not None
|
||||
assert resolved.skill.name == "data-analysis"
|
||||
assert resolved.remaining_text == "run"
|
||||
assert resolved.container_file_path == "/mnt/skills/custom/data-analysis/SKILL.md"
|
||||
|
||||
|
||||
def test_resolve_slash_skill_rejects_disabled_skills(tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis")
|
||||
skill.enabled = False
|
||||
|
||||
assert resolve_slash_skill("/data-analysis run", [skill]) is None
|
||||
|
||||
|
||||
def test_skill_activation_middleware_injects_hidden_human_context_for_model_call(monkeypatch, tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
middleware = SkillActivationMiddleware()
|
||||
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
|
||||
request = _make_model_request([original])
|
||||
captured = {}
|
||||
|
||||
def handler(model_request: ModelRequest):
|
||||
captured["messages"] = model_request.messages
|
||||
return AIMessage(content="ok")
|
||||
|
||||
result = middleware.wrap_model_call(request, handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
assert result.content == "ok"
|
||||
activation_msg, user_msg = captured["messages"]
|
||||
assert is_slash_skill_activation_reminder(activation_msg)
|
||||
assert activation_msg.additional_kwargs["hide_from_ui"] is True
|
||||
assert "Use pandas." in activation_msg.content
|
||||
assert "<user_request>\nanalyze uploads/foo.csv\n</user_request>" in activation_msg.content
|
||||
assert user_msg.content == original.content
|
||||
assert request.state["messages"] == [original]
|
||||
|
||||
|
||||
def test_skill_activation_middleware_does_not_duplicate_existing_activation(monkeypatch, tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
middleware = SkillActivationMiddleware()
|
||||
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
|
||||
first_capture = {}
|
||||
|
||||
def first_handler(model_request: ModelRequest):
|
||||
first_capture["messages"] = model_request.messages
|
||||
return AIMessage(content="ok")
|
||||
|
||||
first_result = middleware.wrap_model_call(_make_model_request([original]), first_handler)
|
||||
|
||||
assert isinstance(first_result, AIMessage)
|
||||
activation_msg, user_msg = first_capture["messages"]
|
||||
assert is_slash_skill_activation_reminder(activation_msg)
|
||||
|
||||
second_capture = {}
|
||||
|
||||
def second_handler(model_request: ModelRequest):
|
||||
second_capture["messages"] = model_request.messages
|
||||
return AIMessage(content="ok")
|
||||
|
||||
second_result = middleware.wrap_model_call(_make_model_request([activation_msg, user_msg]), second_handler)
|
||||
|
||||
assert isinstance(second_result, AIMessage)
|
||||
assert second_capture["messages"] == [activation_msg, user_msg]
|
||||
assert sum(is_slash_skill_activation_reminder(message) for message in second_capture["messages"]) == 1
|
||||
|
||||
|
||||
def test_skill_activation_middleware_does_not_duplicate_activation_separated_by_hidden_context(monkeypatch, tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
middleware = SkillActivationMiddleware()
|
||||
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
|
||||
first_capture = {}
|
||||
|
||||
def first_handler(model_request: ModelRequest):
|
||||
first_capture["messages"] = model_request.messages
|
||||
return AIMessage(content="ok")
|
||||
|
||||
middleware.wrap_model_call(_make_model_request([original]), first_handler)
|
||||
activation_msg, user_msg = first_capture["messages"]
|
||||
hidden_context = HumanMessage(content="dynamic context", additional_kwargs={"hide_from_ui": True})
|
||||
second_capture = {}
|
||||
|
||||
def second_handler(model_request: ModelRequest):
|
||||
second_capture["messages"] = model_request.messages
|
||||
return AIMessage(content="ok")
|
||||
|
||||
second_result = middleware.wrap_model_call(_make_model_request([activation_msg, hidden_context, user_msg]), second_handler)
|
||||
|
||||
assert isinstance(second_result, AIMessage)
|
||||
assert second_capture["messages"] == [activation_msg, hidden_context, user_msg]
|
||||
assert sum(is_slash_skill_activation_reminder(message) for message in second_capture["messages"]) == 1
|
||||
|
||||
|
||||
def test_skill_activation_middleware_dedupes_immediately_previous_activation_without_target_id(monkeypatch, tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
middleware = SkillActivationMiddleware()
|
||||
legacy_activation_msg = SkillActivationMiddleware._make_activation_message(
|
||||
HumanMessage(content="/data-analysis analyze uploads/foo.csv"),
|
||||
"existing activation context",
|
||||
)
|
||||
target = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
|
||||
captured = {}
|
||||
|
||||
def handler(model_request: ModelRequest):
|
||||
captured["messages"] = model_request.messages
|
||||
return AIMessage(content="ok")
|
||||
|
||||
result = middleware.wrap_model_call(_make_model_request([legacy_activation_msg, target]), handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
assert captured["messages"] == [legacy_activation_msg, target]
|
||||
assert sum(is_slash_skill_activation_reminder(message) for message in captured["messages"]) == 1
|
||||
|
||||
|
||||
def test_skill_activation_middleware_async_injects_hidden_human_context_for_model_call(monkeypatch, tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
middleware = SkillActivationMiddleware()
|
||||
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
|
||||
request = _make_model_request([original])
|
||||
captured = {}
|
||||
|
||||
async def handler(model_request: ModelRequest):
|
||||
captured["messages"] = model_request.messages
|
||||
return AIMessage(content="ok")
|
||||
|
||||
result = asyncio.run(middleware.awrap_model_call(request, handler))
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
assert result.content == "ok"
|
||||
activation_msg, user_msg = captured["messages"]
|
||||
assert is_slash_skill_activation_reminder(activation_msg)
|
||||
assert activation_msg.additional_kwargs["hide_from_ui"] is True
|
||||
assert "Use pandas." in activation_msg.content
|
||||
assert "<user_request>\nanalyze uploads/foo.csv\n</user_request>" in activation_msg.content
|
||||
assert user_msg.content == original.content
|
||||
assert request.state["messages"] == [original]
|
||||
|
||||
|
||||
def test_skill_activation_middleware_uses_fallback_when_task_text_is_empty(monkeypatch, tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
middleware = SkillActivationMiddleware()
|
||||
original = HumanMessage(content="/data-analysis", id="msg-1")
|
||||
captured = {}
|
||||
|
||||
def handler(model_request: ModelRequest):
|
||||
captured["messages"] = model_request.messages
|
||||
return AIMessage(content="ok")
|
||||
|
||||
result = middleware.wrap_model_call(_make_model_request([original]), handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
activation_msg = captured["messages"][0]
|
||||
assert "No additional task text was provided after the slash skill command." in activation_msg.content
|
||||
|
||||
|
||||
def test_skill_activation_middleware_uses_original_user_content_when_uploads_are_injected(monkeypatch, tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
middleware = SkillActivationMiddleware()
|
||||
original = HumanMessage(
|
||||
content="<uploaded_files>\n- report.pdf\n</uploaded_files>\n\n/data-analysis 分析这个文档",
|
||||
id="msg-1",
|
||||
additional_kwargs={ORIGINAL_USER_CONTENT_KEY: "/data-analysis 分析这个文档"},
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def handler(model_request: ModelRequest):
|
||||
captured["messages"] = model_request.messages
|
||||
return AIMessage(content="ok")
|
||||
|
||||
result = middleware.wrap_model_call(_make_model_request([original]), handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
assert result.content == "ok"
|
||||
activation_msg, user_msg = captured["messages"]
|
||||
assert is_slash_skill_activation_reminder(activation_msg)
|
||||
assert "Use pandas." in activation_msg.content
|
||||
assert "<user_request>\n分析这个文档\n</user_request>" in activation_msg.content
|
||||
assert user_msg.content == original.content
|
||||
assert user_msg.additional_kwargs[ORIGINAL_USER_CONTENT_KEY] == "/data-analysis 分析这个文档"
|
||||
|
||||
|
||||
def test_skill_activation_middleware_activates_from_list_content(monkeypatch, tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
middleware = SkillActivationMiddleware()
|
||||
original = HumanMessage(content=[{"type": "text", "text": "/data-analysis analyze uploads/foo.csv"}], id="msg-1")
|
||||
captured = {}
|
||||
|
||||
def handler(model_request: ModelRequest):
|
||||
captured["messages"] = model_request.messages
|
||||
return AIMessage(content="ok")
|
||||
|
||||
result = middleware.wrap_model_call(_make_model_request([original]), handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
activation_msg, user_msg = captured["messages"]
|
||||
assert is_slash_skill_activation_reminder(activation_msg)
|
||||
assert "<user_request>\nanalyze uploads/foo.csv\n</user_request>" in activation_msg.content
|
||||
assert user_msg.content == original.content
|
||||
|
||||
|
||||
def test_skill_activation_middleware_records_activation_audit_event(monkeypatch, tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
recorded = []
|
||||
journal = SimpleNamespace(record_middleware=lambda *args, **kwargs: recorded.append((args, kwargs)))
|
||||
runtime = SimpleNamespace(context={"__run_journal": journal})
|
||||
middleware = SkillActivationMiddleware()
|
||||
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
|
||||
|
||||
def handler(model_request: ModelRequest):
|
||||
return AIMessage(content="ok")
|
||||
|
||||
result = middleware.wrap_model_call(_make_model_request([original], runtime=runtime), handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
assert len(recorded) == 1
|
||||
args, kwargs = recorded[0]
|
||||
assert args == ("skill_activation",)
|
||||
assert kwargs["name"] == "SkillActivationMiddleware"
|
||||
assert kwargs["hook"] == "wrap_model_call"
|
||||
assert kwargs["action"] == "activate"
|
||||
assert kwargs["changes"] == {
|
||||
"skill_name": "data-analysis",
|
||||
"category": "custom",
|
||||
"path": "/mnt/skills/custom/data-analysis/SKILL.md",
|
||||
"content_hash": hashlib.sha256(b"# Data Analysis\nUse pandas.").hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def test_skill_activation_middleware_async_records_activation_audit_event(monkeypatch, tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
recorded = []
|
||||
journal = SimpleNamespace(record_middleware=lambda *args, **kwargs: recorded.append((args, kwargs)))
|
||||
runtime = SimpleNamespace(context={"__run_journal": journal})
|
||||
middleware = SkillActivationMiddleware()
|
||||
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
|
||||
|
||||
async def handler(model_request: ModelRequest):
|
||||
return AIMessage(content="ok")
|
||||
|
||||
result = asyncio.run(middleware.awrap_model_call(_make_model_request([original], runtime=runtime), handler))
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
assert len(recorded) == 1
|
||||
args, kwargs = recorded[0]
|
||||
assert args == ("skill_activation",)
|
||||
assert kwargs["hook"] == "awrap_model_call"
|
||||
assert kwargs["changes"]["skill_name"] == "data-analysis"
|
||||
assert kwargs["changes"]["content_hash"] == hashlib.sha256(b"# Data Analysis\nUse pandas.").hexdigest()
|
||||
|
||||
|
||||
def test_skill_activation_middleware_ignores_activation_audit_errors(monkeypatch, tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
journal = SimpleNamespace(record_middleware=lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("db down")))
|
||||
runtime = SimpleNamespace(context={"__run_journal": journal})
|
||||
middleware = SkillActivationMiddleware()
|
||||
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
|
||||
|
||||
def handler(model_request: ModelRequest):
|
||||
return AIMessage(content="ok")
|
||||
|
||||
result = middleware.wrap_model_call(_make_model_request([original], runtime=runtime), handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
assert result.content == "ok"
|
||||
|
||||
|
||||
def test_skill_activation_middleware_activates_only_latest_real_user_message(monkeypatch, tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
middleware = SkillActivationMiddleware()
|
||||
old_slash = HumanMessage(content="/data-analysis old request", id="msg-1")
|
||||
latest_user = HumanMessage(content="continue normally", id="msg-2")
|
||||
request = _make_model_request([old_slash, AIMessage(content="done"), latest_user])
|
||||
captured = {}
|
||||
|
||||
def handler(model_request: ModelRequest):
|
||||
captured["messages"] = model_request.messages
|
||||
return AIMessage(content="ok")
|
||||
|
||||
result = middleware.wrap_model_call(request, handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
assert captured["messages"] == request.messages
|
||||
assert not any(is_slash_skill_activation_reminder(message) for message in captured["messages"])
|
||||
|
||||
|
||||
def test_skill_activation_middleware_ignores_hidden_and_summary_user_messages(monkeypatch, tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
middleware = SkillActivationMiddleware()
|
||||
real_user = HumanMessage(content="continue normally", id="msg-1")
|
||||
hidden_slash = HumanMessage(content="/data-analysis hidden request", id="msg-2", additional_kwargs={"hide_from_ui": True})
|
||||
summary_slash = HumanMessage(content="/data-analysis summary request", id="msg-3", name="summary")
|
||||
request = _make_model_request([real_user, hidden_slash, summary_slash])
|
||||
captured = {}
|
||||
|
||||
def handler(model_request: ModelRequest):
|
||||
captured["messages"] = model_request.messages
|
||||
return AIMessage(content="ok")
|
||||
|
||||
result = middleware.wrap_model_call(request, handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
assert captured["messages"] == request.messages
|
||||
assert not any(is_slash_skill_activation_reminder(message) for message in captured["messages"])
|
||||
|
||||
|
||||
def test_skill_activation_middleware_returns_clear_error_for_disallowed_skill(monkeypatch, tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis")
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
middleware = SkillActivationMiddleware(available_skills={"frontend-design"})
|
||||
original = HumanMessage(content="/data-analysis run")
|
||||
|
||||
def handler(model_request: ModelRequest):
|
||||
raise AssertionError("handler should not be called for invalid slash skills")
|
||||
|
||||
result = middleware.wrap_model_call(_make_model_request([original]), handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
assert "not available for this agent" in result.content
|
||||
|
||||
|
||||
def test_skill_activation_middleware_returns_clear_error_for_missing_skill(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, []))
|
||||
|
||||
middleware = SkillActivationMiddleware()
|
||||
original = HumanMessage(content="/data-analysis run")
|
||||
|
||||
def handler(model_request: ModelRequest):
|
||||
raise AssertionError("handler should not be called for missing slash skills")
|
||||
|
||||
result = middleware.wrap_model_call(_make_model_request([original]), handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
assert "not installed" in result.content
|
||||
|
||||
|
||||
def test_skill_activation_middleware_returns_clear_error_for_disabled_skill(monkeypatch, tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis")
|
||||
skill.enabled = False
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
middleware = SkillActivationMiddleware()
|
||||
original = HumanMessage(content="/data-analysis run")
|
||||
|
||||
def handler(model_request: ModelRequest):
|
||||
raise AssertionError("handler should not be called for disabled slash skills")
|
||||
|
||||
result = middleware.wrap_model_call(_make_model_request([original]), handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
assert "installed but disabled" in result.content
|
||||
|
||||
|
||||
def test_skill_activation_middleware_escapes_activation_content(monkeypatch, tmp_path):
|
||||
skill = _make_skill(
|
||||
tmp_path,
|
||||
"data-analysis",
|
||||
content="# Data Analysis\nUse <xml> & avoid </skill> collisions.\n----- END SKILL.md -----",
|
||||
)
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
middleware = SkillActivationMiddleware()
|
||||
original = HumanMessage(content="/data-analysis analyze </user_request>")
|
||||
captured = {}
|
||||
|
||||
def handler(model_request: ModelRequest):
|
||||
captured["messages"] = model_request.messages
|
||||
return AIMessage(content="ok")
|
||||
|
||||
result = middleware.wrap_model_call(_make_model_request([original]), handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
activation_msg = captured["messages"][0]
|
||||
assert '<skill_content encoding="xml-escaped">' in activation_msg.content
|
||||
assert "analyze </user_request>" in activation_msg.content
|
||||
assert "Use <xml> & avoid </skill> collisions." in activation_msg.content
|
||||
assert "----- BEGIN SKILL.md -----" not in activation_msg.content
|
||||
|
||||
|
||||
def test_skill_activation_middleware_rejects_skill_file_outside_skills_root(monkeypatch, tmp_path):
|
||||
skills_root = tmp_path / "skills"
|
||||
skill_dir = skills_root / "custom" / "data-analysis"
|
||||
skill_dir.mkdir(parents=True)
|
||||
outside_dir = tmp_path / "outside"
|
||||
outside_dir.mkdir()
|
||||
outside_file = outside_dir / "SKILL.md"
|
||||
outside_file.write_text("# Leaked\nDo not read me.", encoding="utf-8")
|
||||
(skill_dir / "SKILL.md").symlink_to(outside_file)
|
||||
skill = Skill(
|
||||
name="data-analysis",
|
||||
description="Description for data-analysis",
|
||||
license="MIT",
|
||||
skill_dir=skill_dir,
|
||||
skill_file=skill_dir / "SKILL.md",
|
||||
relative_path=Path("data-analysis"),
|
||||
category=SkillCategory.CUSTOM,
|
||||
enabled=True,
|
||||
)
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(skills_root, [skill]))
|
||||
|
||||
middleware = SkillActivationMiddleware()
|
||||
|
||||
def handler(model_request: ModelRequest):
|
||||
raise AssertionError("handler should not be called when SKILL.md fails safety checks")
|
||||
|
||||
result = middleware.wrap_model_call(_make_model_request([HumanMessage(content="/data-analysis run")]), handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
assert "could not be loaded safely" in result.content
|
||||
|
||||
|
||||
def test_skill_activation_middleware_reports_missing_skill_file_safely(monkeypatch, tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis")
|
||||
skill.skill_file.unlink()
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
middleware = SkillActivationMiddleware()
|
||||
|
||||
def handler(model_request: ModelRequest):
|
||||
raise AssertionError("handler should not be called when SKILL.md is missing")
|
||||
|
||||
result = middleware.wrap_model_call(_make_model_request([HumanMessage(content="/data-analysis run")]), handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
assert "could not be loaded safely" in result.content
|
||||
|
||||
|
||||
def test_skill_activation_middleware_reports_invalid_utf8_skill_file_safely(monkeypatch, tmp_path):
|
||||
skill = _make_skill(tmp_path, "data-analysis")
|
||||
skill.skill_file.write_bytes(b"\xff\xfe\x00")
|
||||
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
|
||||
|
||||
middleware = SkillActivationMiddleware()
|
||||
|
||||
def handler(model_request: ModelRequest):
|
||||
raise AssertionError("handler should not be called when SKILL.md is not valid UTF-8")
|
||||
|
||||
result = middleware.wrap_model_call(_make_model_request([HumanMessage(content="/data-analysis run")]), handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
assert "could not be loaded safely" in result.content
|
||||
@@ -14,6 +14,7 @@ from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from deerflow.agents.middlewares.uploads_middleware import UploadsMiddleware
|
||||
from deerflow.config.paths import Paths
|
||||
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
|
||||
|
||||
THREAD_ID = "thread-abc123"
|
||||
|
||||
@@ -263,6 +264,22 @@ class TestBeforeAgent:
|
||||
assert "<uploaded_files>" in combined_text
|
||||
assert "analyse this" in combined_text
|
||||
|
||||
def test_list_content_preserves_original_slash_skill_text(self, tmp_path):
|
||||
mw = _middleware(tmp_path)
|
||||
uploads_dir = _uploads_dir(tmp_path)
|
||||
(uploads_dir / "data.csv").write_bytes(b"a,b")
|
||||
|
||||
msg = _human(
|
||||
[{"type": "text", "text": "/data-analysis analyze data.csv"}],
|
||||
files=[{"filename": "data.csv", "size": 3, "path": "/mnt/user-data/uploads/data.csv"}],
|
||||
)
|
||||
result = mw.before_agent(self._state(msg), _runtime())
|
||||
|
||||
assert result is not None
|
||||
updated_msg = result["messages"][-1]
|
||||
assert isinstance(updated_msg.content, list)
|
||||
assert updated_msg.additional_kwargs[ORIGINAL_USER_CONTENT_KEY] == "/data-analysis analyze data.csv"
|
||||
|
||||
def test_preserves_additional_kwargs_on_updated_message(self, tmp_path):
|
||||
mw = _middleware(tmp_path)
|
||||
uploads_dir = _uploads_dir(tmp_path)
|
||||
@@ -278,6 +295,37 @@ class TestBeforeAgent:
|
||||
assert updated_kwargs.get("files") == files_meta
|
||||
assert updated_kwargs.get("element") == "task"
|
||||
|
||||
def test_preserves_original_user_content_before_upload_context(self, tmp_path):
|
||||
mw = _middleware(tmp_path)
|
||||
uploads_dir = _uploads_dir(tmp_path)
|
||||
(uploads_dir / "report.pdf").write_bytes(b"pdf")
|
||||
|
||||
msg = _human(
|
||||
"/data-analysis 分析这个文档",
|
||||
files=[{"filename": "report.pdf", "size": 3, "path": "/mnt/user-data/uploads/report.pdf"}],
|
||||
)
|
||||
result = mw.before_agent(self._state(msg), _runtime())
|
||||
|
||||
assert result is not None
|
||||
updated_msg = result["messages"][-1]
|
||||
assert updated_msg.content.startswith("<uploaded_files>")
|
||||
assert updated_msg.additional_kwargs[ORIGINAL_USER_CONTENT_KEY] == "/data-analysis 分析这个文档"
|
||||
|
||||
def test_preserves_existing_original_user_content_marker(self, tmp_path):
|
||||
mw = _middleware(tmp_path)
|
||||
uploads_dir = _uploads_dir(tmp_path)
|
||||
(uploads_dir / "report.pdf").write_bytes(b"pdf")
|
||||
|
||||
msg = _human(
|
||||
"<uploaded_files>\nold\n</uploaded_files>\n\n/data-analysis run",
|
||||
files=[{"filename": "report.pdf", "size": 3, "path": "/mnt/user-data/uploads/report.pdf"}],
|
||||
**{ORIGINAL_USER_CONTENT_KEY: "/data-analysis run"},
|
||||
)
|
||||
result = mw.before_agent(self._state(msg), _runtime())
|
||||
|
||||
assert result is not None
|
||||
assert result["messages"][-1].additional_kwargs[ORIGINAL_USER_CONTENT_KEY] == "/data-analysis run"
|
||||
|
||||
def test_uploaded_files_returned_in_state_update(self, tmp_path):
|
||||
mw = _middleware(tmp_path)
|
||||
uploads_dir = _uploads_dir(tmp_path)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Regression for #3459 / #3454 — dev gateway reload-exclude must not crash.
|
||||
|
||||
#3426 switched the dev gateway's ``--reload-exclude`` patterns from relative
|
||||
(``sandbox/``) to absolute (``$REPO_ROOT/backend/sandbox``). uvicorn only
|
||||
excludes such a path directly when it already exists as a directory; otherwise
|
||||
it falls back to ``Path.cwd().glob(pattern)``, and on **Python 3.12**
|
||||
``pathlib.Path.glob()`` raises ``NotImplementedError: Non-relative patterns are
|
||||
unsupported`` for an absolute pattern. ``serve.sh`` created the ``.deer-flow``
|
||||
excludes but not ``backend/sandbox``, so a fresh checkout crashed ``make dev``
|
||||
on startup.
|
||||
|
||||
Two layers of coverage:
|
||||
|
||||
* ``test_*_resolve_*`` exercises uvicorn's real ``resolve_reload_patterns`` to
|
||||
pin the failure mode and the fix's mechanism.
|
||||
* ``test_launcher_precreates_every_absolute_reload_exclude`` enforces the actual
|
||||
invariant on both launchers: every absolute exclude dir is ``mkdir -p``'d
|
||||
before uvicorn starts. This encodes the root cause, so any future absolute
|
||||
exclude that forgets its ``mkdir`` fails here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from uvicorn.config import resolve_reload_patterns
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
LAUNCHERS = {
|
||||
"scripts/serve.sh": REPO_ROOT / "scripts" / "serve.sh",
|
||||
"docker/dev-entrypoint.sh": REPO_ROOT / "docker" / "dev-entrypoint.sh",
|
||||
}
|
||||
|
||||
# Shell terminators / redirects that end a simple command's argument list.
|
||||
_CMD_BOUNDARY = re.compile(r"[;&|<>]")
|
||||
|
||||
|
||||
def _logical_lines(script: str) -> list[str]:
|
||||
"""Fold ``\\``-continuations and drop comment lines, yielding logical lines.
|
||||
|
||||
A ``mkdir`` or ``--reload-exclude`` list split across lines with a trailing
|
||||
backslash becomes one line here, so an argument on a continuation line can't
|
||||
be silently dropped by per-line scanning.
|
||||
"""
|
||||
folded = script.replace("\\\n", " ")
|
||||
return [line for line in folded.splitlines() if not line.lstrip().startswith("#")]
|
||||
|
||||
|
||||
def _shlex(fragment: str) -> list[str]:
|
||||
"""Tokenize a shell fragment (quotes stripped, ``$VAR`` kept literal,
|
||||
trailing ``# comment`` honored); tolerate pathological quoting."""
|
||||
try:
|
||||
return shlex.split(fragment, comments=True)
|
||||
except ValueError:
|
||||
return fragment.split()
|
||||
|
||||
|
||||
# ``--reload-exclude`` followed by ``=`` or whitespace, then a value that is a
|
||||
# single-quoted group, a double-quoted group, or a bare token. The quoted
|
||||
# alternatives match a *balanced* pair first, so serve.sh's surrounding
|
||||
# ``GATEWAY_EXTRA_FLAGS="..."`` closing quote is never swallowed into the value.
|
||||
_RELOAD_EXCLUDE = re.compile(r"""--reload-exclude[=\s]+('[^']*'|"[^"]*"|[^\s'"]+)""")
|
||||
|
||||
|
||||
def _reload_exclude_values(script: str) -> list[str]:
|
||||
"""Every ``--reload-exclude`` value, with surrounding quotes removed.
|
||||
|
||||
Handles both CLI forms (``--reload-exclude=<value>`` and the space form
|
||||
``--reload-exclude <value>``) and both shell quotings the launchers use:
|
||||
|
||||
* ``docker/dev-entrypoint.sh`` puts each flag on its own line.
|
||||
* ``scripts/serve.sh`` packs every flag into a single double-quoted
|
||||
``GATEWAY_EXTRA_FLAGS="... --reload-exclude='$X' ..."`` assignment. A
|
||||
whole-line ``shlex`` would collapse that assignment into one token and
|
||||
find no flags (this is what regressed serve.sh in CI); matching balanced
|
||||
inner quotes here keeps the assignment's closing ``"`` out of the value,
|
||||
so every exclude — including the last ``$BACKEND_RUNTIME_HOME`` — is seen.
|
||||
"""
|
||||
values: list[str] = []
|
||||
for line in _logical_lines(script):
|
||||
for raw in _RELOAD_EXCLUDE.findall(line):
|
||||
values.append(raw.strip("\"'"))
|
||||
return values
|
||||
|
||||
|
||||
def _mkdir_dirs(script: str) -> set[str]:
|
||||
"""Exact set of directories created by every ``mkdir`` command.
|
||||
|
||||
Tokenizes each ``mkdir`` argument list rather than substring-matching, so
|
||||
``/app/backend/sandbox`` is not falsely considered created by, say,
|
||||
``mkdir -p /app/backend/sandbox-other``.
|
||||
"""
|
||||
dirs: set[str] = set()
|
||||
for line in _logical_lines(script):
|
||||
match = re.search(r"\bmkdir\b(.*)", line)
|
||||
if not match:
|
||||
continue
|
||||
args = _CMD_BOUNDARY.split(match.group(1), maxsplit=1)[0]
|
||||
for token in _shlex(args):
|
||||
if token.startswith("-"): # skip flags such as -p
|
||||
continue
|
||||
dirs.add(token)
|
||||
return dirs
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info >= (3, 13),
|
||||
reason="pathlib accepts absolute glob patterns on 3.13+, so the crash is 3.12-only",
|
||||
)
|
||||
def test_resolve_reload_patterns_crashes_on_missing_absolute_dir(tmp_path):
|
||||
"""The exact #3454 failure: absolute exclude + missing dir on Python 3.12."""
|
||||
missing = tmp_path / "sandbox" # absolute path that does not exist yet
|
||||
assert not missing.exists()
|
||||
with pytest.raises(NotImplementedError):
|
||||
resolve_reload_patterns([str(missing)], [])
|
||||
|
||||
|
||||
def test_resolve_reload_patterns_is_safe_once_dir_exists(tmp_path):
|
||||
"""The fix's mechanism: a pre-created dir takes uvicorn's is_dir() path."""
|
||||
sandbox = tmp_path / "sandbox"
|
||||
sandbox.mkdir()
|
||||
_patterns, directories = resolve_reload_patterns([str(sandbox)], [])
|
||||
resolved = {d.resolve() for d in directories}
|
||||
assert sandbox.resolve() in resolved
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(LAUNCHERS))
|
||||
def test_launcher_precreates_every_absolute_reload_exclude(name):
|
||||
"""Every absolute ``--reload-exclude`` dir must be created by ``mkdir`` first.
|
||||
|
||||
Relative glob patterns (``*.pyc``, ``__pycache__``) are safe and skipped;
|
||||
anything anchored at ``/`` or a shell variable is an absolute path that
|
||||
uvicorn would glob — and crash on — unless it already exists. Membership is
|
||||
an exact match against the parsed ``mkdir`` argument set (not a substring
|
||||
test), so a path-prefix can't produce a false pass.
|
||||
"""
|
||||
script = LAUNCHERS[name].read_text(encoding="utf-8")
|
||||
created = _mkdir_dirs(script)
|
||||
|
||||
absolute_excludes = [v for v in _reload_exclude_values(script) if v.startswith(("/", "$"))]
|
||||
assert absolute_excludes, f"{name}: expected at least one absolute reload-exclude"
|
||||
|
||||
for value in absolute_excludes:
|
||||
assert value in created, f"{name}: absolute reload-exclude {value!r} is never created via mkdir (created dirs: {sorted(created)})"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(LAUNCHERS))
|
||||
def test_sandbox_mkdir_precedes_uvicorn_launch(name):
|
||||
"""The sandbox mkdir must come before the uvicorn launch, not just exist.
|
||||
|
||||
``_mkdir_dirs`` only proves the mkdir is present somewhere; this pins script
|
||||
order so a future edit can't move (or guard) the mkdir below the launch and
|
||||
silently reintroduce the #3454 crash on a fresh checkout. ``uv run uvicorn``
|
||||
matches the launch but not serve.sh's ``stop_all`` kill line.
|
||||
"""
|
||||
lines = LAUNCHERS[name].read_text(encoding="utf-8").splitlines()
|
||||
launch_idx = next((i for i, ln in enumerate(lines) if "uv run uvicorn" in ln), None)
|
||||
mkdir_idx = next((i for i, ln in enumerate(lines) if re.search(r"\bmkdir\b", ln) and "sandbox" in ln), None)
|
||||
|
||||
assert launch_idx is not None, f"{name}: could not locate the 'uv run uvicorn' launch line"
|
||||
assert mkdir_idx is not None, f"{name}: could not locate the sandbox mkdir line"
|
||||
assert mkdir_idx < launch_idx, f"{name}: sandbox mkdir (line {mkdir_idx + 1}) must precede uvicorn launch (line {launch_idx + 1})"
|
||||
|
||||
|
||||
def test_precreated_sandbox_artifacts_are_gitignored():
|
||||
"""backend/sandbox is runtime state — its contents must stay out of git so
|
||||
sandbox artifacts can't be accidentally committed (matches the reload-exclude
|
||||
intent). A content path is existence-independent, unlike the bare dir path.
|
||||
|
||||
Guards against the inaccurate "gitignored" claim by making it verifiable.
|
||||
"""
|
||||
probe = "backend/sandbox/__artifact_probe__"
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(REPO_ROOT), "check-ignore", "-q", probe],
|
||||
capture_output=True,
|
||||
)
|
||||
if result.returncode == 128: # not a git checkout (e.g. packaged install)
|
||||
pytest.skip("not inside a git working tree")
|
||||
assert result.returncode == 0, "backend/sandbox/* should be gitignored (see backend/.gitignore '/sandbox/')"
|
||||
Reference in New Issue
Block a user