diff --git a/tests/tui_gateway/test_slash_worker_provider.py b/tests/tui_gateway/test_slash_worker_provider.py new file mode 100644 index 0000000000..8b1409f3f6 --- /dev/null +++ b/tests/tui_gateway/test_slash_worker_provider.py @@ -0,0 +1,112 @@ +"""The slash worker inherits the parent agent's provider, not the config default (#57283). + +A Desktop/TUI MoA session pins the live agent to the virtual ``moa`` provider, but the +persistent slash-worker subprocess was spawned with only ``--model``. HermesCLI re-resolves +the provider from config.yaml, so a ``/moa`` one-shot dispatched the MoA PRESET NAME to the +configured real provider (openrouter 402 / "No available channel for model default"). The +worker argv must carry ``--provider`` and the child must hand it to ``HermesCLI``. +""" + +from types import SimpleNamespace + +import tui_gateway.server as server + + +class _Popen: + """Capture the spawn argv without a real child.""" + + def __init__(self, argv, **kw): + self.argv = list(argv) + self.stdout = self.stderr = iter(()) + self.stdin = None + + def poll(self): + return 0 + + +def _spawn(monkeypatch, **worker_kwargs) -> list[str]: + captured = {} + + def _popen(argv, **kw): + captured["argv"] = list(argv) + return _Popen(argv, **kw) + + monkeypatch.setattr(server.subprocess, "Popen", _popen) + server._SlashWorker("sess", "default", **worker_kwargs) + return captured["argv"] + + +def test_slash_worker_argv_carries_provider(monkeypatch): + argv = _spawn(monkeypatch, provider="moa") + + assert "--provider" in argv + assert argv[argv.index("--provider") + 1] == "moa" + # model still forwarded + assert argv[argv.index("--model") + 1] == "default" + + +def test_slash_worker_argv_omits_empty_provider(monkeypatch): + argv = _spawn(monkeypatch, provider=None) + + assert "--provider" not in argv + + +def test_slash_exec_spawns_with_the_live_agent_provider(monkeypatch): + """First-use spawn in ``slash.exec`` passes the session agent's resolved provider — the MoA + one-shot builds ``agent.provider == "moa"`` via ``_apply_model_switch`` before the worker runs.""" + worker_args = None + + class _RecordingWorker: + def __init__(self, session_key, model, profile_home=None, provider=None): + nonlocal worker_args + worker_args = (session_key, model, profile_home, provider) + self.stderr_tail = [] + + def run(self, command): + return "ok" + + def close(self): + pass + + agent = SimpleNamespace(model="default", provider="moa") + session = {"session_key": "s-key", "agent": agent, "profile_home": None, "running": False} + + monkeypatch.setattr(server, "_sessions", {"sid": session}) + monkeypatch.setattr(server, "_SlashWorker", _RecordingWorker) + monkeypatch.setattr(server, "_sess_nowait", lambda params, rid: (session, None)) + monkeypatch.setattr(server, "_live_slash_command_output", lambda *a, **k: None) + monkeypatch.setattr(server, "_is_snapshot_restore", lambda *a, **k: False) + monkeypatch.setattr(server, "_mirror_slash_side_effects", lambda *a, **k: None) + monkeypatch.setattr(server, "_resolve_model", lambda: "fallback-model") + + resp = server._methods["slash.exec"](1, {"session_id": "sid", "command": "/tools"}) + + assert "error" not in resp, resp + assert worker_args is not None + assert worker_args[3] == "moa" + + +def test_slash_worker_child_forwards_provider_to_hermes_cli(monkeypatch): + """The child's argparse accepts --provider and hands it to HermesCLI (mocked): the MoA + virtual provider must reach ``HermesCLI(provider=...)`` instead of config re-resolution.""" + import sys as _sys + + import tui_gateway.slash_worker as sw + + built = {} + + class _FakeCLI: + def __init__(self, **kwargs): + built.update(kwargs) + + monkeypatch.setattr(sw, "HermesCLI", _FakeCLI) + monkeypatch.setattr(sw, "_start_parent_death_watchdog", lambda *a: None) + monkeypatch.setattr(sw, "_prepare_slash_worker_runtime", lambda: None) + monkeypatch.setattr(sw.sys, "stdin", type("S", (), {"readline": lambda self: ""})()) + monkeypatch.setattr(_sys, "argv", ["slash_worker", "--session-key", "s", "--model", "m", + "--provider", "moa"]) + + sw.main() + + assert built.get("provider") == "moa" + assert built.get("model") == "m" diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 966160f62b..6995494d30 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -981,7 +981,8 @@ def _(rid, params: dict) -> dict: try: worker = _SlashWorker( session["session_key"], getattr(session.get("agent"), "model", _resolve_model()), - profile_home=session.get("profile_home")) + profile_home=session.get("profile_home"), + provider=getattr(session.get("agent"), "provider", None) or None) _attach_worker(sid, session, worker) except Exception as e: return _err(rid, 5030, f"slash worker start failed: {e}") diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e5f5b62b8b..23ddda9029 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -232,12 +232,18 @@ def _prepend_tool_paths(env: dict[str, str]) -> dict[str, str]: class _SlashWorker: """Persistent HermesCLI subprocess for slash commands.""" - def __init__(self, session_key: str, model: str, profile_home: str | None = None): + def __init__(self, session_key: str, model: str, profile_home: str | None = None, + provider: str | None = None): self._lock = threading.Lock() self._seq = 0 self.stderr_tail: list[str] = [] self.stdout_queue: queue.Queue[dict | None] = queue.Queue() - argv = [sys.executable, "-m", "tui_gateway.slash_worker", "--session-key", session_key] + (["--model", model] if model else []) + # ``--provider`` pins the child to the parent agent's virtual provider: without it the + # worker re-resolves provider from config, so a MoA session (provider=moa, model=) + # dispatched its preset NAME to the configured real provider and 402/503'd (#57283). + argv = [sys.executable, "-m", "tui_gateway.slash_worker", "--session-key", session_key] \ + + (["--model", model] if model else []) \ + + (["--provider", provider] if provider else []) self._closed = False from hermes_cli._subprocess_compat import windows_hide_flags # slash_worker runs the Hermes agent → needs provider credentials. Tier-1 secrets @@ -2013,7 +2019,8 @@ def _restart_slash_worker(sid: str, session: dict): worker.close() try: new_worker = _SlashWorker(session["session_key"], getattr(session.get("agent"), "model", _resolve_model()), - profile_home=session.get("profile_home")) + profile_home=session.get("profile_home"), + provider=getattr(session.get("agent"), "provider", None) or None) except Exception: session["slash_worker"] = None return diff --git a/tui_gateway/slash_worker.py b/tui_gateway/slash_worker.py index a478adec12..03790abbbd 100644 --- a/tui_gateway/slash_worker.py +++ b/tui_gateway/slash_worker.py @@ -100,6 +100,7 @@ def main(): p = argparse.ArgumentParser(add_help=False) p.add_argument("--session-key", required=True) p.add_argument("--model", default="") + p.add_argument("--provider", default="") args = p.parse_args() os.environ["HERMES_SESSION_KEY"] = args.session_key os.environ["HERMES_INTERACTIVE"] = "1" @@ -108,7 +109,11 @@ def main(): _start_parent_death_watchdog(os.getppid()) _prepare_slash_worker_runtime() with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): - cli = HermesCLI(model=args.model or None, compact=True, resume=args.session_key, verbose=False) + # --provider pins the CLI to the parent agent's resolved provider (a MoA session's virtual + # "moa" provider included). Without it HermesCLI re-resolves from config and dispatches the + # MoA preset NAME to the configured real provider (#57283). + cli = HermesCLI(model=args.model or None, provider=args.provider or None, + compact=True, resume=args.session_key, verbose=False) # Spurious stdin-EOF recovery (same shared-file-description O_NONBLOCK issue as the gateway entry # point — any child inheriting fd 0 can flip the flag). _sw_recovery_times: list[float] = []