"""Resume-aware behavior of `worker._run_rpc_blocking`. These tests swap `robomp.worker.RpcClient` for a recording fake so we can observe the `extra_args` and `set_todos` decisions the driver takes based on whether the workspace's omp session directory already holds a JSONL transcript. """ from __future__ import annotations import asyncio import stat from pathlib import Path from types import SimpleNamespace import pytest from robomp import worker from robomp.config import Settings from robomp.git_ops import DirtyState class _FakeRpcClient: instances: list[_FakeRpcClient] = [] def __init__(self, **kwargs): self.kwargs = kwargs self.set_todos_calls: list[list[dict]] = [] self.get_todos_calls = 0 self.stop_calls = 0 self.mark_closed_calls: list[BaseException] = [] _FakeRpcClient.instances.append(self) def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def install_headless_ui(self) -> None: pass def on_tool_execution_end(self, _cb) -> None: pass def on_message_update(self, _cb) -> None: pass def stop(self) -> None: self.stop_calls += 1 def _mark_closed(self, error: BaseException) -> None: self.mark_closed_calls.append(error) def set_todos(self, phases): self.set_todos_calls.append(phases) def get_todos(self): self.get_todos_calls += 1 return () def prompt_and_wait(self, prompt, timeout): if not hasattr(self, "prompts"): self.prompts: list[str] = [] self.prompts.append(prompt) hook = getattr(self, "on_prompt", None) if hook is not None: hook(self, prompt) class _Turn: messages: list = [] events: list = [] assistant_text: str = "ok" assistant_message: dict | None = None return _Turn() _SEEDED_PHASES = [ { "id": "p1", "name": "Reproduce", "tasks": [ { "id": "t1", "content": "do it", "status": "pending", "notes": "", "details": "", } ], } ] def _make_inputs( tmp_path: Path, settings: Settings, *, session_has_jsonl: bool, slot_uid: int | None = None ) -> tuple[worker.TaskInputs, SimpleNamespace]: root = tmp_path / "workspace" root.mkdir() session_dir = root / "session" session_dir.mkdir() if session_has_jsonl: (session_dir / "foo.jsonl").write_text("{}\n", encoding="utf-8") repo_dir = root / "repo" repo_dir.mkdir() workspace = SimpleNamespace( root=root, session_dir=session_dir, repo_dir=repo_dir, branch="robomp/issue-1", ) repo = SimpleNamespace(full_name="acme/widgets", owner="acme", name="widgets") issue = SimpleNamespace(repo="acme/widgets", number=1, title="bug") db = SimpleNamespace(set_event_model=lambda _did, _model: None, get_issue=lambda _key: None) github = SimpleNamespace() inputs = worker.TaskInputs( settings=settings, db=db, # type: ignore[arg-type] github=github, # type: ignore[arg-type] git_transport=SimpleNamespace(), # type: ignore[arg-type] repo=repo, # type: ignore[arg-type] issue=issue, # type: ignore[arg-type] workspace=workspace, # type: ignore[arg-type] delivery_id="d-test", attempts=0, slot_uid=slot_uid, ) bindings = SimpleNamespace( workspace=workspace, repo=repo, issue=issue, issue_key=f"{repo.full_name}#{issue.number}", abort=None, ) return inputs, bindings @pytest.fixture(autouse=True) def _reset_fake() -> None: _FakeRpcClient.instances.clear() @pytest.fixture(autouse=True) def _patch_worker(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setattr("robomp.worker.RpcClient", _FakeRpcClient) monkeypatch.setattr("robomp.worker._AGENT_HOME_STAGE", tmp_path / "missing-agent-home-stage") monkeypatch.setattr("robomp.worker.host_tools.build", lambda _b: ()) monkeypatch.setattr( "robomp.worker.persona.system_append", lambda *, repo, issue, workspace, bot_login: "SYS", ) monkeypatch.setattr( "robomp.worker.persona.seed_phases", lambda _kind: [dict(p) for p in _SEEDED_PHASES], ) @pytest.mark.asyncio async def test_run_task_sets_impl_authorized_from_directive( tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch ) -> None: inputs, _bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False) captured: dict[str, bool] = {} monkeypatch.setattr(worker, "_build_prompt", lambda *args, **kwargs: "prompt") def fake_run_rpc_blocking( _inputs: worker.TaskInputs, *, task_kind: str, prompt: str, loop: asyncio.AbstractEventLoop, bindings: worker.ToolBindings, directive: worker.DirectiveInfo | None = None, ) -> str: del task_kind, prompt, loop, directive captured["impl_authorized"] = bindings.impl_authorized return "ok" monkeypatch.setattr(worker, "_run_rpc_blocking", fake_run_rpc_blocking) result = await worker.run_task( task_kind="triage_issue", inputs=inputs, directive=worker.DirectiveInfo(body="go ahead", author="can1357", authorizes_impl=True), ) assert result == "ok" assert captured == {"impl_authorized": True} @pytest.mark.asyncio async def test_run_task_preserves_impl_authorized_when_resuming( tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch ) -> None: inputs, _bindings = _make_inputs(tmp_path, settings, session_has_jsonl=True) captured: dict[str, bool] = {} monkeypatch.setattr(worker, "_build_prompt", lambda *args, **kwargs: "prompt") def capture_build(bindings: worker.ToolBindings) -> tuple: captured["impl_authorized"] = bindings.impl_authorized return () monkeypatch.setattr(worker.host_tools, "build", capture_build) result = await worker.run_task( task_kind="handle_comment", inputs=inputs, directive=worker.DirectiveInfo(body="go ahead", author="can1357", authorizes_impl=True), ) assert result == "ok" assert captured == {"impl_authorized": True} assert _FakeRpcClient.instances[0].kwargs["extra_args"] == ("--continue",) @pytest.mark.asyncio async def test_run_rpc_passes_continue_when_session_jsonl_present(tmp_path: Path, settings: Settings) -> None: inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=True) loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="triage_issue", prompt="x", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() assert _FakeRpcClient.instances[0].kwargs["extra_args"] == ("--continue",) @pytest.mark.asyncio async def test_run_rpc_omits_continue_when_session_empty( tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch ) -> None: agent_home = tmp_path / "agent-home" agent_home.mkdir() monkeypatch.setattr(worker, "_AGENT_HOME", agent_home) inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False) loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="triage_issue", prompt="x", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() assert _FakeRpcClient.instances[0].kwargs["extra_args"] == () client_kwargs = _FakeRpcClient.instances[0].kwargs assert client_kwargs["env"]["HOME"] == str(agent_home) assert client_kwargs["env"]["GITHUB_TOKEN"] == "" assert client_kwargs["env"]["GITHUB_WEBHOOK_SECRET"] == "" assert client_kwargs["env"]["ROBOMP_REPLAY_TOKEN"] == "" assert client_kwargs["env"]["ROBOMP_GH_PROXY_HMAC_KEY"] == "" assert client_kwargs["user"] is None assert client_kwargs["group"] is None assert client_kwargs["extra_groups"] is None def test_build_extra_env_stages_agent_home(tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch) -> None: stage_home = tmp_path / "agent-home-stage" agent_home = tmp_path / "agent-home" monkeypatch.setattr(worker, "_AGENT_HOME_STAGE", stage_home) monkeypatch.setattr(worker, "_AGENT_HOME", agent_home) agent_dir = stage_home / ".agent" agent_rules_dir = agent_dir / "rules" omp_agent_dir = stage_home / ".omp" / "agent" agent_rules_dir.mkdir(parents=True) omp_agent_dir.mkdir(parents=True) (agent_dir / "AGENTS.md").write_text("agent instructions\n", encoding="utf-8") (agent_rules_dir / "rule.md").write_text("rule\n", encoding="utf-8") (omp_agent_dir / "models.yml").write_text("models: []\n", encoding="utf-8") env = worker._build_extra_env(settings) assert env["HOME"] == str(agent_home) assert (agent_home / ".agent" / "AGENTS.md").is_file() assert (agent_home / ".agent" / "rules" / "rule.md").is_file() assert (agent_home / ".omp" / "agent" / "models.yml").is_file() assert (agent_home / ".agent").stat().st_mode & 0o777 == 0o755 assert (agent_home / ".agent" / "AGENTS.md").stat().st_mode & 0o777 == 0o644 assert (agent_home / ".agent" / "rules").stat().st_mode & 0o777 == 0o755 assert (agent_home / ".agent" / "rules" / "rule.md").stat().st_mode & 0o777 == 0o644 assert (agent_home / ".omp" / "agent").stat().st_mode & 0o777 == 0o755 assert (agent_home / ".omp" / "agent" / "models.yml").stat().st_mode & 0o777 == 0o644 @pytest.mark.asyncio async def test_run_rpc_omits_home_when_agent_home_absent( tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(worker, "_AGENT_HOME", tmp_path / "missing-agent-home") inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False) loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="triage_issue", prompt="x", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() client_kwargs = _FakeRpcClient.instances[0].kwargs assert "HOME" not in client_kwargs["env"] assert client_kwargs["env"]["GITHUB_TOKEN"] == "" assert client_kwargs["env"]["GITHUB_WEBHOOK_SECRET"] == "" assert client_kwargs["env"]["ROBOMP_REPLAY_TOKEN"] == "" assert client_kwargs["env"]["ROBOMP_GH_PROXY_HMAC_KEY"] == "" @pytest.mark.asyncio async def test_run_rpc_uses_workspace_xdg_dirs_without_slot(tmp_path: Path, settings: Settings) -> None: inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False, slot_uid=None) loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="triage_issue", prompt="x", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() env = _FakeRpcClient.instances[0].kwargs["env"] xdg_root = inputs.workspace.root / ".omp-xdg" for key in ("XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"): path = Path(env[key]) assert path.is_relative_to(xdg_root) assert (path / "omp").is_dir() tmpdir = inputs.workspace.root / ".omp-tmp" assert env["TMPDIR"] == str(tmpdir) assert env["TMP"] == str(tmpdir) assert env["TEMP"] == str(tmpdir) assert env["GIT_CONFIG_COUNT"] == "1" assert env["GIT_CONFIG_KEY_0"] == "safe.directory" assert env["GIT_CONFIG_VALUE_0"] == str(inputs.workspace.repo_dir) assert env["GIT_AUTHOR_NAME"] == settings.resolved_author_name assert env["GIT_AUTHOR_EMAIL"] == settings.git_author_email assert env["GIT_COMMITTER_NAME"] == settings.resolved_author_name assert env["GIT_COMMITTER_EMAIL"] == settings.git_author_email assert tmpdir.is_dir() assert stat.S_IMODE(tmpdir.stat().st_mode) == 0o700 @pytest.mark.asyncio async def test_run_rpc_uses_workspace_xdg_dirs_for_slot_without_chown( tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch ) -> None: chown_calls: list[tuple[Path, int, int]] = [] monkeypatch.setattr("robomp.sandbox.platform.system", lambda: "Linux") monkeypatch.setattr("robomp.sandbox.os.geteuid", lambda: 0) monkeypatch.setattr("robomp.sandbox.os.chown", lambda path, uid, gid: chown_calls.append((Path(path), uid, gid))) inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False, slot_uid=2001) loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="triage_issue", prompt="x", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() env = _FakeRpcClient.instances[0].kwargs["env"] for key in ("XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"): base = Path(env[key]) assert base.is_dir() assert (base / "omp").is_dir() assert Path(env["BUN_INSTALL_CACHE_DIR"]).is_dir() assert chown_calls == [] @pytest.mark.asyncio async def test_run_rpc_skips_set_todos_on_resumed_triage(tmp_path: Path, settings: Settings) -> None: inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=True) loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="triage_issue", prompt="x", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() assert _FakeRpcClient.instances[0].set_todos_calls == [] @pytest.mark.asyncio async def test_run_rpc_seeds_todos_on_fresh_triage(tmp_path: Path, settings: Settings) -> None: inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False) loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="triage_issue", prompt="x", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() calls = _FakeRpcClient.instances[0].set_todos_calls assert len(calls) == 1 assert calls[0] == _SEEDED_PHASES @pytest.mark.asyncio async def test_run_rpc_merges_todos_on_followup_with_resume(tmp_path: Path, settings: Settings) -> None: inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=True) loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="handle_comment", prompt="x", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() client = _FakeRpcClient.instances[0] assert client.get_todos_calls == 1 assert len(client.set_todos_calls) == 1 assert len(client.set_todos_calls[0]) == len(_SEEDED_PHASES) @pytest.mark.asyncio async def test_run_rpc_passes_slot_uid_user_slot_group_and_omp_extra_group(tmp_path: Path, settings: Settings) -> None: inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False, slot_uid=2001) loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="triage_issue", prompt="x", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() client_kwargs = _FakeRpcClient.instances[0].kwargs assert client_kwargs["user"] == 2001 assert client_kwargs["group"] == 2001 assert client_kwargs["extra_groups"] == ["omp"] @pytest.mark.asyncio async def test_run_rpc_arms_hard_timeout_timer( tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch ) -> None: timers = [] class FakeTimer: def __init__(self, interval, function): self.interval = interval self.function = function self.daemon = False self.started = False self.cancelled = False timers.append(self) def start(self) -> None: self.started = True def cancel(self) -> None: self.cancelled = True monkeypatch.setattr("robomp.worker.threading.Timer", FakeTimer) settings.task_timeout_seconds = 3.0 settings.task_timeout_hard_grace_seconds = 7.0 inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False) loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="triage_issue", prompt="x", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() assert len(timers) == 1 timer = timers[0] assert timer.interval == 10.0 assert timer.daemon is True assert timer.started is True assert timer.cancelled is True @pytest.mark.asyncio async def test_run_rpc_hard_timeout_stops_client_and_fails( tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch ) -> None: class FiringTimer: def __init__(self, interval, function): self.interval = interval self.function = function self.daemon = False self.cancelled = False def start(self) -> None: self.function() def cancel(self) -> None: self.cancelled = True monkeypatch.setattr("robomp.worker.threading.Timer", FiringTimer) inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False) loop = asyncio.new_event_loop() try: with pytest.raises(TimeoutError, match="hard timeout"): worker._run_rpc_blocking( inputs, task_kind="triage_issue", prompt="x", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() fake = _FakeRpcClient.instances[0] assert fake.stop_calls == 1 # `_cancel_hook` (used by both manual cancel and hard timeout) MUST also call # `_mark_closed` to unblock `_wait_for_agent_end` — `stop()` alone leaves # `_closed_error` unset (omp_rpc bug), so the worker would hang otherwise. assert len(fake.mark_closed_calls) == 1 from omp_rpc import RpcProcessExitError assert isinstance(fake.mark_closed_calls[0], RpcProcessExitError) @pytest.mark.asyncio async def test_run_rpc_cancel_hook_stops_and_marks_closed( tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch ) -> None: """The cancel hook registered with `register_cancel_hook` must call both `client.stop()` AND `client._mark_closed()`. The latter is the workaround for an upstream omp_rpc bug where `stop()` does not set `_closed_error`, leaving `_wait_for_agent_end` blocked until timeout.""" captured: list = [] monkeypatch.setattr("robomp.worker.register_cancel_hook", lambda hook: captured.append(hook)) monkeypatch.setattr("robomp.worker.unregister_cancel_hook", lambda: None) inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False) loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="triage_issue", prompt="x", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() assert len(captured) == 1 hook = captured[0] fake = _FakeRpcClient.instances[0] pre_stop = fake.stop_calls hook() # Simulate the API/worker firing the cancel assert fake.stop_calls == pre_stop + 1 assert len(fake.mark_closed_calls) == 1 from omp_rpc import RpcProcessExitError assert isinstance(fake.mark_closed_calls[0], RpcProcessExitError) assert "cancelled by operator" in str(fake.mark_closed_calls[0]) class _ClassifiedRow: """Stand-in for `db.IssueRow` carrying just `.classification`.""" def __init__(self, classification: str | None) -> None: self.classification = classification def _make_inputs_with_classification( tmp_path: Path, settings: Settings, *, classification: str | None, ) -> tuple[worker.TaskInputs, SimpleNamespace]: inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=True) row = _ClassifiedRow(classification) if classification else None inputs.db.get_issue = lambda _key: row # type: ignore[attr-defined] bindings.db = inputs.db # tools_called check uses inputs.db.get_issue return inputs, bindings @pytest.mark.asyncio async def test_run_rpc_sends_reminder_when_pr_class_quits_early(tmp_path: Path, settings: Settings) -> None: """`bug` classified turn that never calls a terminal tool gets a reminder.""" inputs, bindings = _make_inputs_with_classification(tmp_path, settings, classification="bug") loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="triage_issue", prompt="kickoff", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() fake = _FakeRpcClient.instances[0] # kickoff + 2 reminders (default ROBOMP_TASK_COMPLETION_MAX_REMINDERS=2) assert len(fake.prompts) == 1 + settings.task_completion_max_reminders assert fake.prompts[0] == "kickoff" assert all("terminal action" in p.lower() or "open the pr" in p.lower() for p in fake.prompts[1:]) @pytest.mark.asyncio async def test_run_rpc_stops_reminding_after_terminal_tool(tmp_path: Path, settings: Settings) -> None: """A reminder turn that fires `gh_open_pr` halts the loop.""" inputs, bindings = _make_inputs_with_classification(tmp_path, settings, classification="bug") # First turn returns with no terminal tool; first reminder causes the # agent to "call" gh_open_pr — simulated by mutating the worker's # tools_called set via the on_prompt hook on the next prompt. def _on_prompt(client: _FakeRpcClient, prompt: str) -> None: if len(client.prompts) == 2: # this is the first reminder # Mimic a tool_end firing during the reminder turn by writing # into the closure set the driver tracks. We can't reach it # directly; instead trip the abort path? No — use the public # contract: tool_end fires through on_tool_execution_end. The # driver registers the callback before prompt_and_wait, so we # replay it here. for cb in client._tool_end_callbacks: cb(SimpleNamespace(tool_name="gh_open_pr", result={})) # Capture the registered tool_end callback on the fake. original_on_tool_end = _FakeRpcClient.on_tool_execution_end def _record_tool_end(self, cb) -> None: self._tool_end_callbacks = getattr(self, "_tool_end_callbacks", []) self._tool_end_callbacks.append(cb) _FakeRpcClient.on_tool_execution_end = _record_tool_end # type: ignore[assignment] try: _FakeRpcClient.on_prompt = staticmethod(_on_prompt) # type: ignore[attr-defined] loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="triage_issue", prompt="kickoff", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() finally: _FakeRpcClient.on_tool_execution_end = original_on_tool_end # type: ignore[assignment] delattr(_FakeRpcClient, "on_prompt") fake = _FakeRpcClient.instances[0] # kickoff + 1 reminder; second reminder NOT sent because gh_open_pr fired. assert len(fake.prompts) == 2, fake.prompts @pytest.mark.asyncio async def test_run_rpc_skips_reminder_for_non_pr_classification(tmp_path: Path, settings: Settings) -> None: """`question` classified turns are not enforced — no reminder.""" inputs, bindings = _make_inputs_with_classification(tmp_path, settings, classification="question") loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="triage_issue", prompt="kickoff", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() fake = _FakeRpcClient.instances[0] assert len(fake.prompts) == 1 @pytest.mark.asyncio async def test_run_rpc_skips_reminder_when_unclassified(tmp_path: Path, settings: Settings) -> None: """No classification (agent quit before classify_issue) → no reminder.""" inputs, bindings = _make_inputs_with_classification(tmp_path, settings, classification=None) loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="triage_issue", prompt="kickoff", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() fake = _FakeRpcClient.instances[0] assert len(fake.prompts) == 1 @pytest.mark.asyncio async def test_run_rpc_review_pr_reminds_until_submit_pr_review(tmp_path: Path, settings: Settings) -> None: inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False) loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="review_pr", prompt="kickoff", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() fake = _FakeRpcClient.instances[0] assert len(fake.prompts) == 1 + settings.task_completion_max_reminders assert fake.prompts[0] == "kickoff" assert all("submit_pr_review" in p for p in fake.prompts[1:]) assert all("gh_open_pr" not in p for p in fake.prompts[1:]) @pytest.mark.asyncio async def test_run_rpc_review_pr_stops_after_submit_without_dirty_probe( tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch ) -> None: inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False) def _probe(_workspace, _slot_uid): # type: ignore[no-untyped-def] raise AssertionError("review_pr must not run dirty-state probes") monkeypatch.setattr(worker, "_probe_workspace_dirty", _probe) original_on_tool_end = _FakeRpcClient.on_tool_execution_end def _record_tool_end(self, cb) -> None: self._tool_end_callbacks = getattr(self, "_tool_end_callbacks", []) self._tool_end_callbacks.append(cb) def _on_prompt(client: _FakeRpcClient, _prompt: str) -> None: for cb in client._tool_end_callbacks: cb(SimpleNamespace(tool_name="submit_pr_review", result={})) _FakeRpcClient.on_tool_execution_end = _record_tool_end # type: ignore[assignment] try: _FakeRpcClient.on_prompt = staticmethod(_on_prompt) # type: ignore[attr-defined] loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="review_pr", prompt="kickoff", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() finally: _FakeRpcClient.on_tool_execution_end = original_on_tool_end # type: ignore[assignment] delattr(_FakeRpcClient, "on_prompt") fake = _FakeRpcClient.instances[0] assert fake.prompts == ["kickoff"] # --------------------------------------------------------------------------- # Dirty-state watchdog # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_run_rpc_sends_dirty_state_reminder_when_worktree_has_unpushed_work( tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch ) -> None: """Agent ended its turn with unpushed commits → reminder fires; clean → loop exits.""" inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False) dirty = DirtyState(uncommitted=2, unpushed=1, summary="Unpushed commits (1):\nabc1234 wip") clean = DirtyState(uncommitted=0, unpushed=0, summary="") states = iter([dirty, clean]) monkeypatch.setattr(worker, "_probe_workspace_dirty", lambda _ws, _slot: next(states, clean)) loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="handle_comment", prompt="kickoff", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() fake = _FakeRpcClient.instances[0] assert len(fake.prompts) == 2, fake.prompts reminder = fake.prompts[1] assert "Unpushed commits" in reminder assert "abc1234" in reminder assert "{{" not in reminder, "template placeholder leaked" @pytest.mark.asyncio async def test_run_rpc_skips_dirty_state_reminder_when_worktree_is_clean( tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch ) -> None: """Clean worktree at end of turn → no extra prompts.""" inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False) monkeypatch.setattr( worker, "_probe_workspace_dirty", lambda _ws, _slot: DirtyState(uncommitted=0, unpushed=0, summary=""), ) loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="handle_comment", prompt="kickoff", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() fake = _FakeRpcClient.instances[0] assert len(fake.prompts) == 1 @pytest.mark.asyncio async def test_run_rpc_caps_dirty_state_reminders_at_budget( tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch ) -> None: """Persistently dirty workspace must not loop past the reminder budget.""" inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False) dirty = DirtyState(uncommitted=1, unpushed=0, summary="Uncommitted changes (1):\n?? oops.txt") monkeypatch.setattr(worker, "_probe_workspace_dirty", lambda _ws, _slot: dirty) loop = asyncio.new_event_loop() try: worker._run_rpc_blocking( inputs, task_kind="handle_comment", prompt="kickoff", loop=loop, bindings=bindings, # type: ignore[arg-type] ) finally: loop.close() fake = _FakeRpcClient.instances[0] # kickoff + N reminders, capped at the configured budget (default 2). assert len(fake.prompts) == 1 + settings.task_completion_max_reminders # --------------------------------------------------------------------------- # Natives-cache capture-on-success # --------------------------------------------------------------------------- class _RecordingNativesCache: """Test double for `NativesCache`: records `capture` calls, optionally raises so we can verify exception swallowing.""" def __init__(self, *, raise_on_capture: bool = False) -> None: self.capture_calls: list[tuple[str, str, Path]] = [] self.raise_on_capture = raise_on_capture def capture(self, repo: str, key: str, native_dir: Path, **_kwargs) -> Path | None: self.capture_calls.append((repo, key, native_dir)) if self.raise_on_capture: raise RuntimeError("simulated cache failure") return native_dir def _make_capture_inputs( tmp_path: Path, settings: Settings, *, cache: _RecordingNativesCache | None, with_native_artifacts: bool, ) -> worker.TaskInputs: """Build a `TaskInputs` whose workspace optionally has built natives.""" inputs, _ = _make_inputs(tmp_path, settings, session_has_jsonl=False) # Replace the SimpleNamespace workspace with one carrying the fields # `_capture_natives_cache` needs (workspace_key + repo_full_name). ws = SimpleNamespace( root=inputs.workspace.root, session_dir=inputs.workspace.session_dir, repo_dir=inputs.workspace.repo_dir, branch=inputs.workspace.branch, workspace_key="acme__widgets__1", repo_full_name="acme/widgets", ) if with_native_artifacts: native_dir = ws.repo_dir / "packages" / "natives" / "native" native_dir.mkdir(parents=True) (native_dir / "pi_natives.linux-arm64.node").write_bytes(b"ELFx") (native_dir / "index.d.ts").write_text("") (native_dir / "index.js").write_text("") (native_dir / "embedded-addon.js").write_text("") return worker.TaskInputs( settings=settings, db=inputs.db, github=inputs.github, git_transport=inputs.git_transport, repo=inputs.repo, issue=inputs.issue, workspace=ws, # type: ignore[arg-type] delivery_id=inputs.delivery_id, attempts=inputs.attempts, slot_uid=inputs.slot_uid, natives_cache=cache, # type: ignore[arg-type] ) def test_capture_natives_cache_no_op_without_cache(tmp_path: Path, settings: Settings) -> None: inputs = _make_capture_inputs(tmp_path, settings, cache=None, with_native_artifacts=True) # Just must not raise. worker._capture_natives_cache(inputs) def test_capture_natives_cache_skips_without_artifacts(tmp_path: Path, settings: Settings) -> None: cache = _RecordingNativesCache() inputs = _make_capture_inputs(tmp_path, settings, cache=cache, with_native_artifacts=False) worker._capture_natives_cache(inputs) # No artifacts → no key compute, no capture. assert cache.capture_calls == [] def test_capture_natives_cache_swallows_key_compute_failure( tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch ) -> None: cache = _RecordingNativesCache() inputs = _make_capture_inputs(tmp_path, settings, cache=cache, with_native_artifacts=True) # Repo dir is not a git repo → natives_compute_key raises. # Already true for the SimpleNamespace workspace (repo_dir is plain tmp dir). worker._capture_natives_cache(inputs) assert cache.capture_calls == [] def test_capture_natives_cache_swallows_capture_exception( tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch ) -> None: cache = _RecordingNativesCache(raise_on_capture=True) inputs = _make_capture_inputs(tmp_path, settings, cache=cache, with_native_artifacts=True) # Bypass git: stub the key compute so capture is reached. monkeypatch.setattr(worker, "natives_compute_key", lambda _repo_dir: "deadbeef") # Must not propagate the RuntimeError. worker._capture_natives_cache(inputs) assert len(cache.capture_calls) == 1 def test_capture_natives_cache_records_on_success( tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch ) -> None: cache = _RecordingNativesCache() inputs = _make_capture_inputs(tmp_path, settings, cache=cache, with_native_artifacts=True) monkeypatch.setattr(worker, "natives_compute_key", lambda _repo_dir: "cafef00d") worker._capture_natives_cache(inputs) assert len(cache.capture_calls) == 1 repo, key, native_dir = cache.capture_calls[0] assert repo == "acme/widgets" assert key == "cafef00d" assert native_dir == inputs.workspace.repo_dir / "packages" / "natives" / "native"