283 lines
9.7 KiB
Python
283 lines
9.7 KiB
Python
"""Tests for subprocess teammate spawning."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
from openharness.tasks.manager import BackgroundTaskManager
|
|
from openharness.tasks.types import TaskRecord
|
|
from openharness.swarm.subprocess_backend import SubprocessBackend
|
|
from openharness.swarm.types import TeammateSpawnConfig
|
|
|
|
|
|
def _argv_str(captured: dict) -> str:
|
|
"""Join captured argv into a string for substring assertions."""
|
|
argv = captured.get("argv") or []
|
|
return " ".join(argv)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subprocess_backend_forwards_system_prompt_in_command(monkeypatch, tmp_path: Path):
|
|
captured: dict[str, object] = {}
|
|
|
|
async def _fake_create_agent_task(self, **kwargs):
|
|
captured.update(kwargs)
|
|
return TaskRecord(
|
|
id="task_123",
|
|
type="local_agent",
|
|
status="running",
|
|
description=str(kwargs["description"]),
|
|
cwd=str(kwargs["cwd"]),
|
|
output_file=tmp_path / "task_123.log",
|
|
argv=list(kwargs.get("argv") or []),
|
|
)
|
|
|
|
monkeypatch.setattr(BackgroundTaskManager, "create_agent_task", _fake_create_agent_task)
|
|
monkeypatch.setattr("openharness.swarm.subprocess_backend.get_teammate_command", lambda: "/usr/bin/python3")
|
|
|
|
backend = SubprocessBackend()
|
|
config = TeammateSpawnConfig(
|
|
name="reviewer",
|
|
team="default",
|
|
prompt="Review the code changes.",
|
|
cwd=str(tmp_path),
|
|
parent_session_id="sess-001",
|
|
system_prompt="You are a careful code reviewer.",
|
|
task_type="local_agent",
|
|
)
|
|
|
|
result = await backend.spawn(config)
|
|
|
|
assert result.success is True
|
|
argv_str = _argv_str(captured)
|
|
assert "--system-prompt" in argv_str
|
|
assert "You are a careful code reviewer." in argv_str
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subprocess_backend_forwards_append_system_prompt_mode(monkeypatch, tmp_path: Path):
|
|
captured: dict[str, object] = {}
|
|
|
|
async def _fake_create_agent_task(self, **kwargs):
|
|
captured.update(kwargs)
|
|
return TaskRecord(
|
|
id="task_234",
|
|
type="local_agent",
|
|
status="running",
|
|
description=str(kwargs["description"]),
|
|
cwd=str(kwargs["cwd"]),
|
|
output_file=tmp_path / "task_234.log",
|
|
argv=list(kwargs.get("argv") or []),
|
|
)
|
|
|
|
monkeypatch.setattr(BackgroundTaskManager, "create_agent_task", _fake_create_agent_task)
|
|
monkeypatch.setattr("openharness.swarm.subprocess_backend.get_teammate_command", lambda: "/usr/bin/python3")
|
|
|
|
backend = SubprocessBackend()
|
|
config = TeammateSpawnConfig(
|
|
name="reviewer",
|
|
team="default",
|
|
prompt="Review the code changes.",
|
|
cwd=str(tmp_path),
|
|
parent_session_id="sess-001",
|
|
system_prompt="Project-specific addendum.",
|
|
system_prompt_mode="append",
|
|
task_type="local_agent",
|
|
)
|
|
|
|
result = await backend.spawn(config)
|
|
|
|
assert result.success is True
|
|
argv_str = _argv_str(captured)
|
|
assert "--append-system-prompt" in argv_str
|
|
assert "Project-specific addendum." in argv_str
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subprocess_backend_passes_argv_list_not_shell_command(
|
|
monkeypatch, tmp_path: Path
|
|
):
|
|
"""Regression test for #230: teammate spawn must use the direct-exec
|
|
``argv`` path, not the shell-evaluated ``command`` path. Shell routing
|
|
via Git Bash on Windows cannot reliably exec Windows-pathed binaries
|
|
(e.g. ``C:\\Users\\...\\python.exe``) when bash is itself launched via
|
|
``asyncio.create_subprocess_exec`` — the same call that works
|
|
interactively returns ``command not found``. Bypassing the shell
|
|
sidesteps the entire class of cross-platform quoting bug."""
|
|
captured: dict[str, object] = {}
|
|
|
|
async def _fake_create_agent_task(self, **kwargs):
|
|
captured.update(kwargs)
|
|
return TaskRecord(
|
|
id="task_argv",
|
|
type="local_agent",
|
|
status="running",
|
|
description=str(kwargs["description"]),
|
|
cwd=str(kwargs["cwd"]),
|
|
output_file=tmp_path / "task_argv.log",
|
|
argv=list(kwargs.get("argv") or []),
|
|
)
|
|
|
|
monkeypatch.setattr(BackgroundTaskManager, "create_agent_task", _fake_create_agent_task)
|
|
|
|
backend = SubprocessBackend()
|
|
config = TeammateSpawnConfig(
|
|
name="worker",
|
|
team="default",
|
|
prompt="hi",
|
|
cwd=str(tmp_path),
|
|
parent_session_id="sess-001",
|
|
task_type="local_agent",
|
|
)
|
|
result = await backend.spawn(config)
|
|
|
|
assert result.success is True
|
|
# Spawn must hand off as argv, not command.
|
|
assert captured.get("command") is None
|
|
argv = captured.get("argv")
|
|
assert isinstance(argv, list) and argv, "expected non-empty argv list"
|
|
# First element is the exact Python interpreter selected for teammate spawn.
|
|
assert argv[0] == sys.executable
|
|
# Tail must include the worker invocation.
|
|
assert "--task-worker" in argv
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subprocess_backend_argv_preserves_windows_backslashes(
|
|
monkeypatch, tmp_path: Path
|
|
):
|
|
"""When ``get_teammate_command()`` returns a Windows path with
|
|
backslashes, those backslashes must arrive at the manager intact.
|
|
Using a list (``argv``) instead of a shell-evaluated string is what
|
|
makes that guarantee — there is no shell escape parser between us
|
|
and ``asyncio.create_subprocess_exec``."""
|
|
captured: dict[str, object] = {}
|
|
|
|
async def _fake_create_agent_task(self, **kwargs):
|
|
captured.update(kwargs)
|
|
return TaskRecord(
|
|
id="task_winargv",
|
|
type="local_agent",
|
|
status="running",
|
|
description=str(kwargs["description"]),
|
|
cwd=str(kwargs["cwd"]),
|
|
output_file=tmp_path / "task_winargv.log",
|
|
argv=list(kwargs.get("argv") or []),
|
|
)
|
|
|
|
win_path = r"C:\Users\simu\AppData\Roaming\uv\tools\openharness-ai\Scripts\python.exe"
|
|
monkeypatch.setattr(BackgroundTaskManager, "create_agent_task", _fake_create_agent_task)
|
|
# Use the public override env var rather than monkeypatching the
|
|
# function symbol — proved fragile under full-suite pytest module
|
|
# attribute resolution.
|
|
monkeypatch.setenv("OPENHARNESS_TEAMMATE_COMMAND", win_path)
|
|
|
|
backend = SubprocessBackend()
|
|
config = TeammateSpawnConfig(
|
|
name="worker",
|
|
team="default",
|
|
prompt="hi",
|
|
cwd=str(tmp_path),
|
|
parent_session_id="sess-001",
|
|
task_type="local_agent",
|
|
)
|
|
result = await backend.spawn(config)
|
|
|
|
assert result.success is True
|
|
argv = captured.get("argv") or []
|
|
assert argv[0] == win_path, f"backslashed path mangled: {argv[0]!r}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subprocess_backend_passes_env_via_kwarg(
|
|
monkeypatch, tmp_path: Path
|
|
):
|
|
"""Inherited teammate env vars must flow through ``env=`` on
|
|
``create_agent_task`` and never be embedded in argv as ``KEY=val``
|
|
tokens (which the OS would just pass to the child as plain args)."""
|
|
captured: dict[str, object] = {}
|
|
|
|
async def _fake_create_agent_task(self, **kwargs):
|
|
captured.update(kwargs)
|
|
return TaskRecord(
|
|
id="task_env",
|
|
type="local_agent",
|
|
status="running",
|
|
description=str(kwargs["description"]),
|
|
cwd=str(kwargs["cwd"]),
|
|
output_file=tmp_path / "task_env.log",
|
|
argv=list(kwargs.get("argv") or []),
|
|
)
|
|
|
|
monkeypatch.setattr(BackgroundTaskManager, "create_agent_task", _fake_create_agent_task)
|
|
monkeypatch.setattr(
|
|
"openharness.swarm.subprocess_backend.get_teammate_command",
|
|
lambda: "/usr/bin/python3",
|
|
)
|
|
|
|
backend = SubprocessBackend()
|
|
config = TeammateSpawnConfig(
|
|
name="worker",
|
|
team="default",
|
|
prompt="hi",
|
|
cwd=str(tmp_path),
|
|
parent_session_id="sess-001",
|
|
task_type="local_agent",
|
|
)
|
|
result = await backend.spawn(config)
|
|
|
|
assert result.success is True
|
|
argv = captured.get("argv") or []
|
|
# No element of argv should look like a shell env-prefix token.
|
|
for token in argv:
|
|
assert "OPENHARNESS_AGENT_TEAMS=" not in token
|
|
assert "CLAUDE_CODE_COORDINATOR_MODE=" not in token
|
|
env = captured.get("env")
|
|
assert isinstance(env, dict)
|
|
assert env.get("OPENHARNESS_AGENT_TEAMS") == "1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subprocess_backend_does_not_inject_env_when_command_overridden(
|
|
monkeypatch, tmp_path: Path
|
|
):
|
|
"""When the caller supplies ``config.command``, we do not silently
|
|
inject inherited env vars and we use the shell-evaluated path (not
|
|
the argv path). This preserves caller intent and pre-fix behaviour
|
|
for code that already builds its own shell-quoted command string."""
|
|
captured: dict[str, object] = {}
|
|
|
|
async def _fake_create_agent_task(self, **kwargs):
|
|
captured.update(kwargs)
|
|
return TaskRecord(
|
|
id="task_override",
|
|
type="local_agent",
|
|
status="running",
|
|
description=str(kwargs["description"]),
|
|
cwd=str(kwargs["cwd"]),
|
|
output_file=tmp_path / "task_override.log",
|
|
command=str(kwargs.get("command") or ""),
|
|
)
|
|
|
|
monkeypatch.setattr(BackgroundTaskManager, "create_agent_task", _fake_create_agent_task)
|
|
|
|
backend = SubprocessBackend()
|
|
config = TeammateSpawnConfig(
|
|
name="worker",
|
|
team="default",
|
|
prompt="hi",
|
|
cwd=str(tmp_path),
|
|
parent_session_id="sess-001",
|
|
command="custom-script --do-it",
|
|
task_type="local_agent",
|
|
)
|
|
result = await backend.spawn(config)
|
|
|
|
assert result.success is True
|
|
assert captured["command"] == "custom-script --do-it"
|
|
assert captured.get("argv") is None
|
|
assert captured.get("env") is None
|