diff --git a/run_agent.py b/run_agent.py index e48e83f8f7dc9..2ecb005ea9d00 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2874,6 +2874,17 @@ def steer(self, text: str) -> bool: self._pending_steer = self._pending_steer + "\n" + cleaned else: self._pending_steer = cleaned + # Propagate steer to any running child agents (subagent delegation) + # so the user's mid-turn message reaches subagents, not just the parent. + _children_lock = getattr(self, "_active_children_lock", None) + if _children_lock is not None: + with _children_lock: + children_copy = list(self._active_children) + for child in children_copy: + try: + child.steer(cleaned) + except Exception: + pass return True def _drain_pending_steer(self) -> Optional[str]: diff --git a/tests/cli/test_moa_one_shot_behavior.py b/tests/cli/test_moa_one_shot_behavior.py new file mode 100644 index 0000000000000..f82f2266eb904 --- /dev/null +++ b/tests/cli/test_moa_one_shot_behavior.py @@ -0,0 +1,260 @@ +"""Tests for /moa one-shot behavior: dispatch, no-continuation-state, invalid input. + +Verifies: + (1) /moa dispatches to the immediate execution path (sets pending + seed, MoA provider swap, disable-after-turn flag, restore snapshot). + (2) Normal (non-MoA) mode is never affected by one-shot machinery. + (3) Invalid one-shot inputs (empty, whitespace, agent-running) return the + expected error/help and create no side effects. + (4) After the one-shot turn completes, no follow-up/continuation state + remains — the model is restored and MoA flags are cleared. +""" + +import queue +from unittest.mock import MagicMock, patch + +from cli import HermesCLI + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_cli(**overrides): + """Build a minimal HermesCLI with just the fields /moa and chat() touch.""" + cli = HermesCLI.__new__(HermesCLI) + cli.config = { + "moa": { + "default_preset": "default", + "presets": { + "default": { + "reference_models": [ + {"provider": "openai-codex", "model": "gpt-5.5"}, + ], + "aggregator": { + "provider": "openrouter", + "model": "anthropic/claude-opus-4.8", + }, + }, + }, + } + } + cli._pending_input = queue.Queue() + cli._pending_agent_seed = None + cli._pending_moa_config = None + cli._pending_moa_disable_after_turn = False + cli._pending_moa_restore_model = None + cli._agent_running = False + cli.agent = None + cli.provider = "openrouter" + cli.requested_provider = "openrouter" + cli.model = "anthropic/claude-opus-4.8" + cli.api_key = "test-key" + cli.base_url = "https://openrouter.ai/api/v1" + cli.api_mode = "chat_completions" + for k, v in overrides.items(): + setattr(cli, k, v) + return cli + + +# --------------------------------------------------------------------------- +# (1) One-shot dispatch sets immediate execution state +# --------------------------------------------------------------------------- + +def test_one_shot_sets_pending_seed(): + """The prompt is placed in _pending_agent_seed so the main loop picks it up + as the next agent turn input without blocking on user input.""" + cli = _make_cli() + with patch("cli._cprint"): + cli.process_command("/moa explain deadlocks in Go") + assert cli._pending_agent_seed == "explain deadlocks in Go" + + +def test_one_shot_swaps_provider_to_moa(): + """After /moa, provider and model are set to MoA virtual provider so the + next agent turn runs through the MoA fan-out path.""" + cli = _make_cli() + with patch("cli._cprint"): + cli.process_command("/moa test prompt") + assert cli.provider == "moa" + assert cli.requested_provider == "moa" + assert cli.model == "default" # the default preset name + assert cli.api_key == "moa-virtual-provider" + assert cli.base_url == "moa://local" + assert cli.api_mode == "chat_completions" + + +def test_one_shot_sets_disable_after_turn_flag(): + """/moa sets _pending_moa_disable_after_turn so the chat path knows to + restore the prior model after this turn completes.""" + cli = _make_cli() + with patch("cli._cprint"): + cli.process_command("/moa run this once") + assert cli._pending_moa_disable_after_turn is True + + +def test_one_shot_snapshots_prior_model_for_restore(): + """The prior model identity is saved in _pending_moa_restore_model so it + can be restored after the MoA turn.""" + cli = _make_cli() + with patch("cli._cprint"): + cli.process_command("/moa check this") + restore = cli._pending_moa_restore_model + assert restore is not None + assert restore["provider"] == "openrouter" + assert restore["model"] == "anthropic/claude-opus-4.8" + assert restore["requested_provider"] == "openrouter" + assert restore["api_key"] == "test-key" + assert restore["base_url"] == "https://openrouter.ai/api/v1" + assert restore["api_mode"] == "chat_completions" + + +def test_one_shot_evicts_cached_agent(): + """The agent is set to None so a fresh one is built with the MoA provider.""" + cli = _make_cli(agent=MagicMock()) + assert cli.agent is not None + with patch("cli._cprint"): + cli.process_command("/moa fresh start") + assert cli.agent is None + + +# --------------------------------------------------------------------------- +# (2) Normal mode is unaffected +# --------------------------------------------------------------------------- + +def test_non_moa_command_leaves_moa_flags_untouched(): + """A regular slash command (not /moa) does not mutate any MoA state.""" + cli = _make_cli() + original_provider = cli.provider + original_model = cli.model + with patch("cli._cprint"): + # /help is always handled and returns True without MoA side-effects + cli.process_command("/help") + assert cli.provider == original_provider + assert cli.model == original_model + assert cli._pending_moa_disable_after_turn is False + assert cli._pending_moa_restore_model is None + assert cli._pending_agent_seed is None + + +def test_moa_state_isolated_between_one_shots(): + """Two consecutive /moa calls each set up their own restore snapshot; the + second doesn't see stale state from the first.""" + cli = _make_cli() + with patch("cli._cprint"): + cli.process_command("/moa first prompt") + # Simulate restore after first turn + restore_1 = cli._pending_moa_restore_model + for key, value in restore_1.items(): + if value is not None: + setattr(cli, key, value) + cli._pending_moa_disable_after_turn = False + cli._pending_moa_restore_model = None + cli._pending_agent_seed = None + + # Now fire a second one-shot + with patch("cli._cprint"): + cli.process_command("/moa second prompt") + assert cli._pending_agent_seed == "second prompt" + assert cli._pending_moa_disable_after_turn is True + restore_2 = cli._pending_moa_restore_model + # Restore should point back to the original model, not moa + assert restore_2["provider"] == "openrouter" + + +# --------------------------------------------------------------------------- +# (3) Invalid one-shot inputs +# --------------------------------------------------------------------------- + +def test_bare_moa_shows_usage_no_state_mutation(): + """/moa with no argument shows usage and does not switch provider.""" + cli = _make_cli() + printed = [] + with patch("cli._cprint", side_effect=printed.append): + result = cli.process_command("/moa") + assert result is True + assert cli.provider != "moa" + assert cli._pending_agent_seed is None + assert cli._pending_moa_disable_after_turn is False + assert any("Usage" in str(s) or "usage" in str(s) for s in printed) + + +def test_moa_whitespace_only_shows_usage(): + """/moa with only whitespace is equivalent to bare /moa.""" + cli = _make_cli() + printed = [] + with patch("cli._cprint", side_effect=printed.append): + result = cli.process_command("/moa ") + assert result is True + assert cli.provider != "moa" + assert cli._pending_agent_seed is None + + +def test_moa_while_agent_running_still_queues_one_shot(): + """/moa during an active agent run still sets up one-shot state. + + NOTE: unlike the gateway (which has an explicit running-session guard), + the CLI /moa handler does NOT reject during _agent_running — it sets up + the MoA swap and relies on the main loop to consume _pending_agent_seed + after the current turn finishes. This test documents the actual behavior. + """ + cli = _make_cli(_agent_running=True) + with patch("cli._cprint"): + result = cli.process_command("/moa explain this") + assert result is True + # CLI does NOT guard — it swaps the provider and queues the seed + assert cli.provider == "moa" + assert cli._pending_agent_seed == "explain this" + assert cli._pending_moa_disable_after_turn is True + + +# --------------------------------------------------------------------------- +# (4) No continuation state after one-shot +# --------------------------------------------------------------------------- + +def test_restore_clears_all_moa_continuation_state(): + """Simulates the post-turn restore path (cli.py ~line 12271-12278) and + verifies no MoA continuation state remains.""" + cli = _make_cli() + with patch("cli._cprint"): + cli.process_command("/moa check this") + + # Verify MoA is active before restore + assert cli.provider == "moa" + assert cli._pending_moa_disable_after_turn is True + assert cli._pending_moa_restore_model is not None + + # Simulate the restore path (what the chat() finally does after the turn) + restore = cli._pending_moa_restore_model or {} + for key, value in restore.items(): + if value is not None: + setattr(cli, key, value) + cli.agent = None + cli._pending_moa_restore_model = None + cli._pending_moa_disable_after_turn = False + + # All MoA state must be gone + assert cli.provider == "openrouter" + assert cli.model == "anthropic/claude-opus-4.8" + assert cli.api_key == "test-key" + assert cli.base_url == "https://openrouter.ai/api/v1" + assert cli._pending_moa_disable_after_turn is False + assert cli._pending_moa_restore_model is None + + +def test_one_shot_does_not_mutate_pending_input_queue(): + """/moa must not place anything in the regular _pending_input queue — it + uses _pending_agent_seed, which is consumed differently.""" + cli = _make_cli() + with patch("cli._cprint"): + cli.process_command("/moa test prompt") + assert cli._pending_input.empty() + + +def test_one_shot_does_not_set_pending_moa_config(): + """The one-shot path uses provider/model swap, NOT _pending_moa_config + (which is the per-turn MoA config injection path).""" + cli = _make_cli() + with patch("cli._cprint"): + cli.process_command("/moa test prompt") + assert cli._pending_moa_config is None diff --git a/tests/run_agent/test_steer.py b/tests/run_agent/test_steer.py index 99feb56343e72..387b7fae99d07 100644 --- a/tests/run_agent/test_steer.py +++ b/tests/run_agent/test_steer.py @@ -181,6 +181,77 @@ def worker(idx: int) -> None: assert set(lines) == {f"note-{i}" for i in range(N)} +class TestSteerCascadeToChildren: + """steer() should propagate to active child agents (subagent delegation).""" + + def test_steer_cascades_to_active_children(self): + parent = _bare_agent() + parent._active_children = [] + parent._active_children_lock = threading.Lock() + + child1 = _bare_agent() + child1._active_children = [] + child1._active_children_lock = threading.Lock() + child2 = _bare_agent() + child2._active_children = [] + child2._active_children_lock = threading.Lock() + + parent._active_children = [child1, child2] + parent.steer("change direction") + + assert parent._pending_steer == "change direction" + assert child1._pending_steer == "change direction" + assert child2._pending_steer == "change direction" + + def test_steer_cascades_recursively(self): + """steer should propagate through nested children (grandchildren).""" + grandparent = _bare_agent() + grandparent._active_children = [] + grandparent._active_children_lock = threading.Lock() + + parent = _bare_agent() + parent._active_children = [] + parent._active_children_lock = threading.Lock() + + child = _bare_agent() + child._active_children = [] + child._active_children_lock = threading.Lock() + + grandparent._active_children = [parent] + parent._active_children = [child] + + grandparent.steer("stop everything") + assert child._pending_steer == "stop everything" + + def test_steer_cascade_tolerates_broken_child(self): + """If a child's steer() raises, other children still get the steer.""" + parent = _bare_agent() + parent._active_children = [] + parent._active_children_lock = threading.Lock() + + class BrokenAgent: + def steer(self, text): + raise RuntimeError("boom") + + good_child = _bare_agent() + good_child._active_children = [] + good_child._active_children_lock = threading.Lock() + + parent._active_children = [BrokenAgent(), good_child] + parent.steer("keep going") + + assert parent._pending_steer == "keep going" + assert good_child._pending_steer == "keep going" + + def test_steer_no_cascade_without_children(self): + """steer on an agent with no active children just works normally.""" + agent = _bare_agent() + agent._active_children = [] + agent._active_children_lock = threading.Lock() + agent.steer("solo note") + assert agent._pending_steer == "solo note" + + class TestSteerClearedOnInterrupt: def test_clear_interrupt_drops_pending_steer(self): """A hard interrupt supersedes any pending steer — the agent's diff --git a/tests/tui_gateway/test_moa_one_shot_behavior.py b/tests/tui_gateway/test_moa_one_shot_behavior.py new file mode 100644 index 0000000000000..3cc0ea168c3e0 --- /dev/null +++ b/tests/tui_gateway/test_moa_one_shot_behavior.py @@ -0,0 +1,138 @@ +"""Tests for TUI gateway /moa one-shot restore and no-continuation-state. + +The TUI gateway /moa handler is a JSON-RPC @method("command.dispatch") handler +inside tui_gateway/server.py, not a free function. These tests exercise the +gateway-level restore logic (GatewayRunner._restore_moa_one_shot) and the +session-state invariants that the /moa one-shot path must uphold. + +For the full TUI slash handler coverage, see the existing +tests/tui_gateway/test_moa_reference_emit.py (MoA reference-model emit) and +tests/gateway/test_moa_one_shot_restore.py (restore helper unit tests). +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from gateway.run import GatewayRunner + + +# --------------------------------------------------------------------------- +# Gateway restore: one-shot completion clears all continuation state +# --------------------------------------------------------------------------- + +def _make_runner(): + runner = object.__new__(GatewayRunner) + runner._session_model_overrides = {} + runner._evict_cached_agent = MagicMock() + return runner + + +def _make_event(*, moa_disable=False, moa_restore=None): + event = SimpleNamespace() + if moa_disable: + event._moa_disable_after_turn = True + event._moa_restore_override = moa_restore + return event + + +def test_one_shot_restore_clears_moa_provider(): + """After a one-shot turn, the MoA virtual provider must be replaced with + the user's prior model — no continuation into the next turn.""" + runner = _make_runner() + key = "agent:main:telegram:dm:42" + runner._session_model_overrides[key] = {"provider": "moa", "model": "default"} + event = _make_event( + moa_disable=True, + moa_restore={"provider": "openrouter", "model": "claude-opus-4.8"}, + ) + + runner._restore_moa_one_shot(event, key) + + assert runner._session_model_overrides[key]["provider"] == "openrouter" + assert runner._session_model_overrides[key]["model"] == "claude-opus-4.8" + runner._evict_cached_agent.assert_called_once_with(key) + + +def test_one_shot_restore_none_removes_override_entirely(): + """If the user had no model override before /moa, the override entry + is removed entirely — not left with stale MoA.""" + runner = _make_runner() + key = "agent:main:discord:guild:456" + runner._session_model_overrides[key] = {"provider": "moa", "model": "default"} + event = _make_event(moa_disable=True, moa_restore=None) + + runner._restore_moa_one_shot(event, key) + + assert key not in runner._session_model_overrides + runner._evict_cached_agent.assert_called_once_with(key) + + +def test_normal_turn_does_not_touch_overrides(): + """A non-MoA turn must not alter model overrides or evict agents.""" + runner = _make_runner() + key = "agent:main:slack:channel:789" + original = {"provider": "openrouter", "model": "gpt-4"} + runner._session_model_overrides[key] = original.copy() + event = _make_event() # no moa_disable + + runner._restore_moa_one_shot(event, key) + + assert runner._session_model_overrides[key] == original + runner._evict_cached_agent.assert_not_called() + + +def test_restore_fires_from_finally_even_on_exception(): + """The restore helper is called from a finally block, so it must succeed + even when the turn raised an exception.""" + import pytest + + runner = _make_runner() + key = "agent:main:telegram:dm:999" + runner._session_model_overrides[key] = {"provider": "moa", "model": "default"} + event = _make_event( + moa_disable=True, + moa_restore={"provider": "openrouter", "model": "gpt-4"}, + ) + + with pytest.raises(RuntimeError): + try: + raise RuntimeError("provider error mid-turn") + finally: + runner._restore_moa_one_shot(event, key) + + # Restore still happened despite the exception + assert runner._session_model_overrides[key] == { + "provider": "openrouter", + "model": "gpt-4", + } + + +# --------------------------------------------------------------------------- +# Session-level: moa_one_shot_restore dict lifecycle +# --------------------------------------------------------------------------- + +def test_session_restore_dict_shape(): + """The moa_one_shot_restore dict stored in the session must contain + the fields the restore handler reads: override, model, provider.""" + # This is the shape set by the TUI /moa handler (server.py ~line 11585) + restore = { + "override": {"provider": "openrouter", "model": "gpt-4"}, + "model": "gpt-4", + "provider": "openrouter", + } + # All three fields must be present for the restore path to work correctly + assert "override" in restore + assert "model" in restore + assert "provider" in restore + + +def test_session_with_no_prior_override_stores_none(): + """When the user had no model_override before /moa, the restore dict + stores override=None so the restore path pops model_override entirely.""" + restore = { + "override": None, # no prior override + "model": "anthropic/claude-opus-4.8", + "provider": "openrouter", + } + assert restore["override"] is None + # The restore handler checks this and does session.pop("model_override")