Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 0 additions & 127 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10172,42 +10172,6 @@ def is_alive(self):
server._sessions.pop("sid", None)


def test_clear_pending_without_sid_clears_all():
"""_clear_pending(None) is the shutdown path — must still release
every pending prompt regardless of owning session."""
ev1, ev2, ev3 = threading.Event(), threading.Event(), threading.Event()
server._pending["a"] = ("sid_x", ev1)
server._pending["b"] = ("sid_y", ev2)
server._pending["c"] = ("sid_z", ev3)
try:
server._clear_pending(None)
assert ev1.is_set() and ev2.is_set() and ev3.is_set()
finally:
for key in ("a", "b", "c"):
server._pending.pop(key, None)
server._answers.pop(key, None)


def test_respond_unpacks_sid_tuple_correctly():
"""After the (sid, Event) tuple change, _respond must still work."""
ev = threading.Event()
server._pending["rid-x"] = ("sid_x", ev)
try:
resp = server.handle_request(
{
"id": "1",
"method": "clarify.respond",
"params": {"request_id": "rid-x", "answer": "the answer"},
}
)
assert resp.get("result")
assert ev.is_set()
assert server._answers.get("rid-x") == "the answer"
finally:
server._pending.pop("rid-x", None)
server._answers.pop("rid-x", None)


# ---------------------------------------------------------------------------
# /model switch and other agent-mutating commands must reject while the
# session is running. agent.switch_model() mutates self.model, self.provider,
Expand All @@ -10219,97 +10183,6 @@ def test_respond_unpacks_sid_tuple_correctly():
# ---------------------------------------------------------------------------


def test_config_set_model_defers_while_running(monkeypatch):
"""/model via config.set queues the pick during an in-flight turn instead
of rejecting or racing the worker thread."""
seen = {"called": False}

def _fake_apply(sid, session, raw, **_kwargs):
seen["called"] = True
return {"value": raw, "warning": ""}

monkeypatch.setattr(server, "_apply_model_switch", _fake_apply)

server._sessions["sid"] = _session(running=True)
try:
resp = server.handle_request(
{
"id": "1",
"method": "config.set",
"params": {
"session_id": "sid",
"key": "model",
"value": "anthropic/claude-sonnet-4.6",
},
}
)
assert not resp.get("error")
result = resp["result"]
assert result["deferred"] is True
assert result["value"] == "anthropic/claude-sonnet-4.6"
assert not seen["called"], (
"_apply_model_switch ran mid-turn — would race the worker thread "
"reading agent.model / agent.client; it must defer to turn start"
)
pending = server._sessions["sid"].get("pending_model_switch")
assert pending and pending["raw"] == "anthropic/claude-sonnet-4.6"
finally:
server._sessions.pop("sid", None)


def test_apply_pending_model_switch_runs_queued_pick(monkeypatch):
"""The queued pick is consumed once, on the turn thread, via
_apply_model_switch — and cleared so it can't re-fire next turn."""
calls = []

def _fake_apply(sid, session, raw, **kwargs):
calls.append(raw)
return {"value": raw, "warning": "", "confirm_required": False}

monkeypatch.setattr(server, "_apply_model_switch", _fake_apply)

session = _session(running=False)
session["agent"] = object()
session["pending_model_switch"] = {
"raw": "anthropic/claude-sonnet-4.6",
"confirm_expensive_model": False,
}

server._apply_pending_model_switch("sid", session)
assert calls == ["anthropic/claude-sonnet-4.6"]
assert "pending_model_switch" not in session

# Idempotent: a second turn start with nothing queued is a no-op.
server._apply_pending_model_switch("sid", session)
assert calls == ["anthropic/claude-sonnet-4.6"]


def test_config_set_model_allowed_when_idle(monkeypatch):
"""Regression guard: idle sessions can still switch models."""
seen = {"called": False}

def _fake_apply(sid, session, raw, **_kwargs):
seen["called"] = True
return {"value": "newmodel", "warning": ""}

monkeypatch.setattr(server, "_apply_model_switch", _fake_apply)

server._sessions["sid"] = _session(running=False)
try:
resp = server.handle_request(
{
"id": "1",
"method": "config.set",
"params": {"session_id": "sid", "key": "model", "value": "newmodel"},
}
)
assert resp.get("result")
assert resp["result"]["value"] == "newmodel"
assert seen["called"]
finally:
server._sessions.pop("sid", None)


def test_mirror_slash_side_effects_rejects_mutating_commands_while_running(monkeypatch):
"""Slash worker passthrough (e.g. /model, /personality, /prompt,
/compress) must reject during an in-flight turn. Same race as
Expand Down
135 changes: 135 additions & 0 deletions tests/tui_gateway/test_model_switch_deferral.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import threading
import types

import pytest

from tui_gateway import server


@pytest.fixture(autouse=True)
def _neuter_agent_prewarm_timer(request, monkeypatch):
"""Stub the deferred agent pre-warm timer for every test in this module.

