Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"""A model picked mid-turn must still get its selection-guard confirm step.
|
||||
|
||||
``config.set model`` on a *running* session cannot swap the agent in place --
|
||||
the worker thread is reading ``agent.model`` / ``agent.client`` on every
|
||||
iteration -- so it stashes the pick in ``session["pending_model_switch"]`` and
|
||||
``_apply_pending_model_switch`` applies it at the next turn start.
|
||||
|
||||
That deferral used to skip the selection guards entirely: the stash branch
|
||||
answered ``confirm_required: False`` without ever calling them. A client that
|
||||
implements the confirm round-trip was therefore told no consent was needed, so
|
||||
it never prompted. One turn later ``_apply_pending_model_switch`` ran the
|
||||
guards with the stashed (unconfirmed) flag, saw the warning, and deliberately
|
||||
dropped the switch -- correct on its own terms, but by then no round-trip was
|
||||
possible. The user's pick silently reverted and the confirm was never offered
|
||||
on this path at all.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from tui_gateway import server
|
||||
|
||||
|
||||
# A vendor-documented data-training tier. The data-policy guard keys on the
|
||||
# model id alone (no base_url / api_key / model_info), which is exactly what
|
||||
# the stash branch can see before resolution.
|
||||
GUARDED_MODEL = "muse-spark-1.2-contributor"
|
||||
UNGUARDED_MODEL = "anthropic/claude-sonnet-4.6"
|
||||
|
||||
|
||||
def _session(**extra):
|
||||
return {
|
||||
"agent": types.SimpleNamespace(),
|
||||
"session_key": "session-key",
|
||||
"history": [],
|
||||
"history_lock": threading.Lock(),
|
||||
"history_version": 0,
|
||||
"running": False,
|
||||
"attached_images": [],
|
||||
"image_counter": 0,
|
||||
"cols": 80,
|
||||
"slash_worker": None,
|
||||
"show_reasoning": False,
|
||||
"tool_progress_mode": "all",
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def _config_set_model(value, **extra_params):
|
||||
params = {"session_id": "sid", "key": "model", "value": value}
|
||||
params.update(extra_params)
|
||||
|
||||
return server.handle_request({"id": "1", "method": "config.set", "params": params})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def running_session(monkeypatch):
|
||||
"""A busy session whose live swap path is fatal if it is ever reached."""
|
||||
|
||||
def _must_not_run(*_args, **_kwargs):
|
||||
raise AssertionError(
|
||||
"_apply_model_switch ran on the busy path -- it would race the "
|
||||
"worker thread reading agent.model / agent.client"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(server, "_apply_model_switch", _must_not_run)
|
||||
server._sessions["sid"] = _session(running=True)
|
||||
try:
|
||||
yield server._sessions["sid"]
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
class TestGuardedPickAsksBeforeStashing:
|
||||
def test_reports_confirm_required_instead_of_deferring(self, running_session):
|
||||
resp = _config_set_model(GUARDED_MODEL)
|
||||
|
||||
assert not resp.get("error")
|
||||
result = resp["result"]
|
||||
assert result["confirm_required"] is True, (
|
||||
"the deferred path answered confirm_required=False without running "
|
||||
"the guards, so a correct client never prompts and the pick is "
|
||||
"dropped a turn later with no way to consent"
|
||||
)
|
||||
assert result["confirm_message"].strip()
|
||||
assert result["deferred"] is False
|
||||
|
||||
def test_leaves_the_session_untouched(self, running_session):
|
||||
_config_set_model(GUARDED_MODEL)
|
||||
|
||||
assert "pending_model_switch" not in running_session, (
|
||||
"an unconfirmed guarded pick must not be queued -- the next turn "
|
||||
"start would drop it anyway, after the pill already moved"
|
||||
)
|
||||
|
||||
def test_confirm_message_names_the_guard(self, running_session):
|
||||
message = _config_set_model(GUARDED_MODEL)["result"]["confirm_message"]
|
||||
|
||||
assert "CONTRIBUTOR TIER" in message
|
||||
assert "train" in message.lower()
|
||||
|
||||
def test_reconfirming_queues_the_pick(self, running_session):
|
||||
resp = _config_set_model(GUARDED_MODEL, confirm_expensive_model=True)
|
||||
|
||||
result = resp["result"]
|
||||
assert result["deferred"] is True
|
||||
assert result["confirm_required"] is False
|
||||
|
||||
pending = running_session["pending_model_switch"]
|
||||
assert pending["raw"] == GUARDED_MODEL
|
||||
assert pending["confirm_expensive_model"] is True, (
|
||||
"the ack must survive into the stash or _apply_pending_model_switch "
|
||||
"re-runs the guard at turn start and drops the confirmed pick"
|
||||
)
|
||||
|
||||
|
||||
class TestUnguardedPickStillDefers:
|
||||
"""The queue-don't-race behaviour is the whole point of this branch."""
|
||||
|
||||
def test_defers_without_a_confirm_step(self, running_session):
|
||||
result = _config_set_model(UNGUARDED_MODEL)["result"]
|
||||
|
||||
assert result["deferred"] is True
|
||||
assert result["confirm_required"] is False
|
||||
assert result["confirm_message"] == ""
|
||||
assert result["value"] == UNGUARDED_MODEL
|
||||
|
||||
def test_stashes_the_pick_for_the_next_turn(self, running_session):
|
||||
_config_set_model(UNGUARDED_MODEL)
|
||||
|
||||
pending = running_session["pending_model_switch"]
|
||||
assert pending["raw"] == UNGUARDED_MODEL
|
||||
assert pending["confirm_expensive_model"] is False
|
||||
|
||||
def test_explicit_provider_is_still_recorded_for_display(self, running_session):
|
||||
_config_set_model(f"{UNGUARDED_MODEL} --provider anthropic")
|
||||
|
||||
pending = running_session["pending_model_switch"]
|
||||
assert pending["display_provider"] == "anthropic"
|
||||
|
||||
|
||||
class TestGuardFailureIsNotFatal:
|
||||
def test_a_raising_guard_falls_back_to_deferring(self, running_session, monkeypatch):
|
||||
"""A broken guard must never cost the user their model pick.
|
||||
|
||||
The apply-time check in ``_apply_pending_model_switch`` is still there,
|
||||
so failing open here degrades to the old behaviour rather than to a
|
||||
silently unguarded switch.
|
||||
"""
|
||||
|
||||
def _boom(*_args, **_kwargs):
|
||||
raise RuntimeError("guard table is broken")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_selection_guards.combined_selection_warning", _boom
|
||||
)
|
||||
|
||||
result = _config_set_model(GUARDED_MODEL)["result"]
|
||||
|
||||
assert result["deferred"] is True
|
||||
assert running_session["pending_model_switch"]["raw"] == GUARDED_MODEL
|
||||
|
||||
|
||||
class TestHelperContract:
|
||||
def test_returns_none_for_an_empty_model(self):
|
||||
assert server._pending_switch_selection_warning("", "") is None
|
||||
|
||||
def test_returns_none_when_no_guard_fires(self):
|
||||
assert server._pending_switch_selection_warning(UNGUARDED_MODEL, "") is None
|
||||
|
||||
def test_returns_the_message_when_a_guard_fires(self):
|
||||
message = server._pending_switch_selection_warning(GUARDED_MODEL, "")
|
||||
|
||||
assert message is not None
|
||||
assert "CONTRIBUTOR TIER" in message
|
||||
|
||||
def test_an_explicit_provider_reaches_the_guards(self, monkeypatch):
|
||||
"""Provider-keyed guards are useless if the provider is dropped here.
|
||||
|
||||
The docstring promises the early call can only under-fire relative to
|
||||
the resolved one, and that only holds if what the caller DID say is
|
||||
forwarded. Asserting on a guarded model id would pass even with
|
||||
``provider`` dropped, so record the kwargs instead.
|
||||
"""
|
||||
seen = {}
|
||||
|
||||
def _fake(model, provider=None, **kwargs):
|
||||
seen["model"] = model
|
||||
seen["provider"] = provider
|
||||
return None
|
||||
|
||||
import hermes_cli.model_selection_guards as guards
|
||||
|
||||
monkeypatch.setattr(guards, "combined_selection_warning", _fake)
|
||||
server._pending_switch_selection_warning(UNGUARDED_MODEL, "openrouter")
|
||||
|
||||
assert seen == {"model": UNGUARDED_MODEL, "provider": "openrouter"}
|
||||
|
||||
def test_an_empty_provider_is_normalised_to_none(self, monkeypatch):
|
||||
"""`provider or None` is load-bearing: "" is not "no provider" to a
|
||||
guard that does an `is None` check, and the TUI sends "" for unset."""
|
||||
seen = {}
|
||||
|
||||
def _fake(model, provider=None, **kwargs):
|
||||
seen["provider"] = provider
|
||||
return None
|
||||
|
||||
import hermes_cli.model_selection_guards as guards
|
||||
|
||||
monkeypatch.setattr(guards, "combined_selection_warning", _fake)
|
||||
server._pending_switch_selection_warning(UNGUARDED_MODEL, "")
|
||||
|
||||
assert seen == {"provider": None}
|
||||
Reference in New Issue
Block a user