fix(acp): resolve a bare agent command through PATH before spawning (#6598)

* fix(acp): resolve a bare agent command through PATH before spawning

`invoke_acp_agent` handed `acp_agents.<name>.command` straight to the ACP
SDK, which spawns it with `asyncio.create_subprocess_exec`. That API applies
no `PATHEXT` lookup on Windows, so a bare npm launcher (`npx`, `mcode`)
raised `FileNotFoundError` although its `.cmd` shim was on `PATH`. The
documented `command: npx` and `command: mcode` shapes could not start, and
the tool reported "was not found on PATH" for an installed launcher.

Resolve the configured command with `shutil.which` before spawning — the way
the MCP Python SDK normalizes stdio server commands — and keep the configured
value when nothing matches, so the existing install guidance still fires.

Fixes #6597

* fix(acp): resolve the ACP agent command off the event loop

`_resolve_agent_command` calls `shutil.which`, which stats the filesystem.
Calling it inline in the async `_invoke` tripped the strict BlockBuster gate
(`BlockingError: Blocking call to os.access`) for every command that resolves,
and would block the Gateway event loop in production.

Offload the lookup with `asyncio.to_thread`, matching the neighbouring
`_get_work_dir`, `_build_acp_mcp_servers`, and `_format_invocation_error`
calls.

The blocking-io anchor configured `command="test-agent"`, a name that resolves
nowhere, so `shutil.which` short-circuited before `os.access` and the gate
never covered the new call. Point it at `sys.executable` so the lookup is
really exercised, and cover the unresolvable-guidance branch end-to-end with a
new test.

Refs #6597

* fix(acp): keep the configured command in ACP remediation text

Review follow-ups on the PATH resolution:

- Only bare names are resolved. A configured path is interpreted by the spawn
  against the per-thread ACP workspace, so resolving it against the Gateway's
  working directory could pick a different file or hand the spawn a path that
  then fails.
- `_format_invocation_error` receives `agent_config.command` again. A command
  can resolve on PATH and still fail to launch (a broken shebang, for example),
  and reporting the resolved path made "was not found on PATH" false and
  dropped the `codex-acp` hint.
- The blocking-io anchor uses a bare name backed by a shim on a controlled PATH
  instead of `sys.executable`, which the bare-name scoping no longer resolves.

Tests: the pass-through assertion in
`test_invoke_acp_agent_uses_fixed_acp_workspace` pins PATH, and new coverage
covers the bare-name scoping and the spawn-failure message.

Refs #6597

* docs(acp): correct the path-resolution rationale

A configured path is resolved by the spawn in its own process, so the
pass-through reason must not claim it is resolved against the ACP workspace:
that only holds on POSIX, while Windows resolves a relative executable against
the calling process's directory. State the platform-neutral reason instead.

Refs #6597

* fix(acp): resolve the launcher against the agent's own PATH

The ACP SDK merges acp_agents.<name>.env over its inherited environment at spawn time, so a configured PATH, not the Gateway's, decides which launcher the agent would find. Resolve a bare command against that effective PATH, and use the Gateway's only when the agent does not override it.

Return a successful lookup as an absolute path: a relative PATH entry makes shutil.which yield a result relative to the Gateway's cwd, while the spawn runs with cwd set to the ACP workspace, where that path resolves elsewhere or not at all.

Regressions: a real-subprocess test with same-named launchers in the Gateway and agent PATHs (asserting which one ran), and a real-subprocess test whose PATH holds a relative directory while the ACP workspace is elsewhere. Both are red on the pre-fix code.
This commit is contained in:
Jason Cat 2026-10-10 19:23:39 +08:00 committed by GitHub
parent a333ac3189
commit f623e64e07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 355 additions and 6 deletions

View File

@ -367,6 +367,7 @@ streams simulate chunks from a non-streaming response, while no-tool streams sta
- `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR` accepts a UTF-8 token handoff and reuses it for later model instances in the same process. Undecodable handoffs are skipped so Claude Code can still try its override or default credentials file.
- CLI credential JSON files accept UTF-8 with or without a BOM, independently of the host locale. `make doctor` accepts the same files when checking CLI authentication. Invalid text encoding is treated as an unreadable source; Claude Code can still try its default file after an invalid override.
- ACP agent entries are separate from model providers — if you configure `acp_agents.codex`, point it at a Codex ACP adapter such as `npx -y @zed-industries/codex-acp`
- A bare ACP agent `command` is resolved before the agent starts, so npm-installed launchers work on Windows too: `npx` or `mcode` is spawned as its `npx.cmd`/`mcode.cmd` shim instead of failing with a command-not-found error. The lookup uses the `PATH` the agent subprocess will actually see (`acp_agents.<name>.env.PATH` when set, otherwise the Gateway's) and hands the spawn an absolute path, so a relative `PATH` entry cannot be re-interpreted inside the agent's own workspace. A `command` containing a path separator is used as configured, and a bare name that cannot be resolved is still reported with the install guidance below.
- Each ACP agent's `timeout_seconds` (default: 1800) is one shared budget for initialization, session creation, and the prompt, starting after the subprocess launches. On timeout, DeerFlow aborts the invocation and closes the subprocess before returning an error. Workspace/MCP preparation and subprocess cleanup are outside this budget. A `TimeoutError` raised by the SDK before this deadline expires retains its own error message.
- MiniMax Code speaks ACP directly. Install and authenticate it, then add it as an ACP agent:

View File

@ -93,6 +93,7 @@ E2B output sync records remote file versions and actual host file metadata in a
- `timeout_seconds` is one deadline shared by initialization, session creation, and prompt after process launch. Keep the SDK process context outside the timeout so teardown finishes before the tool returns; workspace/MCP preparation and teardown are outside the protocol budget. Real-process regressions in `tests/test_invoke_acp_agent_tool.py` cover every hung phase, the shared budget, success, and caller cancellation. Only report budget expiry when the timeout context's `expired()` is true; otherwise propagate SDK `TimeoutError` to `_format_invocation_error`.
- ACP results collect only `agent_message_chunk` text. Thought chunks remain internal and must not be concatenated into the tool result
- Missing ACP executables now return an actionable error message instead of a raw `[Errno 2]`
- A bare configured `command` is resolved before the spawn (`_resolve_agent_command`), off the event loop because the lookup stats the filesystem. The ACP SDK's stdio transport calls `asyncio.create_subprocess_exec`, which performs no `PATHEXT` lookup on Windows, so a bare npm launcher (`npx`, `mcode`) would raise `FileNotFoundError` although its `.cmd` shim is on `PATH`. Resolution consults the `PATH` the agent subprocess will actually see (`acp_agents.<name>.env.PATH` when the agent config sets one, the Gateway's otherwise) and returns an absolute path, so a relative `PATH` entry cannot be re-interpreted inside the ACP workspace. A `command` containing a path separator goes to the spawn unchanged, so the spawn — not `shutil.which` — decides what it refers to, and `_format_invocation_error` always receives the configured value so its remediation still matches `config.yaml`
- Each ACP agent uses a per-thread workspace at `{base_dir}/users/{user_id}/threads/{thread_id}/acp-workspace/`. The workspace is accessible to the lead agent via the virtual path `/mnt/acp-workspace/` (read-only). In docker sandbox mode, the directory is volume-mounted into the container at `/mnt/acp-workspace` (read-only); in local sandbox mode, path translation is handled by `tools.py`
Ordinary `task` results forward bounded `artifact.knowledge_sources` records

View File

@ -125,6 +125,56 @@ def _build_permission_response(options: list[Any], *, auto_approve: bool) -> Any
return RequestPermissionResponse(outcome=DeniedOutcome(outcome="cancelled"))
def _agent_path(env: dict[str, str] | None) -> str | None:
"""Return the ``PATH`` the ACP SDK will give the agent subprocess.
The SDK builds the child environment by inheriting a trimmed copy of the
Gateway's environment and then applying ``acp_agents.<name>.env`` on top, so
a configured ``PATH`` -- not the Gateway's -- decides which launcher the
agent actually finds. ``None`` means the agent does not override ``PATH``,
which lets ``shutil.which`` fall back to the Gateway's own.
The lookup is case-insensitive because environment variable names are on
Windows, where ``Path`` and ``PATH`` name the same variable.
"""
if not env:
return None
return next((value for key, value in env.items() if key.upper() == "PATH"), None)
def _resolve_agent_command(command: str, path: str | None = None) -> str:
"""Return the host path to spawn for a configured ACP agent command.
``asyncio.create_subprocess_exec``, which the ACP SDK's stdio transport
uses, does not apply ``PATHEXT`` on Windows: a bare ``npx`` or ``mcode``
raises ``FileNotFoundError`` even though the npm shim (``npx.cmd``) is on
``PATH``. Resolve the name the way the MCP Python SDK normalizes stdio
commands, and keep the configured value when nothing matches so the
not-found remediation still fires.
Only bare names are resolved. A configured path is handed to the spawn
unchanged, so the spawn -- not ``shutil.which`` -- decides what it refers
to.
``path`` is the ``PATH`` the agent subprocess will actually see (see
``_agent_path``); the Gateway's own is only the fallback. A successful
lookup is returned absolute: for a relative ``PATH`` entry ``shutil.which``
yields a result relative to the Gateway's cwd, while the spawn runs with
``cwd`` set to the ACP workspace, where that path resolves elsewhere -- or
not at all.
The lookup stats the filesystem (``shutil.which`` -> ``os.access``), so
callers must run it off the event loop.
"""
if os.path.dirname(command):
return command
try:
resolved = shutil.which(command, path=path)
except OSError:
return command
return os.path.abspath(resolved) if resolved else command
def _format_invocation_error(agent: str, cmd: str, exc: Exception) -> str:
"""Return a user-facing ACP invocation error with actionable remediation."""
if not isinstance(exc, FileNotFoundError):
@ -213,7 +263,12 @@ def build_invoke_acp_agent_tool(agents: dict) -> BaseTool:
return response
client = _CollectingClient()
cmd = agent_config.command
agent_env: dict[str, str] | None = None
if agent_config.env:
agent_env = {k: (os.environ.get(v[1:], "") if v.startswith("$") else v) for k, v in agent_config.env.items()}
# Resolve against the PATH the agent subprocess will see, not the Gateway's:
# the SDK merges agent_env over its inherited environment at spawn time.
cmd = await asyncio.to_thread(_resolve_agent_command, agent_config.command, _agent_path(agent_env))
args = agent_config.args or []
physical_cwd = await asyncio.to_thread(_get_work_dir, thread_id)
try:
@ -225,9 +280,6 @@ def build_invoke_acp_agent_tool(agents: dict) -> BaseTool:
exc,
)
mcp_servers = []
agent_env: dict[str, str] | None = None
if agent_config.env:
agent_env = {k: (os.environ.get(v[1:], "") if v.startswith("$") else v) for k, v in agent_config.env.items()}
try:
from acp import spawn_agent_process
@ -268,7 +320,9 @@ def build_invoke_acp_agent_tool(agents: dict) -> BaseTool:
return result or "(no response)"
except Exception as e:
logger.error("ACP agent '%s' invocation failed: %s", agent, e)
return await asyncio.to_thread(_format_invocation_error, agent, cmd, e)
# Report the configured command, not the resolved path: remediation
# text has to match what the operator wrote in config.yaml.
return await asyncio.to_thread(_format_invocation_error, agent, agent_config.command, e)
return StructuredTool.from_function(
name="invoke_acp_agent",

View File

@ -1,9 +1,15 @@
"""Regression test for ACP invocation setup on the event loop."""
"""Regression test for ACP invocation setup on the event loop.
The configured command is a bare name backed by a launcher shim on a controlled
``PATH``, so the gate really runs the ``shutil.which`` lookup (``os.access``)
instead of short-circuiting on a name that resolves nowhere.
"""
from __future__ import annotations
import asyncio
import contextlib
import sys
from types import SimpleNamespace
from typing import Any
@ -32,6 +38,14 @@ async def test_invoke_acp_agent_setup_does_not_block_event_loop(monkeypatch, tmp
)
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(config_path))
shim_dir = tmp_path / "bin"
shim_dir.mkdir()
shim = shim_dir / ("test-agent.cmd" if sys.platform == "win32" else "test-agent")
shim.write_text("@echo off\n" if sys.platform == "win32" else "#!/bin/sh\n", encoding="utf-8")
if sys.platform != "win32":
shim.chmod(0o755)
monkeypatch.setenv("PATH", str(shim_dir))
captured: dict[str, Any] = {}
class _Connection:

View File

@ -2,8 +2,10 @@
import asyncio
import contextlib
import os
import sys
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
@ -11,11 +13,13 @@ import pytest
from deerflow.config.acp_config import ACPAgentConfig
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
@ -190,6 +194,9 @@ 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(
@ -992,3 +999,275 @@ async def test_invoke_acp_agent_preserves_sdk_timeout_errors(acp_subprocess_tool
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_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_only_resolves_bare_names(monkeypatch, tmp_path):
"""A 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_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)"