deer-flow/backend/tests/blocking_io/test_invoke_acp_agent_tool.py
Evan 195bf358ca
fix(acp): fail fast when an ACP agent exits and drain its stderr (#7354)
* fix(acp): fail fast when an ACP agent exits and drain its stderr

An ACP agent that exited before answering (missing credentials, a wrong
argument) left invoke_acp_agent waiting for the whole timeout_seconds
budget (30 minutes by default) and then suggested raising the timeout.
The pinned SDK never fails pending requests when the agent's stdout
reaches EOF, so nothing noticed the process was gone.

The SDK also opens the agent's stderr as a pipe and never reads it. An
agent that logged more than asyncio's pipe buffering absorbs blocked on
its next stderr write and stalled the invocation the same way.

Race the ACP exchange against the agent process: when it exits (or a
newer SDK reports the connection closed), allow a short grace for
responses still buffered in the pipe, then report the exit code. Drain
stderr continuously into a bounded tail that is logged on failure and
kept out of model-visible text.

* fix(acp): log the full stderr tail on timeout and pin tail retention

Log the timeout tail after the SDK context has terminated and reaped the
agent, so lines still in flight when it stalled are included. Make the
exit test write more stderr than the retained tail, so keeping the start
of the stream instead of its end fails the assertion on the fatal line.
2026-10-23 22:30:26 +08:00

84 lines
3.1 KiB
Python

"""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
import acp
import pytest
from deerflow.config.acp_config import ACPAgentConfig
from deerflow.tools.builtins import invoke_acp_agent_tool as acp_tool
pytestmark = pytest.mark.asyncio
async def test_invoke_acp_agent_setup_does_not_block_event_loop(monkeypatch, tmp_path) -> None:
from deerflow.config import paths as paths_module
configured_paths = SimpleNamespace(
base_dir=tmp_path,
acp_workspace_dir=lambda thread_id, user_id=None: tmp_path / "threads" / thread_id / "acp-workspace",
)
monkeypatch.setattr(paths_module, "get_paths", lambda: configured_paths)
config_path = tmp_path / "extensions_config.json"
await asyncio.to_thread(
config_path.write_text,
'{"mcpServers": {"test-server": {"enabled": true, "type": "http", "url": "https://example.test/mcp"}}, "skills": {}}',
encoding="utf-8",
)
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:
async def initialize(self, **kwargs: Any) -> None:
captured["initialize"] = kwargs
async def new_session(self, **kwargs: Any) -> SimpleNamespace:
captured["new_session"] = kwargs
return SimpleNamespace(session_id="session-1")
async def prompt(self, **kwargs: Any) -> None:
captured["prompt"] = kwargs
@contextlib.asynccontextmanager
async def fake_spawn_agent_process(client, command, *args, env=None, cwd=None):
captured["cwd"] = cwd
# An agent process that stays alive for the whole exchange.
yield _Connection(), SimpleNamespace(stderr=None, wait=asyncio.Event().wait)
monkeypatch.setattr(acp, "spawn_agent_process", fake_spawn_agent_process)
tool = acp_tool.build_invoke_acp_agent_tool(
{"test-agent": ACPAgentConfig(command="test-agent", description="Test agent")},
)
result = await tool.coroutine(
agent="test-agent",
prompt="run",
config={"configurable": {"thread_id": "thread-1"}},
)
expected_cwd = tmp_path / "threads" / "thread-1" / "acp-workspace"
assert result == "(no response)"
assert captured["cwd"] == str(expected_cwd)
assert captured["new_session"]["mcp_servers"] == [{"name": "test-server", "type": "http", "url": "https://example.test/mcp", "headers": []}]
assert expected_cwd.is_dir()