deer-flow/backend/tests/test_invoke_acp_agent_tool.py
Jason Cat ca81ace2dc
fix(acp): resolve an absolute Windows agent command before spawning (#6656)
* 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.
2026-10-11 18:40:31 +08:00

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)"