``session.create`` and non-eager ``session.resume`` fire a 50 ms
background ``threading.Timer`` (``_schedule_agent_build``) that calls
whatever ``server._make_agent`` is patched in AT FIRE TIME. Left live,
a timer armed by one test outlives it and lands in the NEXT test's
``_make_agent`` mock, racily corrupting its captured state (the
``'tip' == 'cont_tip'`` flakes in the session_resume tests). Tests that
exercise the deferred build itself opt back in with
``@pytest.mark.real_agent_prewarm``.
"""
if request.node.get_closest_marker("real_agent_prewarm"):
yield
return
monkeypatch.setattr(server, "_schedule_agent_build", lambda *a, **k: None)
yield


def _session(agent=None, **extra):
return {
"agent": agent if agent is not None else 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 test_config_set_model_defers_while_running(monkeypatch):
"""/model via config.set queues the pick during an in-flight turn instead
of rejecting or racing the worker thread."""
seen = {"called": False}

def _fake_apply(sid, session, raw, **_kwargs):
seen["called"] = True
return {"value": raw, "warning": ""}

monkeypatch.setattr(server, "_apply_model_switch", _fake_apply)

server._sessions["sid"] = _session(running=True)
try:
resp = server.handle_request(
{
"id": "1",
"method": "config.set",
"params": {
"session_id": "sid",
"key": "model",
"value": "anthropic/claude-sonnet-4.6",
},
}
)
assert not resp.get("error")
result = resp["result"]
assert result["deferred"] is True
assert result["value"] == "anthropic/claude-sonnet-4.6"
assert not seen["called"], (
"_apply_model_switch ran mid-turn — would race the worker thread "
"reading agent.model / agent.client; it must defer to turn start"
)
pending = server._sessions["sid"].get("pending_model_switch")
assert pending and pending["raw"] == "anthropic/claude-sonnet-4.6"
finally:
server._sessions.pop("sid", None)


def test_apply_pending_model_switch_runs_queued_pick(monkeypatch):
"""The queued pick is consumed once, on the turn thread, via
_apply_model_switch — and cleared so it can't re-fire next turn."""
calls = []

def _fake_apply(sid, session, raw, **kwargs):
calls.append(raw)
return {"value": raw, "warning": "", "confirm_required": False}

monkeypatch.setattr(server, "_apply_model_switch", _fake_apply)

session = _session(running=False)
session["agent"] = object()
session["pending_model_switch"] = {
"raw": "anthropic/claude-sonnet-4.6",
"confirm_expensive_model": False,
}

server._apply_pending_model_switch("sid", session)
assert calls == ["anthropic/claude-sonnet-4.6"]
assert "pending_model_switch" not in session

# Idempotent: a second turn start with nothing queued is a no-op.
server._apply_pending_model_switch("sid", session)
assert calls == ["anthropic/claude-sonnet-4.6"]


def test_config_set_model_allowed_when_idle(monkeypatch):
"""Regression guard: idle sessions can still switch models."""
seen = {"called": False}

def _fake_apply(sid, session, raw, **_kwargs):
seen["called"] = True
return {"value": "newmodel", "warning": ""}

monkeypatch.setattr(server, "_apply_model_switch", _fake_apply)

server._sessions["sid"] = _session(running=False)
try:
resp = server.handle_request(
{
"id": "1",
"method": "config.set",
"params": {"session_id": "sid", "key": "model", "value": "newmodel"},
}
)
assert resp.get("result")
assert resp["result"]["value"] == "newmodel"
assert seen["called"]
finally:
server._sessions.pop("sid", None)
61 changes: 61 additions & 0 deletions tests/tui_gateway/test_pending_respond.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import threading

import pytest

from tui_gateway import server


@pytest.fixture(autouse=True)
def _neuter_agent_prewarm_timer(request, monkeypatch):
"""Stub the deferred agent pre-warm timer for every test in this module.

``session.create`` and non-eager ``session.resume`` fire a 50 ms
background ``threading.Timer`` (``_schedule_agent_build``) that calls
whatever ``server._make_agent`` is patched in AT FIRE TIME. Left live,
a timer armed by one test outlives it and lands in the NEXT test's
``_make_agent`` mock, racily corrupting its captured state (the
``'tip' == 'cont_tip'`` flakes in the session_resume tests). Tests that
exercise the deferred build itself opt back in with
``@pytest.mark.real_agent_prewarm``.
"""
if request.node.get_closest_marker("real_agent_prewarm"):
yield
return
monkeypatch.setattr(server, "_schedule_agent_build", lambda *a, **k: None)
yield


def test_clear_pending_without_sid_clears_all():
"""_clear_pending(None) is the shutdown path — must still release
every pending prompt regardless of owning session."""
ev1, ev2, ev3 = threading.Event(), threading.Event(), threading.Event()
server._pending["a"] = ("sid_x", ev1)
server._pending["b"] = ("sid_y", ev2)
server._pending["c"] = ("sid_z", ev3)
try:
server._clear_pending(None)
assert ev1.is_set() and ev2.is_set() and ev3.is_set()
finally:
for key in ("a", "b", "c"):
server._pending.pop(key, None)
server._answers.pop(key, None)


def test_respond_unpacks_sid_tuple_correctly():
"""After the (sid, Event) tuple change, _respond must still work."""
ev = threading.Event()
server._pending["rid-x"] = ("sid_x", ev)
try:
resp = server.handle_request(
{
"id": "1",
"method": "clarify.respond",
"params": {"request_id": "rid-x", "answer": "the answer"},
}
)
assert resp.get("result")
assert ev.is_set()
assert server._answers.get("rid-x") == "the answer"
finally:
server._pending.pop("rid-x", None)
server._answers.pop("rid-x", None)
Loading