from __future__ import annotations import json import os from pathlib import Path import signal import subprocess import sys import tempfile import time import unittest from unittest.mock import patch from ai_triage.acp import ACPClient, ACPProcessError, ACPProtocolError, ACPTimeoutError FAKE_AGENT = Path(__file__).with_name("fake_acp_agent.py") class ACPClientTests(unittest.TestCase): def client(self, scenario: str = "normal", **kwargs: object) -> ACPClient: return ACPClient( [sys.executable, str(FAKE_AGENT), scenario], timeout_seconds=float(kwargs.pop("timeout_seconds", 2)), max_output_bytes=int(kwargs.pop("max_output_bytes", 64_000)), environment=kwargs.pop("environment", None), ) def test_collects_agent_message_chunks_and_removes_isolated_cwd(self) -> None: raw = self.client().prompt("system", "untrusted evidence") result = json.loads(raw) observed_cwd = result.pop("observed_cwd") self.assertEqual("product_error", result["category"]) self.assertFalse(Path(observed_cwd).exists()) self.assertNotEqual(str(Path.cwd()), observed_cwd) def test_rejects_every_tool_permission_request(self) -> None: result = self.client("permission").prompt("system", "evidence") self.assertEqual("permission rejected", result) def test_rejects_malformed_json_rpc(self) -> None: with self.assertRaises(ACPProtocolError): self.client("malformed").prompt("system", "evidence") def test_reports_agent_exit_before_prompt_response(self) -> None: with self.assertRaises(ACPProcessError): self.client("exit").prompt("system", "evidence") def test_enforces_agent_output_limit(self) -> None: with self.assertRaises(ACPProtocolError): self.client("oversize", max_output_bytes=256).prompt("system", "evidence") def test_timeout_terminates_the_agent_process(self) -> None: with tempfile.TemporaryDirectory() as directory: pid_file = Path(directory) / "pid" child_pid_file = Path(directory) / "child-pid" with patch.dict(os.environ, {"AI_TRIAGE_GITHUB_TOKEN": "must-not-leak"}): with self.assertRaises(ACPTimeoutError): self.client( "hang", timeout_seconds=0.15, environment={ "FAKE_ACP_PID_FILE": str(pid_file), "FAKE_ACP_CHILD_PID_FILE": str(child_pid_file), }, ).prompt("system", "evidence") pid = int(pid_file.read_text(encoding="utf-8")) child_pid = int(child_pid_file.read_text(encoding="utf-8")) for process_id in (pid, child_pid): deadline = time.monotonic() + 2 while time.monotonic() < deadline: try: os.kill(process_id, 0) except ProcessLookupError: break time.sleep(0.02) else: self.fail(f"ACP process {process_id} remained alive after timeout") def test_does_not_inherit_controller_secrets(self) -> None: with patch.dict(os.environ, {"AI_TRIAGE_GITHUB_TOKEN": "must-not-leak"}): result = self.client("environment").prompt("system", "evidence") self.assertEqual("not present", result) if __name__ == "__main__": unittest.main()