mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* fix(acp): resolve an absolute Windows agent command before spawning A command configured as an absolute path without an extension -- npm's global shim layout, e.g. C:\npm\mcode beside mcode.cmd -- was handed to the ACP SDK unchanged and always failed: create_subprocess_exec applies no PATHEXT, so it ran npm's extensionless shell script and died with WinError 193 (or raised FileNotFoundError when only the .cmd exists). The separator guard from #6598 was too broad: only relative paths need to stay with the spawn, because their meaning is owned by the ACP-workspace cwd, while an absolute path does not depend on the cwd. Absolute paths now go through the same shutil.which lookup as bare names: the lookup searches the command's own directory and probes PATHEXT, and the configured value is kept when nothing matches so the not-found remediation still fires. Relative paths (bin/agent, ./bin/agent) still go to the spawn unchanged. Docs: README.md and backend/packages/harness/deerflow/tools/AGENTS.md now describe the bare/absolute vs relative split. AI disclosure: this change was drafted with AI assistance at my request, under my direction and review. * fix(acp): keep already-absolute lookup results unchanged Review feedback on #6656 (willem-bd): os.path.abspath collapsed a `..` that follows a symlink, so an absolute POSIX command like `/srv/current/../shared/agent` (with `current -> releases/v1`) was rewritten to `/srv/shared/agent` -- a path that either does not exist or names a different launcher. The anchoring to the Gateway cwd exists only for relative PATH lookup results, where shutil.which cannot know the cwd the spawn will use; an already-absolute result needs no rewrite. Regressions: a platform-independent stub-lookup test pinning the literal parent-segment path, and a POSIX-only test that builds the symlink layout and asserts the configured command is returned unchanged.
1407 lines
53 KiB
Python
1407 lines
53 KiB
Python
"""Tests for the built-in ACP invocation tool."""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
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
|
|
|
|
|
|
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(), object()
|
|
|
|
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(), object()
|
|
|
|
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(), object()
|
|
|
|
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(), object()
|
|
|
|
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(), object()
|
|
|
|
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(), object()
|
|
|
|
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
|
|
|
|
|
|
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)"
|