mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(acp): fail fast when an ACP agent exits and drain its stderr An ACP agent that exited before answering (missing credentials, a wrong argument) left invoke_acp_agent waiting for the whole timeout_seconds budget (30 minutes by default) and then suggested raising the timeout. The pinned SDK never fails pending requests when the agent's stdout reaches EOF, so nothing noticed the process was gone. The SDK also opens the agent's stderr as a pipe and never reads it. An agent that logged more than asyncio's pipe buffering absorbs blocked on its next stderr write and stalled the invocation the same way. Race the ACP exchange against the agent process: when it exits (or a newer SDK reports the connection closed), allow a short grace for responses still buffered in the pipe, then report the exit code. Drain stderr continuously into a bounded tail that is logged on failure and kept out of model-visible text. * fix(acp): log the full stderr tail on timeout and pin tail retention Log the timeout tail after the SDK context has terminated and reaped the agent, so lines still in flight when it stalled are included. Make the exit test write more stderr than the retained tail, so keeping the start of the stream instead of its end fails the assertion on the fatal line.
1530 lines
58 KiB
Python
1530 lines
58 KiB
Python
"""Tests for the built-in ACP invocation tool."""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from deerflow.config.acp_config import ACPAgentConfig
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig, reset_extensions_config, set_extensions_config
|
|
from deerflow.tools.builtins.invoke_acp_agent_tool import (
|
|
_agent_path,
|
|
_build_acp_mcp_servers,
|
|
_build_mcp_servers,
|
|
_build_permission_response,
|
|
_format_invocation_error,
|
|
_get_work_dir,
|
|
_resolve_agent_command,
|
|
build_invoke_acp_agent_tool,
|
|
)
|
|
from deerflow.tools.tools import get_available_tools
|
|
|
|
|
|
class _LiveAgentProcess:
|
|
"""Process double for an agent that stays alive for the whole ACP exchange."""
|
|
|
|
stderr = None
|
|
returncode = None
|
|
|
|
async def wait(self) -> int:
|
|
await asyncio.Event().wait()
|
|
|
|
|
|
def test_build_mcp_servers_filters_disabled_and_maps_transports():
|
|
set_extensions_config(ExtensionsConfig(mcp_servers={"stale": McpServerConfig(enabled=True, type="stdio", command="echo")}, skills={}))
|
|
fresh_config = ExtensionsConfig(
|
|
mcp_servers={
|
|
"stdio": McpServerConfig(enabled=True, type="stdio", command="npx", args=["srv"]),
|
|
"http": McpServerConfig(enabled=True, type="http", url="https://example.com/mcp"),
|
|
"disabled": McpServerConfig(enabled=False, type="stdio", command="echo"),
|
|
},
|
|
skills={},
|
|
)
|
|
monkeypatch = pytest.MonkeyPatch()
|
|
monkeypatch.setattr(
|
|
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
|
classmethod(lambda cls: fresh_config),
|
|
)
|
|
|
|
try:
|
|
assert _build_mcp_servers() == {
|
|
"stdio": {"transport": "stdio", "command": "npx", "args": ["srv"]},
|
|
"http": {"transport": "http", "url": "https://example.com/mcp"},
|
|
}
|
|
finally:
|
|
monkeypatch.undo()
|
|
reset_extensions_config()
|
|
|
|
|
|
def test_build_acp_mcp_servers_formats_list_payload():
|
|
set_extensions_config(ExtensionsConfig(mcp_servers={"stale": McpServerConfig(enabled=True, type="stdio", command="echo")}, skills={}))
|
|
fresh_config = ExtensionsConfig(
|
|
mcp_servers={
|
|
"stdio": McpServerConfig(enabled=True, type="stdio", command="npx", args=["srv"], env={"FOO": "bar"}),
|
|
"http": McpServerConfig(enabled=True, type="http", url="https://example.com/mcp", headers={"Authorization": "Bearer token"}),
|
|
"disabled": McpServerConfig(enabled=False, type="stdio", command="echo"),
|
|
},
|
|
skills={},
|
|
)
|
|
monkeypatch = pytest.MonkeyPatch()
|
|
monkeypatch.setattr(
|
|
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
|
classmethod(lambda cls: fresh_config),
|
|
)
|
|
|
|
try:
|
|
assert _build_acp_mcp_servers() == [
|
|
{
|
|
"name": "stdio",
|
|
"type": "stdio",
|
|
"command": "npx",
|
|
"args": ["srv"],
|
|
"env": [{"name": "FOO", "value": "bar"}],
|
|
},
|
|
{
|
|
"name": "http",
|
|
"type": "http",
|
|
"url": "https://example.com/mcp",
|
|
"headers": [{"name": "Authorization", "value": "Bearer token"}],
|
|
},
|
|
]
|
|
finally:
|
|
monkeypatch.undo()
|
|
reset_extensions_config()
|
|
|
|
|
|
def test_build_permission_response_prefers_allow_once():
|
|
response = _build_permission_response(
|
|
[
|
|
SimpleNamespace(kind="reject_once", optionId="deny"),
|
|
SimpleNamespace(kind="allow_always", optionId="always"),
|
|
SimpleNamespace(kind="allow_once", optionId="once"),
|
|
],
|
|
auto_approve=True,
|
|
)
|
|
|
|
assert response.outcome.outcome == "selected"
|
|
assert response.outcome.option_id == "once"
|
|
|
|
|
|
def test_build_permission_response_denies_when_no_allow_option():
|
|
response = _build_permission_response(
|
|
[
|
|
SimpleNamespace(kind="reject_once", optionId="deny"),
|
|
SimpleNamespace(kind="reject_always", optionId="deny-forever"),
|
|
],
|
|
auto_approve=True,
|
|
)
|
|
|
|
assert response.outcome.outcome == "cancelled"
|
|
|
|
|
|
def test_build_permission_response_denies_when_auto_approve_false():
|
|
"""P1.2: When auto_approve=False, permission is always denied regardless of options."""
|
|
response = _build_permission_response(
|
|
[
|
|
SimpleNamespace(kind="allow_once", optionId="once"),
|
|
SimpleNamespace(kind="allow_always", optionId="always"),
|
|
],
|
|
auto_approve=False,
|
|
)
|
|
|
|
assert response.outcome.outcome == "cancelled"
|
|
|
|
|
|
def test_missing_mcode_command_returns_install_and_login_guidance():
|
|
result = _format_invocation_error("mcode", "mcode", FileNotFoundError())
|
|
|
|
assert "npm install --global @minimax-ai/code" in result
|
|
assert "mcode login" in result
|
|
assert "restart DeerFlow" in result
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_build_invoke_tool_description_and_unknown_agent_error():
|
|
tool = build_invoke_acp_agent_tool(
|
|
{
|
|
"codex": ACPAgentConfig(command="codex-acp", description="Codex CLI"),
|
|
"claude_code": ACPAgentConfig(command="claude-code-acp", description="Claude Code"),
|
|
}
|
|
)
|
|
|
|
assert "Available agents:" in tool.description
|
|
assert "- codex: Codex CLI" in tool.description
|
|
assert "- claude_code: Claude Code" in tool.description
|
|
assert "Do NOT include /mnt/user-data paths" in tool.description
|
|
assert "/mnt/acp-workspace/" in tool.description
|
|
|
|
result = await tool.coroutine(agent="missing", prompt="do work")
|
|
assert result == "Error: Unknown agent 'missing'. Available: codex, claude_code"
|
|
|
|
|
|
def test_get_work_dir_uses_base_dir_when_no_thread_id(monkeypatch, tmp_path):
|
|
"""_get_work_dir(None) uses {base_dir}/acp-workspace/ (global fallback)."""
|
|
from deerflow.config import paths as paths_module
|
|
|
|
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
|
result = _get_work_dir(None)
|
|
expected = tmp_path / "acp-workspace"
|
|
assert result == str(expected)
|
|
assert expected.exists()
|
|
|
|
|
|
def test_get_work_dir_uses_per_thread_path_when_thread_id_given(monkeypatch, tmp_path):
|
|
"""P1.1: _get_work_dir(thread_id) uses {base_dir}/threads/{thread_id}/acp-workspace/."""
|
|
from deerflow.config import paths as paths_module
|
|
from deerflow.runtime import user_context as uc_module
|
|
|
|
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(uc_module, "get_effective_user_id", lambda: None)
|
|
result = _get_work_dir("thread-abc-123")
|
|
expected = tmp_path / "threads" / "thread-abc-123" / "acp-workspace"
|
|
assert result == str(expected)
|
|
assert expected.exists()
|
|
|
|
|
|
def test_get_work_dir_falls_back_to_global_for_invalid_thread_id(monkeypatch, tmp_path):
|
|
"""P1.1: Invalid thread_id (e.g. path traversal chars) falls back to global workspace."""
|
|
from deerflow.config import paths as paths_module
|
|
|
|
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
|
result = _get_work_dir("../../evil")
|
|
expected = tmp_path / "acp-workspace"
|
|
assert result == str(expected)
|
|
assert expected.exists()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invoke_acp_agent_uses_fixed_acp_workspace(monkeypatch, tmp_path):
|
|
"""ACP agent uses {base_dir}/acp-workspace/ when no thread_id is available (no config)."""
|
|
from deerflow.config import paths as paths_module
|
|
|
|
# An empty PATH directory keeps the configured command unresolvable, so the
|
|
# spawn assertion below checks the configuration rather than the host PATH.
|
|
monkeypatch.setenv("PATH", str(tmp_path / "empty-bin"))
|
|
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
|
|
|
monkeypatch.setattr(
|
|
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
|
classmethod(
|
|
lambda cls: ExtensionsConfig(
|
|
mcp_servers={"github": McpServerConfig(enabled=True, type="stdio", command="npx", args=["github-mcp"])},
|
|
skills={},
|
|
)
|
|
),
|
|
)
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
class DummyClient:
|
|
def __init__(self) -> None:
|
|
self._chunks: list[str] = []
|
|
|
|
@property
|
|
def collected_text(self) -> str:
|
|
return "".join(self._chunks)
|
|
|
|
async def session_update(self, session_id: str, update, **kwargs) -> None:
|
|
if hasattr(update, "content") and hasattr(update.content, "text"):
|
|
self._chunks.append(update.content.text)
|
|
|
|
async def request_permission(self, options, session_id: str, tool_call, **kwargs):
|
|
raise AssertionError("request_permission should not be called in this test")
|
|
|
|
class DummyConn:
|
|
async def initialize(self, **kwargs):
|
|
captured["initialize"] = kwargs
|
|
|
|
async def new_session(self, **kwargs):
|
|
captured["new_session"] = kwargs
|
|
return SimpleNamespace(session_id="session-1")
|
|
|
|
async def prompt(self, **kwargs):
|
|
captured["prompt"] = kwargs
|
|
client = captured["client"]
|
|
await client.session_update(
|
|
"session-1",
|
|
SimpleNamespace(
|
|
session_update="agent_thought_chunk",
|
|
content=text_content_block("internal reasoning"),
|
|
),
|
|
)
|
|
await client.session_update(
|
|
"session-1",
|
|
SimpleNamespace(
|
|
session_update="agent_message_chunk",
|
|
content=text_content_block("ACP result"),
|
|
),
|
|
)
|
|
|
|
class DummyProcessContext:
|
|
def __init__(self, client, cmd, *args, cwd):
|
|
captured["client"] = client
|
|
captured["spawn"] = {"cmd": cmd, "args": list(args), "cwd": cwd}
|
|
|
|
async def __aenter__(self):
|
|
return DummyConn(), _LiveAgentProcess()
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
class DummyRequestError(Exception):
|
|
@staticmethod
|
|
def method_not_found(method: str):
|
|
return DummyRequestError(method)
|
|
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"acp",
|
|
SimpleNamespace(
|
|
PROTOCOL_VERSION="2026-03-24",
|
|
Client=DummyClient,
|
|
RequestError=DummyRequestError,
|
|
spawn_agent_process=lambda client, cmd, *args, env=None, cwd: DummyProcessContext(client, cmd, *args, cwd=cwd),
|
|
text_block=lambda text: {"type": "text", "text": text},
|
|
),
|
|
)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"acp.schema",
|
|
SimpleNamespace(
|
|
ClientCapabilities=lambda: {"supports": []},
|
|
Implementation=lambda **kwargs: kwargs,
|
|
TextContentBlock=type(
|
|
"TextContentBlock",
|
|
(),
|
|
{"__init__": lambda self, text: setattr(self, "text", text)},
|
|
),
|
|
),
|
|
)
|
|
text_content_block = sys.modules["acp.schema"].TextContentBlock
|
|
|
|
expected_cwd = str(tmp_path / "acp-workspace")
|
|
|
|
tool = build_invoke_acp_agent_tool(
|
|
{
|
|
"codex": ACPAgentConfig(
|
|
command="codex-acp",
|
|
args=["--json"],
|
|
description="Codex CLI",
|
|
model="gpt-5-codex",
|
|
)
|
|
}
|
|
)
|
|
|
|
try:
|
|
result = await tool.coroutine(
|
|
agent="codex",
|
|
prompt="Implement the fix",
|
|
)
|
|
finally:
|
|
sys.modules.pop("acp", None)
|
|
sys.modules.pop("acp.schema", None)
|
|
|
|
assert result == "ACP result"
|
|
assert captured["spawn"] == {"cmd": "codex-acp", "args": ["--json"], "cwd": expected_cwd}
|
|
assert captured["new_session"] == {
|
|
"cwd": expected_cwd,
|
|
"mcp_servers": [
|
|
{
|
|
"name": "github",
|
|
"type": "stdio",
|
|
"command": "npx",
|
|
"args": ["github-mcp"],
|
|
"env": [],
|
|
}
|
|
],
|
|
"model": "gpt-5-codex",
|
|
}
|
|
assert captured["prompt"] == {
|
|
"session_id": "session-1",
|
|
"prompt": [{"type": "text", "text": "Implement the fix"}],
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invoke_acp_agent_uses_per_thread_workspace_when_thread_id_in_config(monkeypatch, tmp_path):
|
|
"""P1.1: When thread_id is in the RunnableConfig, ACP agent uses per-thread workspace."""
|
|
from deerflow.config import paths as paths_module
|
|
from deerflow.runtime import user_context as uc_module
|
|
|
|
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(uc_module, "get_effective_user_id", lambda: None)
|
|
|
|
monkeypatch.setattr(
|
|
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
|
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
|
)
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
class DummyClient:
|
|
def __init__(self) -> None:
|
|
self._chunks: list[str] = []
|
|
|
|
@property
|
|
def collected_text(self) -> str:
|
|
return "".join(self._chunks)
|
|
|
|
async def session_update(self, session_id, update, **kwargs):
|
|
pass
|
|
|
|
async def request_permission(self, options, session_id, tool_call, **kwargs):
|
|
raise AssertionError("should not be called")
|
|
|
|
class DummyConn:
|
|
async def initialize(self, **kwargs):
|
|
pass
|
|
|
|
async def new_session(self, **kwargs):
|
|
captured["new_session"] = kwargs
|
|
return SimpleNamespace(session_id="s1")
|
|
|
|
async def prompt(self, **kwargs):
|
|
pass
|
|
|
|
class DummyProcessContext:
|
|
def __init__(self, client, cmd, *args, cwd):
|
|
captured["cwd"] = cwd
|
|
|
|
async def __aenter__(self):
|
|
return DummyConn(), _LiveAgentProcess()
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
class DummyRequestError(Exception):
|
|
@staticmethod
|
|
def method_not_found(method):
|
|
return DummyRequestError(method)
|
|
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"acp",
|
|
SimpleNamespace(
|
|
PROTOCOL_VERSION="2026-03-24",
|
|
Client=DummyClient,
|
|
RequestError=DummyRequestError,
|
|
spawn_agent_process=lambda client, cmd, *args, env=None, cwd: DummyProcessContext(client, cmd, *args, cwd=cwd),
|
|
text_block=lambda text: {"type": "text", "text": text},
|
|
),
|
|
)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"acp.schema",
|
|
SimpleNamespace(
|
|
ClientCapabilities=lambda: {},
|
|
Implementation=lambda **kwargs: kwargs,
|
|
TextContentBlock=type("TextContentBlock", (), {"__init__": lambda self, text: setattr(self, "text", text)}),
|
|
),
|
|
)
|
|
|
|
thread_id = "thread-xyz-789"
|
|
expected_cwd = str(tmp_path / "threads" / thread_id / "acp-workspace")
|
|
|
|
tool = build_invoke_acp_agent_tool({"codex": ACPAgentConfig(command="codex-acp", description="Codex CLI")})
|
|
|
|
try:
|
|
await tool.coroutine(
|
|
agent="codex",
|
|
prompt="Do something",
|
|
config={"configurable": {"thread_id": thread_id}},
|
|
)
|
|
finally:
|
|
sys.modules.pop("acp", None)
|
|
sys.modules.pop("acp.schema", None)
|
|
|
|
assert captured["cwd"] == expected_cwd
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invoke_acp_agent_passes_env_to_spawn(monkeypatch, tmp_path):
|
|
"""env map in ACPAgentConfig reaches spawn_agent_process exactly as AppConfig resolved it."""
|
|
from deerflow.config import paths as paths_module
|
|
|
|
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(
|
|
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
|
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
|
)
|
|
# A resolved secret that itself starts with $ must not be resolved again.
|
|
monkeypatch.setenv("TEST_OPENAI_KEY", "$2b$12$from-env")
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
class DummyClient:
|
|
def __init__(self) -> None:
|
|
self._chunks: list[str] = []
|
|
|
|
@property
|
|
def collected_text(self) -> str:
|
|
return ""
|
|
|
|
async def session_update(self, session_id, update, **kwargs):
|
|
pass
|
|
|
|
async def request_permission(self, options, session_id, tool_call, **kwargs):
|
|
raise AssertionError("should not be called")
|
|
|
|
class DummyConn:
|
|
async def initialize(self, **kwargs):
|
|
pass
|
|
|
|
async def new_session(self, **kwargs):
|
|
return SimpleNamespace(session_id="s1")
|
|
|
|
async def prompt(self, **kwargs):
|
|
pass
|
|
|
|
class DummyProcessContext:
|
|
def __init__(self, client, cmd, *args, env=None, cwd):
|
|
captured["env"] = env
|
|
|
|
async def __aenter__(self):
|
|
return DummyConn(), _LiveAgentProcess()
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
class DummyRequestError(Exception):
|
|
@staticmethod
|
|
def method_not_found(method):
|
|
return DummyRequestError(method)
|
|
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"acp",
|
|
SimpleNamespace(
|
|
PROTOCOL_VERSION="2026-03-24",
|
|
Client=DummyClient,
|
|
RequestError=DummyRequestError,
|
|
spawn_agent_process=lambda client, cmd, *args, env=None, cwd: DummyProcessContext(client, cmd, *args, env=env, cwd=cwd),
|
|
text_block=lambda text: {"type": "text", "text": text},
|
|
),
|
|
)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"acp.schema",
|
|
SimpleNamespace(
|
|
ClientCapabilities=lambda: {},
|
|
Implementation=lambda **kwargs: kwargs,
|
|
TextContentBlock=type("TextContentBlock", (), {"__init__": lambda self, text: setattr(self, "text", text)}),
|
|
),
|
|
)
|
|
|
|
tool = build_invoke_acp_agent_tool(
|
|
{
|
|
"codex": ACPAgentConfig(
|
|
command="codex-acp",
|
|
description="Codex CLI",
|
|
env=AppConfig.resolve_env_variables({"OPENAI_API_KEY": "$TEST_OPENAI_KEY", "FOO": "bar"}),
|
|
)
|
|
}
|
|
)
|
|
|
|
try:
|
|
await tool.coroutine(agent="codex", prompt="Do something")
|
|
finally:
|
|
sys.modules.pop("acp", None)
|
|
sys.modules.pop("acp.schema", None)
|
|
|
|
assert captured["env"] == {"OPENAI_API_KEY": "$2b$12$from-env", "FOO": "bar"}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invoke_acp_agent_skips_invalid_mcp_servers(monkeypatch, tmp_path, caplog):
|
|
"""Invalid MCP config should be logged and skipped instead of failing ACP invocation."""
|
|
from deerflow.config import paths as paths_module
|
|
|
|
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(
|
|
"deerflow.tools.builtins.invoke_acp_agent_tool._build_acp_mcp_servers",
|
|
lambda: (_ for _ in ()).throw(ValueError("missing command")),
|
|
)
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
class DummyClient:
|
|
def __init__(self) -> None:
|
|
self._chunks: list[str] = []
|
|
|
|
@property
|
|
def collected_text(self) -> str:
|
|
return ""
|
|
|
|
async def session_update(self, session_id, update, **kwargs):
|
|
pass
|
|
|
|
async def request_permission(self, options, session_id, tool_call, **kwargs):
|
|
raise AssertionError("should not be called")
|
|
|
|
class DummyConn:
|
|
async def initialize(self, **kwargs):
|
|
pass
|
|
|
|
async def new_session(self, **kwargs):
|
|
captured["new_session"] = kwargs
|
|
return SimpleNamespace(session_id="s1")
|
|
|
|
async def prompt(self, **kwargs):
|
|
pass
|
|
|
|
class DummyProcessContext:
|
|
def __init__(self, client, cmd, *args, env=None, cwd=None):
|
|
captured["spawn"] = {"cmd": cmd, "args": list(args), "env": env, "cwd": cwd}
|
|
|
|
async def __aenter__(self):
|
|
return DummyConn(), _LiveAgentProcess()
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
class DummyRequestError(Exception):
|
|
@staticmethod
|
|
def method_not_found(method):
|
|
return DummyRequestError(method)
|
|
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"acp",
|
|
SimpleNamespace(
|
|
PROTOCOL_VERSION="2026-03-24",
|
|
Client=DummyClient,
|
|
RequestError=DummyRequestError,
|
|
spawn_agent_process=lambda client, cmd, *args, env=None, cwd: DummyProcessContext(client, cmd, *args, env=env, cwd=cwd),
|
|
text_block=lambda text: {"type": "text", "text": text},
|
|
),
|
|
)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"acp.schema",
|
|
SimpleNamespace(
|
|
ClientCapabilities=lambda: {},
|
|
Implementation=lambda **kwargs: kwargs,
|
|
TextContentBlock=type("TextContentBlock", (), {"__init__": lambda self, text: setattr(self, "text", text)}),
|
|
),
|
|
)
|
|
|
|
tool = build_invoke_acp_agent_tool({"codex": ACPAgentConfig(command="codex-acp", description="Codex CLI")})
|
|
caplog.set_level("WARNING")
|
|
|
|
try:
|
|
await tool.coroutine(agent="codex", prompt="Do something")
|
|
finally:
|
|
sys.modules.pop("acp", None)
|
|
sys.modules.pop("acp.schema", None)
|
|
|
|
assert captured["new_session"]["mcp_servers"] == []
|
|
assert "continuing without MCP servers" in caplog.text
|
|
assert "missing command" in caplog.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invoke_acp_agent_passes_none_env_when_not_configured(monkeypatch, tmp_path):
|
|
"""When env is empty, None is passed to spawn_agent_process (subprocess inherits parent env)."""
|
|
from deerflow.config import paths as paths_module
|
|
|
|
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(
|
|
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
|
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
|
)
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
class DummyClient:
|
|
def __init__(self) -> None:
|
|
self._chunks: list[str] = []
|
|
|
|
@property
|
|
def collected_text(self) -> str:
|
|
return ""
|
|
|
|
async def session_update(self, session_id, update, **kwargs):
|
|
pass
|
|
|
|
async def request_permission(self, options, session_id, tool_call, **kwargs):
|
|
raise AssertionError("should not be called")
|
|
|
|
class DummyConn:
|
|
async def initialize(self, **kwargs):
|
|
pass
|
|
|
|
async def new_session(self, **kwargs):
|
|
return SimpleNamespace(session_id="s1")
|
|
|
|
async def prompt(self, **kwargs):
|
|
pass
|
|
|
|
class DummyProcessContext:
|
|
def __init__(self, client, cmd, *args, env=None, cwd):
|
|
captured["env"] = env
|
|
|
|
async def __aenter__(self):
|
|
return DummyConn(), _LiveAgentProcess()
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
class DummyRequestError(Exception):
|
|
@staticmethod
|
|
def method_not_found(method):
|
|
return DummyRequestError(method)
|
|
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"acp",
|
|
SimpleNamespace(
|
|
PROTOCOL_VERSION="2026-03-24",
|
|
Client=DummyClient,
|
|
RequestError=DummyRequestError,
|
|
spawn_agent_process=lambda client, cmd, *args, env=None, cwd: DummyProcessContext(client, cmd, *args, env=env, cwd=cwd),
|
|
text_block=lambda text: {"type": "text", "text": text},
|
|
),
|
|
)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"acp.schema",
|
|
SimpleNamespace(
|
|
ClientCapabilities=lambda: {},
|
|
Implementation=lambda **kwargs: kwargs,
|
|
TextContentBlock=type("TextContentBlock", (), {"__init__": lambda self, text: setattr(self, "text", text)}),
|
|
),
|
|
)
|
|
|
|
tool = build_invoke_acp_agent_tool({"codex": ACPAgentConfig(command="codex-acp", description="Codex CLI")})
|
|
|
|
try:
|
|
await tool.coroutine(agent="codex", prompt="Do something")
|
|
finally:
|
|
sys.modules.pop("acp", None)
|
|
sys.modules.pop("acp.schema", None)
|
|
|
|
assert captured["env"] is None
|
|
|
|
|
|
def test_get_available_tools_includes_invoke_acp_agent_when_agents_configured(monkeypatch):
|
|
from deerflow.config.acp_config import load_acp_config_from_dict
|
|
|
|
load_acp_config_from_dict(
|
|
{
|
|
"codex": {
|
|
"command": "codex-acp",
|
|
"args": [],
|
|
"description": "Codex CLI",
|
|
}
|
|
}
|
|
)
|
|
|
|
fake_config = SimpleNamespace(
|
|
tools=[],
|
|
models=[],
|
|
tool_search=SimpleNamespace(enabled=False),
|
|
get_model_config=lambda name: None,
|
|
)
|
|
monkeypatch.setattr("deerflow.tools.tools.get_app_config", lambda: fake_config)
|
|
monkeypatch.setattr(
|
|
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
|
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
|
)
|
|
|
|
tools = get_available_tools(include_mcp=True, subagent_enabled=False)
|
|
assert "invoke_acp_agent" in [tool.name for tool in tools]
|
|
|
|
load_acp_config_from_dict({})
|
|
|
|
|
|
def test_get_available_tools_sync_invoke_acp_agent_preserves_thread_workspace(monkeypatch, tmp_path):
|
|
from deerflow.config import paths as paths_module
|
|
from deerflow.runtime import user_context as uc_module
|
|
|
|
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(uc_module, "get_effective_user_id", lambda: None)
|
|
monkeypatch.setattr(
|
|
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
|
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
|
)
|
|
monkeypatch.setattr("deerflow.tools.tools.is_host_bash_allowed", lambda config=None: True)
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
class DummyClient:
|
|
@property
|
|
def collected_text(self) -> str:
|
|
return "ok"
|
|
|
|
async def session_update(self, session_id, update, **kwargs):
|
|
pass
|
|
|
|
async def request_permission(self, options, session_id, tool_call, **kwargs):
|
|
raise AssertionError("should not be called")
|
|
|
|
class DummyConn:
|
|
async def initialize(self, **kwargs):
|
|
pass
|
|
|
|
async def new_session(self, **kwargs):
|
|
return SimpleNamespace(session_id="s1")
|
|
|
|
async def prompt(self, **kwargs):
|
|
pass
|
|
|
|
class DummyProcessContext:
|
|
def __init__(self, client, cmd, *args, env=None, cwd):
|
|
captured["cwd"] = cwd
|
|
|
|
async def __aenter__(self):
|
|
return DummyConn(), _LiveAgentProcess()
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"acp",
|
|
SimpleNamespace(
|
|
PROTOCOL_VERSION="2026-03-24",
|
|
Client=DummyClient,
|
|
spawn_agent_process=lambda client, cmd, *args, env=None, cwd: DummyProcessContext(client, cmd, *args, env=env, cwd=cwd),
|
|
text_block=lambda text: {"type": "text", "text": text},
|
|
),
|
|
)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"acp.schema",
|
|
SimpleNamespace(
|
|
ClientCapabilities=lambda: {},
|
|
Implementation=lambda **kwargs: kwargs,
|
|
TextContentBlock=type("TextContentBlock", (), {"__init__": lambda self, text: setattr(self, "text", text)}),
|
|
),
|
|
)
|
|
|
|
explicit_config = SimpleNamespace(
|
|
tools=[],
|
|
models=[],
|
|
tool_search=SimpleNamespace(enabled=False),
|
|
skill_evolution=SimpleNamespace(enabled=False),
|
|
sandbox=SimpleNamespace(),
|
|
get_model_config=lambda name: None,
|
|
acp_agents={"codex": ACPAgentConfig(command="codex-acp", description="Codex CLI")},
|
|
)
|
|
tools = get_available_tools(include_mcp=False, subagent_enabled=False, app_config=explicit_config)
|
|
tool = next(tool for tool in tools if tool.name == "invoke_acp_agent")
|
|
|
|
thread_id = "thread-sync-123"
|
|
tool.invoke(
|
|
{"agent": "codex", "prompt": "Do something"},
|
|
config={"configurable": {"thread_id": thread_id}},
|
|
)
|
|
|
|
assert captured["cwd"] == str(tmp_path / "threads" / thread_id / "acp-workspace")
|
|
|
|
|
|
def test_get_available_tools_uses_explicit_app_config_for_acp_agents(monkeypatch):
|
|
explicit_agents = {"codex": ACPAgentConfig(command="codex-acp", description="Codex CLI")}
|
|
explicit_config = SimpleNamespace(
|
|
tools=[],
|
|
models=[],
|
|
tool_search=SimpleNamespace(enabled=False),
|
|
skill_evolution=SimpleNamespace(enabled=False),
|
|
get_model_config=lambda name: None,
|
|
acp_agents=explicit_agents,
|
|
)
|
|
sentinel_tool = SimpleNamespace(name="invoke_acp_agent")
|
|
captured: dict[str, object] = {}
|
|
|
|
def fail_get_acp_agents():
|
|
raise AssertionError("ambient get_acp_agents() must not be used when app_config is explicit")
|
|
|
|
def fake_build_invoke_acp_agent_tool(agents):
|
|
captured["agents"] = agents
|
|
return sentinel_tool
|
|
|
|
monkeypatch.setattr("deerflow.tools.tools.is_host_bash_allowed", lambda config=None: True)
|
|
monkeypatch.setattr("deerflow.config.acp_config.get_acp_agents", fail_get_acp_agents)
|
|
monkeypatch.setattr("deerflow.tools.builtins.invoke_acp_agent_tool.build_invoke_acp_agent_tool", fake_build_invoke_acp_agent_tool)
|
|
|
|
tools = get_available_tools(include_mcp=False, subagent_enabled=False, app_config=explicit_config)
|
|
|
|
assert captured["agents"] is explicit_agents
|
|
assert "invoke_acp_agent" in [tool.name for tool in tools]
|
|
|
|
|
|
# A real ACP subprocess can stop responding at any protocol phase. Keep the
|
|
# SDK's transport and cleanup intact so these tests detect leaked child processes.
|
|
_ACP_AGENT_SCRIPT = """\
|
|
import asyncio
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import acp
|
|
from acp.schema import InitializeResponse, NewSessionResponse, PromptResponse
|
|
|
|
|
|
class _TestAgent:
|
|
def on_connect(self, conn):
|
|
self._conn = conn
|
|
|
|
async def _enter_phase(self, phase):
|
|
Path(sys.argv[3]).write_text(phase, encoding="utf-8")
|
|
if phase == "prompt":
|
|
await self._conn.session_update(session_id="test-session", update=acp.update_agent_thought_text(phase))
|
|
if phase == sys.argv[1]:
|
|
await asyncio.Event().wait()
|
|
delay = float(sys.argv[2])
|
|
if delay:
|
|
await asyncio.sleep(delay)
|
|
|
|
async def initialize(self, protocol_version, client_capabilities=None, client_info=None, **kwargs):
|
|
await self._enter_phase("initialize")
|
|
return InitializeResponse(protocol_version=protocol_version)
|
|
|
|
async def new_session(self, cwd, additional_directories=None, mcp_servers=None, **kwargs):
|
|
await self._enter_phase("new_session")
|
|
return NewSessionResponse(session_id="test-session")
|
|
|
|
async def prompt(self, session_id, prompt, **kwargs):
|
|
await self._enter_phase("prompt")
|
|
return PromptResponse(stop_reason="end_turn")
|
|
|
|
|
|
asyncio.run(acp.run_agent(_TestAgent()))
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def acp_subprocess_tool(monkeypatch, tmp_path):
|
|
import acp as acp_module
|
|
|
|
from deerflow.config import paths as paths_module
|
|
|
|
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(
|
|
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
|
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
|
)
|
|
script_path = tmp_path / "test_acp_agent.py"
|
|
script_path.write_text(_ACP_AGENT_SCRIPT, encoding="utf-8")
|
|
phase_path = tmp_path / "phase.txt"
|
|
captured = {}
|
|
phase_entered = asyncio.Event()
|
|
real_spawn_agent_process = acp_module.spawn_agent_process
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def _spying_spawn_agent_process(client, cmd, *args, env=None, cwd=None):
|
|
original_session_update = client.session_update
|
|
|
|
async def _session_update(session_id, update, **kwargs):
|
|
if getattr(update, "session_update", None) == "agent_thought_chunk":
|
|
phase_entered.set()
|
|
await original_session_update(session_id, update, **kwargs)
|
|
|
|
client.session_update = _session_update
|
|
async with real_spawn_agent_process(client, cmd, *args, env=env, cwd=cwd) as (conn, proc):
|
|
captured["proc"] = proc
|
|
yield conn, proc
|
|
|
|
monkeypatch.setattr(acp_module, "spawn_agent_process", _spying_spawn_agent_process)
|
|
|
|
def build_tool(hang_phase="", phase_delay=0, timeout_seconds=2):
|
|
tool = build_invoke_acp_agent_tool(
|
|
{
|
|
"test": ACPAgentConfig(
|
|
command=sys.executable,
|
|
args=[str(script_path), hang_phase, str(phase_delay), str(phase_path)],
|
|
description="Test ACP agent",
|
|
timeout_seconds=timeout_seconds,
|
|
)
|
|
}
|
|
)
|
|
return tool
|
|
|
|
return SimpleNamespace(build_tool=build_tool, captured=captured, phase_entered=phase_entered, phase_path=phase_path)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("phase", ["initialize", "new_session", "prompt"])
|
|
async def test_invoke_acp_agent_times_out_and_kills_hung_subprocess(acp_subprocess_tool, phase):
|
|
tool = acp_subprocess_tool.build_tool(hang_phase=phase)
|
|
start = time.monotonic()
|
|
# The outer timeout only bounds a regression; it must not supply the tool's deadline.
|
|
result = await asyncio.wait_for(tool.coroutine(agent="test", prompt="do work"), timeout=15)
|
|
assert time.monotonic() - start < 10
|
|
assert "timed out" in result.lower()
|
|
assert "test" in result
|
|
assert acp_subprocess_tool.phase_path.read_text(encoding="utf-8") == phase
|
|
assert acp_subprocess_tool.captured["proc"].returncode is not None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invoke_acp_agent_shares_one_timeout_budget(acp_subprocess_tool):
|
|
# Each phase completes within 3s, but their combined 3.6s exceeds one invocation budget.
|
|
tool = acp_subprocess_tool.build_tool(phase_delay=1.2, timeout_seconds=3)
|
|
result = await asyncio.wait_for(tool.coroutine(agent="test", prompt="do work"), timeout=15)
|
|
assert "timed out" in result.lower()
|
|
assert acp_subprocess_tool.captured["proc"].returncode is not None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invoke_acp_agent_completes_within_timeout(acp_subprocess_tool):
|
|
tool = acp_subprocess_tool.build_tool()
|
|
result = await asyncio.wait_for(tool.coroutine(agent="test", prompt="do work"), timeout=15)
|
|
assert result == "(no response)"
|
|
assert acp_subprocess_tool.phase_path.read_text(encoding="utf-8") == "prompt"
|
|
assert acp_subprocess_tool.captured["proc"].returncode is not None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invoke_acp_agent_cancellation_closes_subprocess(acp_subprocess_tool):
|
|
tool = acp_subprocess_tool.build_tool(hang_phase="prompt", timeout_seconds=60)
|
|
invocation = asyncio.create_task(tool.coroutine(agent="test", prompt="do work"))
|
|
try:
|
|
await asyncio.wait_for(acp_subprocess_tool.phase_entered.wait(), timeout=15)
|
|
invocation.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await asyncio.wait_for(invocation, timeout=15)
|
|
assert acp_subprocess_tool.captured["proc"].returncode is not None
|
|
finally:
|
|
if not invocation.done():
|
|
invocation.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await invocation
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("phase", ["initialize", "new_session", "prompt"])
|
|
async def test_invoke_acp_agent_preserves_sdk_timeout_errors(acp_subprocess_tool, monkeypatch, phase):
|
|
from acp.client.connection import ClientSideConnection
|
|
|
|
async def raise_sdk_timeout(self, *args, **kwargs):
|
|
raise TimeoutError("SDK transport timed out")
|
|
|
|
monkeypatch.setattr(ClientSideConnection, phase, raise_sdk_timeout)
|
|
tool = acp_subprocess_tool.build_tool(timeout_seconds=60)
|
|
result = await asyncio.wait_for(tool.coroutine(agent="test", prompt="do work"), timeout=15)
|
|
assert result == "Error invoking ACP agent 'test': SDK transport timed out"
|
|
assert "timeout_seconds" not in result
|
|
assert acp_subprocess_tool.captured["proc"].returncode is not None
|
|
|
|
|
|
_STDERR_ACP_AGENT_SCRIPT = """\
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
import acp
|
|
from acp.schema import InitializeResponse, NewSessionResponse, PromptResponse
|
|
|
|
# argv: <phase to stop in, or ""> <stderr bytes to write per phase> <"exit" or "hang">
|
|
|
|
|
|
class _TestAgent:
|
|
def on_connect(self, conn):
|
|
self._conn = conn
|
|
|
|
def _enter_phase(self, phase):
|
|
line = f"{phase}: " + "x" * 200 + "\\n"
|
|
for _ in range(int(sys.argv[2]) // len(line) + 1):
|
|
sys.stderr.write(line)
|
|
if phase == sys.argv[1] and sys.argv[3] == "exit":
|
|
sys.stderr.write(f"fatal: cannot {phase} without credentials\\n")
|
|
sys.stderr.flush()
|
|
os._exit(3)
|
|
if phase == sys.argv[1]:
|
|
sys.stderr.write(f"stalled: waiting for {phase} login\\n")
|
|
sys.stderr.flush()
|
|
time.sleep(3600)
|
|
sys.stderr.flush()
|
|
|
|
async def initialize(self, protocol_version, client_capabilities=None, client_info=None, **kwargs):
|
|
self._enter_phase("initialize")
|
|
return InitializeResponse(protocol_version=protocol_version)
|
|
|
|
async def new_session(self, cwd, additional_directories=None, mcp_servers=None, **kwargs):
|
|
self._enter_phase("new_session")
|
|
return NewSessionResponse(session_id="test-session")
|
|
|
|
async def prompt(self, session_id, prompt, **kwargs):
|
|
self._enter_phase("prompt")
|
|
await self._conn.session_update(session_id=session_id, update=acp.update_agent_message_text("done"))
|
|
return PromptResponse(stop_reason="end_turn")
|
|
|
|
|
|
asyncio.run(acp.run_agent(_TestAgent()))
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def acp_stderr_tool(monkeypatch, tmp_path):
|
|
from deerflow.config import paths as paths_module
|
|
|
|
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(
|
|
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
|
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
|
)
|
|
script_path = tmp_path / "stderr_acp_agent.py"
|
|
script_path.write_text(_STDERR_ACP_AGENT_SCRIPT, encoding="utf-8")
|
|
|
|
def build_tool(stop_phase="", stderr_bytes=0, stop="exit", timeout_seconds=60):
|
|
return build_invoke_acp_agent_tool(
|
|
{
|
|
"test": ACPAgentConfig(
|
|
command=sys.executable,
|
|
args=[str(script_path), stop_phase, str(stderr_bytes), stop],
|
|
description="Test ACP agent",
|
|
timeout_seconds=timeout_seconds,
|
|
)
|
|
}
|
|
)
|
|
|
|
return build_tool
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("phase", ["initialize", "new_session", "prompt"])
|
|
async def test_invoke_acp_agent_reports_an_agent_that_exits_mid_invocation(acp_stderr_tool, caplog, phase):
|
|
# More stderr than the retained tail, so the fatal line (written last) is
|
|
# only logged if the tail keeps the end of the stream rather than its start.
|
|
tool = acp_stderr_tool(stop_phase=phase, stderr_bytes=40 * 1024)
|
|
start = time.monotonic()
|
|
with caplog.at_level(logging.ERROR, logger="deerflow.tools.builtins.invoke_acp_agent_tool"):
|
|
# The tool's own 60s budget must not be what ends the call.
|
|
result = await asyncio.wait_for(tool.coroutine(agent="test", prompt="do work"), timeout=30)
|
|
assert time.monotonic() - start < 15
|
|
assert result.startswith("Error: ACP agent 'test' exited with code 3 before completing the ACP invocation.")
|
|
assert "timeout_seconds" not in result
|
|
# The agent's diagnostics reach the operator log, not the model.
|
|
assert f"fatal: cannot {phase} without credentials" in caplog.text
|
|
assert "credentials" not in result
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invoke_acp_agent_logs_the_stderr_of_a_stalled_agent(acp_stderr_tool, caplog):
|
|
tool = acp_stderr_tool(stop_phase="prompt", stderr_bytes=40 * 1024, stop="hang", timeout_seconds=3)
|
|
with caplog.at_level(logging.ERROR, logger="deerflow.tools.builtins.invoke_acp_agent_tool"):
|
|
result = await asyncio.wait_for(tool.coroutine(agent="test", prompt="do work"), timeout=30)
|
|
assert "timed out after 3 seconds" in result
|
|
# The last line the agent wrote before stalling is the one an operator needs.
|
|
assert "stalled: waiting for prompt login" in caplog.text
|
|
assert "login" not in result
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invoke_acp_agent_drains_verbose_stderr(acp_stderr_tool):
|
|
# Far beyond what an unread stderr pipe absorbs before the agent's writes block.
|
|
tool = acp_stderr_tool(stderr_bytes=1024 * 1024, timeout_seconds=20)
|
|
result = await asyncio.wait_for(tool.coroutine(agent="test", prompt="do work"), timeout=40)
|
|
assert result == "done"
|
|
|
|
|
|
def _write_launcher_shim(directory: Path, name: str, command: str) -> Path:
|
|
"""Write an executable launcher shim for ``name`` under ``directory``."""
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
if sys.platform == "win32":
|
|
shim = directory / f"{name}.cmd"
|
|
shim.write_text(f"@echo off\n{command}\n", encoding="utf-8")
|
|
else:
|
|
shim = directory / name
|
|
shim.write_text(f"#!/bin/sh\nexec {command}\n", encoding="utf-8")
|
|
shim.chmod(0o755)
|
|
return shim
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invoke_acp_agent_spawns_the_path_resolved_command(monkeypatch, tmp_path):
|
|
"""A bare configured command reaches the ACP SDK as its PATH-resolved path.
|
|
|
|
Windows resolves npm-installed launchers through ``PATHEXT`` (``npx`` ->
|
|
``npx.cmd``) and ``asyncio.create_subprocess_exec`` does not, so the
|
|
resolved path must be handed to the SDK instead of the bare name.
|
|
"""
|
|
import acp as acp_module
|
|
|
|
from deerflow.config import paths as paths_module
|
|
|
|
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(
|
|
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
|
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
|
)
|
|
script_path = tmp_path / "test_acp_agent.py"
|
|
script_path.write_text(_ACP_AGENT_SCRIPT, encoding="utf-8")
|
|
phase_path = tmp_path / "phase.txt"
|
|
|
|
shim_name = "deerflow-acp-probe"
|
|
shim = _write_launcher_shim(tmp_path / "bin", shim_name, f'"{sys.executable}" "{script_path}" "" 0 "{phase_path}"')
|
|
monkeypatch.setenv("PATH", str(shim.parent))
|
|
|
|
captured: dict[str, object] = {}
|
|
real_spawn_agent_process = acp_module.spawn_agent_process
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def _spying_spawn_agent_process(client, cmd, *args, env=None, cwd=None):
|
|
captured["cmd"] = cmd
|
|
async with real_spawn_agent_process(client, cmd, *args, env=env, cwd=cwd) as (conn, proc):
|
|
yield conn, proc
|
|
|
|
monkeypatch.setattr(acp_module, "spawn_agent_process", _spying_spawn_agent_process)
|
|
|
|
tool = build_invoke_acp_agent_tool({"probe": ACPAgentConfig(command=shim_name, description="Probe", timeout_seconds=30)})
|
|
result = await asyncio.wait_for(tool.coroutine(agent="probe", prompt="do work"), timeout=20)
|
|
|
|
# Windows PATHEXT matching reports the shim with its extension case
|
|
# normalized, so compare paths rather than raw strings.
|
|
assert Path(captured["cmd"]) == shim
|
|
assert result == "(no response)"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invoke_acp_agent_spawns_an_absolute_command_without_an_extension(monkeypatch, tmp_path):
|
|
"""An absolute command has to reach its ``PATHEXT`` shim before the spawn.
|
|
|
|
npm installs ``mcode``, ``mcode.cmd``, and ``mcode.ps1`` side by side; a
|
|
configured absolute path without the extension would spawn the shell
|
|
script and fail with ``WinError 193``. The resolver has to pick the
|
|
``.cmd`` shim the same way it does for a bare name.
|
|
"""
|
|
import acp as acp_module
|
|
|
|
from deerflow.config import paths as paths_module
|
|
|
|
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(
|
|
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
|
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
|
)
|
|
script_path = tmp_path / "test_acp_agent.py"
|
|
script_path.write_text(_ACP_AGENT_SCRIPT, encoding="utf-8")
|
|
phase_path = tmp_path / "phase.txt"
|
|
|
|
shim_name = "deerflow-acp-probe"
|
|
shim = _write_launcher_shim(tmp_path / "npm", shim_name, f'"{sys.executable}" "{script_path}" "" 0 "{phase_path}"')
|
|
if sys.platform == "win32":
|
|
# npm also drops an extensionless shell script next to the .cmd shim.
|
|
(shim.parent / shim_name).write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
|
|
|
captured: dict[str, object] = {}
|
|
real_spawn_agent_process = acp_module.spawn_agent_process
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def _spying_spawn_agent_process(client, cmd, *args, env=None, cwd=None):
|
|
captured["cmd"] = cmd
|
|
async with real_spawn_agent_process(client, cmd, *args, env=env, cwd=cwd) as (conn, proc):
|
|
yield conn, proc
|
|
|
|
monkeypatch.setattr(acp_module, "spawn_agent_process", _spying_spawn_agent_process)
|
|
|
|
configured = str(shim.parent / shim_name)
|
|
tool = build_invoke_acp_agent_tool({"probe": ACPAgentConfig(command=configured, description="Probe", timeout_seconds=30)})
|
|
result = await asyncio.wait_for(tool.coroutine(agent="probe", prompt="do work"), timeout=20)
|
|
|
|
assert Path(captured["cmd"]) == shim
|
|
assert result == "(no response)"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invoke_acp_agent_reports_guidance_for_an_unresolvable_command(monkeypatch, tmp_path):
|
|
"""A command PATH cannot resolve still reports the configured name.
|
|
|
|
``_resolve_agent_command`` falls back to the configured value when nothing
|
|
matches, so the not-found guidance must name what the user configured.
|
|
"""
|
|
from deerflow.config import paths as paths_module
|
|
|
|
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(
|
|
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
|
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
|
)
|
|
|
|
tool = build_invoke_acp_agent_tool({"probe": ACPAgentConfig(command="deerflow-missing-acp-agent", description="Probe", timeout_seconds=30)})
|
|
result = await asyncio.wait_for(tool.coroutine(agent="probe", prompt="do work"), timeout=20)
|
|
|
|
assert "Command 'deerflow-missing-acp-agent' was not found on PATH" in result
|
|
assert "acp_agents.probe.command" in result
|
|
|
|
|
|
def test_agent_path_reads_the_configured_override_case_insensitively():
|
|
"""The env the SDK will apply decides which PATH resolves the launcher."""
|
|
assert _agent_path(None) is None
|
|
assert _agent_path({}) is None
|
|
assert _agent_path({"FOO": "bar"}) is None
|
|
assert _agent_path({"PATH": "/agent-bin"}) == "/agent-bin"
|
|
assert _agent_path({"Path": "/agent-bin"}) == "/agent-bin"
|
|
|
|
|
|
def test_resolve_agent_command_leaves_relative_paths_to_the_spawn(monkeypatch, tmp_path):
|
|
"""A relative configured path is left to the spawn, which runs in the ACP workspace."""
|
|
resolved_dir = tmp_path / "resolved"
|
|
monkeypatch.setattr(
|
|
"deerflow.tools.builtins.invoke_acp_agent_tool.shutil.which",
|
|
lambda command, path=None: str(resolved_dir / command),
|
|
)
|
|
|
|
assert _resolve_agent_command("npx") == str(resolved_dir / "npx")
|
|
assert _resolve_agent_command("bin/agent") == "bin/agent"
|
|
assert _resolve_agent_command("./bin/agent") == "./bin/agent"
|
|
|
|
|
|
def test_resolve_agent_command_resolves_absolute_paths_through_the_lookup(monkeypatch, tmp_path):
|
|
"""An absolute command keeps its directory but still goes through the lookup."""
|
|
seen: list[tuple[str, str | None]] = []
|
|
|
|
def _which(command, path=None):
|
|
seen.append((command, path))
|
|
return command + ".CMD"
|
|
|
|
monkeypatch.setattr("deerflow.tools.builtins.invoke_acp_agent_tool.shutil.which", _which)
|
|
|
|
absolute = str(tmp_path / "npm" / "mcode")
|
|
assert _resolve_agent_command(absolute, str(tmp_path / "agent-bin")) == absolute + ".CMD"
|
|
assert seen == [(absolute, str(tmp_path / "agent-bin"))]
|
|
|
|
|
|
def test_resolve_agent_command_keeps_an_absolute_lookup_result_as_returned(monkeypatch, tmp_path):
|
|
"""An already-absolute lookup result must not be lexically rewritten.
|
|
|
|
``os.path.abspath`` collapses a ``..`` that the kernel resolves after a
|
|
symlink, which changes the file the spawn runs; the anchoring exists for
|
|
relative ``PATH`` results only.
|
|
"""
|
|
literal = str(tmp_path / "current" / ".." / "shared" / "agent")
|
|
monkeypatch.setattr(
|
|
"deerflow.tools.builtins.invoke_acp_agent_tool.shutil.which",
|
|
lambda command, path=None: command,
|
|
)
|
|
|
|
assert _resolve_agent_command(literal) == literal
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform != "win32", reason="PATHEXT sibling resolution is Windows-only")
|
|
def test_resolve_agent_command_resolves_a_windows_path_without_an_extension(tmp_path):
|
|
"""npm's extensionless sh script and its ``.cmd`` shim sit side by side.
|
|
|
|
``asyncio.create_subprocess_exec`` does not probe ``PATHEXT``, so spawning
|
|
the configured path runs the shell script and fails with ``WinError 193``;
|
|
the lookup has to pick the ``.cmd`` shim instead.
|
|
"""
|
|
npm_bin = tmp_path / "npm"
|
|
npm_bin.mkdir()
|
|
extensionless = npm_bin / "mcode"
|
|
extensionless.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
|
(npm_bin / "mcode.cmd").write_text("@echo off\r\necho ran\r\n", encoding="ascii")
|
|
|
|
resolved = _resolve_agent_command(str(extensionless))
|
|
|
|
assert Path(resolved) == npm_bin / "mcode.CMD"
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX has no PATHEXT indirection to resolve")
|
|
def test_resolve_agent_command_keeps_an_absolute_posix_command(tmp_path):
|
|
"""On POSIX the absolute command itself is the executable; resolution keeps it."""
|
|
tool = tmp_path / "bin" / "mcode"
|
|
tool.parent.mkdir()
|
|
tool.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
|
tool.chmod(0o755)
|
|
|
|
assert _resolve_agent_command(str(tool)) == str(tool)
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="symlink-plus-parent-segment resolution is POSIX-specific")
|
|
def test_resolve_agent_command_keeps_a_posix_symlink_that_precedes_a_parent_segment(tmp_path):
|
|
"""An absolute command is returned as configured, not lexically rewritten.
|
|
|
|
With ``current -> releases/v1``, ``current/../shared/agent`` resolves to
|
|
``releases/shared/agent`` for the kernel, while a lexical ``abspath``
|
|
would collapse it to the nonexistent ``shared/agent`` beside the symlink
|
|
or to a different file that happens to live there.
|
|
"""
|
|
release = tmp_path / "releases" / "v1"
|
|
release.mkdir(parents=True)
|
|
tool = tmp_path / "releases" / "shared" / "agent"
|
|
tool.parent.mkdir(parents=True)
|
|
tool.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
|
tool.chmod(0o755)
|
|
current = tmp_path / "current"
|
|
current.symlink_to(release, target_is_directory=True)
|
|
|
|
configured = str(current / ".." / "shared" / "agent")
|
|
|
|
assert _resolve_agent_command(configured) == configured
|
|
|
|
|
|
def test_resolve_agent_command_hands_the_effective_path_to_the_lookup(monkeypatch, tmp_path):
|
|
"""The agent's own PATH, not the Gateway's, is what the lookup consults."""
|
|
seen: list[str | None] = []
|
|
|
|
def _which(command, path=None):
|
|
seen.append(path)
|
|
return str(tmp_path / (path or "gateway-bin") / command)
|
|
|
|
monkeypatch.setattr("deerflow.tools.builtins.invoke_acp_agent_tool.shutil.which", _which)
|
|
|
|
assert _resolve_agent_command("npx", str(tmp_path / "agent-bin")) == str(tmp_path / "agent-bin" / "npx")
|
|
assert _resolve_agent_command("npx") == str(tmp_path / "gateway-bin" / "npx")
|
|
assert seen == [str(tmp_path / "agent-bin"), None]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invoke_acp_agent_reports_the_configured_command_when_spawn_fails(monkeypatch, tmp_path):
|
|
"""A command PATH resolves can still fail to launch.
|
|
|
|
The remediation text has to stay anchored to the configured command, so the
|
|
``codex-acp`` hint below survives the resolution.
|
|
"""
|
|
import acp as acp_module
|
|
|
|
from deerflow.config import paths as paths_module
|
|
|
|
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(
|
|
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
|
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
|
)
|
|
shim_dir = tmp_path / "bin"
|
|
_write_launcher_shim(shim_dir, "codex-acp", "echo")
|
|
_write_launcher_shim(shim_dir, "codex", "echo")
|
|
monkeypatch.setenv("PATH", str(shim_dir))
|
|
|
|
def _raise_missing(*args, **kwargs):
|
|
raise FileNotFoundError(2, "No such file or directory")
|
|
|
|
monkeypatch.setattr(acp_module, "spawn_agent_process", _raise_missing)
|
|
|
|
tool = build_invoke_acp_agent_tool({"codex": ACPAgentConfig(command="codex-acp", description="Codex CLI", timeout_seconds=30)})
|
|
result = await asyncio.wait_for(tool.coroutine(agent="codex", prompt="do work"), timeout=20)
|
|
|
|
assert "Command 'codex-acp' was not found on PATH" in result
|
|
assert "does not speak ACP directly" in result
|
|
|
|
|
|
def _write_marking_shim(directory: Path, name: str, marker: Path, command: str) -> Path:
|
|
"""Write a launcher shim for ``name`` that records that this copy ran."""
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
body = f'\necho ran > "{marker}"\n{command}\n'
|
|
if sys.platform == "win32":
|
|
shim = directory / f"{name}.cmd"
|
|
shim.write_text(f"@echo off{body}", encoding="utf-8")
|
|
else:
|
|
shim = directory / name
|
|
shim.write_text(f"#!/bin/sh{body}", encoding="utf-8")
|
|
shim.chmod(0o755)
|
|
return shim
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invoke_acp_agent_resolves_against_the_agent_configured_path(monkeypatch, tmp_path):
|
|
"""The agent's ``env.PATH`` wins when both PATHs hold the same launcher name.
|
|
|
|
The ACP SDK merges ``acp_agents.<name>.env`` over the inherited environment
|
|
at spawn time, so resolving the bare name against the Gateway's ``PATH``
|
|
would launch a different same-named launcher than the agent was configured
|
|
for.
|
|
"""
|
|
import acp as acp_module
|
|
|
|
from deerflow.config import paths as paths_module
|
|
|
|
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(
|
|
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
|
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
|
)
|
|
script_path = tmp_path / "test_acp_agent.py"
|
|
script_path.write_text(_ACP_AGENT_SCRIPT, encoding="utf-8")
|
|
phase_path = tmp_path / "phase.txt"
|
|
shim_name = "deerflow-acp-probe"
|
|
launch = f'"{sys.executable}" "{script_path}" "" 0 "{phase_path}"'
|
|
|
|
gateway_marker = tmp_path / "gateway-ran.txt"
|
|
agent_marker = tmp_path / "agent-ran.txt"
|
|
_write_marking_shim(tmp_path / "gateway-bin", shim_name, gateway_marker, launch)
|
|
agent_shim = _write_marking_shim(tmp_path / "agent-bin", shim_name, agent_marker, launch)
|
|
monkeypatch.setenv("PATH", str(tmp_path / "gateway-bin"))
|
|
|
|
captured: dict[str, object] = {}
|
|
real_spawn_agent_process = acp_module.spawn_agent_process
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def _spying_spawn_agent_process(client, cmd, *args, env=None, cwd=None):
|
|
captured["cmd"] = cmd
|
|
async with real_spawn_agent_process(client, cmd, *args, env=env, cwd=cwd) as (conn, proc):
|
|
yield conn, proc
|
|
|
|
monkeypatch.setattr(acp_module, "spawn_agent_process", _spying_spawn_agent_process)
|
|
|
|
tool = build_invoke_acp_agent_tool(
|
|
{
|
|
"probe": ACPAgentConfig(
|
|
command=shim_name,
|
|
env={"PATH": str(tmp_path / "agent-bin")},
|
|
description="Probe",
|
|
timeout_seconds=30,
|
|
)
|
|
}
|
|
)
|
|
result = await asyncio.wait_for(tool.coroutine(agent="probe", prompt="do work"), timeout=20)
|
|
|
|
assert Path(captured["cmd"]) == agent_shim
|
|
assert agent_marker.read_text(encoding="utf-8").strip() == "ran"
|
|
assert not gateway_marker.exists()
|
|
assert result == "(no response)"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invoke_acp_agent_spawns_an_absolute_path_for_a_relative_path_entry(monkeypatch, tmp_path):
|
|
"""A relative PATH entry must not be re-interpreted inside the ACP workspace.
|
|
|
|
``shutil.which`` yields a result relative to the Gateway's cwd when a
|
|
relative directory is on ``PATH``, but the SDK spawns with ``cwd`` set to
|
|
the per-thread ACP workspace, where that relative path does not exist.
|
|
"""
|
|
import acp as acp_module
|
|
|
|
from deerflow.config import paths as paths_module
|
|
|
|
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(
|
|
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
|
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
|
)
|
|
script_path = tmp_path / "test_acp_agent.py"
|
|
script_path.write_text(_ACP_AGENT_SCRIPT, encoding="utf-8")
|
|
phase_path = tmp_path / "phase.txt"
|
|
|
|
# Pin the process cwd so the relative PATH entry below points at a shim
|
|
# beside the ACP workspace rather than inside it.
|
|
monkeypatch.chdir(tmp_path)
|
|
shim_name = "deerflow-acp-probe"
|
|
shim = _write_launcher_shim(tmp_path / "bin", shim_name, f'"{sys.executable}" "{script_path}" "" 0 "{phase_path}"')
|
|
monkeypatch.setenv("PATH", "bin")
|
|
|
|
captured: dict[str, object] = {}
|
|
real_spawn_agent_process = acp_module.spawn_agent_process
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def _spying_spawn_agent_process(client, cmd, *args, env=None, cwd=None):
|
|
captured["cmd"] = cmd
|
|
captured["cwd"] = cwd
|
|
async with real_spawn_agent_process(client, cmd, *args, env=env, cwd=cwd) as (conn, proc):
|
|
yield conn, proc
|
|
|
|
monkeypatch.setattr(acp_module, "spawn_agent_process", _spying_spawn_agent_process)
|
|
|
|
tool = build_invoke_acp_agent_tool({"probe": ACPAgentConfig(command=shim_name, description="Probe", timeout_seconds=30)})
|
|
result = await asyncio.wait_for(tool.coroutine(agent="probe", prompt="do work"), timeout=20)
|
|
|
|
assert os.path.isabs(captured["cmd"])
|
|
assert Path(captured["cmd"]) == shim
|
|
assert Path(captured["cwd"]) != shim.parent
|
|
assert result == "(no response)"
|