Files
aiturk-hermes-ide/tests/tui_gateway/test_compute_host_late_compress_ack.py
T

236 lines
9.2 KiB
Python

"""Regression tests for #97948 symptom A (salvaged from #99630).
A manual /compress on a compute-host session used to block its RPC waiter for
a hard-coded 120s, return a 5019 timeout error, and then DROP the host's late
``control.ack`` — so the rotated session_key / history_version / session_info
never reached the gateway session and the desktop never refreshed.
"""
import queue
import sys
import threading
import time
import types
import pytest
from tui_gateway import server
from tui_gateway.host_supervisor import HostSupervisor
def _supervisor() -> tuple[HostSupervisor, list]:
sup = HostSupervisor(argv=[sys.executable, "-c", ""], autostart=False)
sent: list = []
sup._send_frame = lambda frame: sent.append(frame)
sup.start = lambda: None # never spawn a child
return sup, sent
def _session(**extra) -> dict:
return {
"agent": types.SimpleNamespace(),
"session_key": "old-session-key",
"history": [],
"history_lock": threading.Lock(),
"history_version": 3,
"running": False,
"attached_images": [],
"image_counter": 0,
"cols": 80,
"slash_worker": None,
"show_reasoning": False,
"tool_progress_mode": "all",
"_compute_host_active": True,
**extra,
}
# ── HostSupervisor: late-ack registration ───────────────────────────────────
def test_control_timeout_registers_one_shot_late_ack_handler():
sup, sent = _supervisor()
fired: list = []
with pytest.raises(queue.Empty):
sup.control("sid", route_name="session.compress", payload={"command": "/compress"},
wait=True, timeout=0.05, on_late_ack=fired.append)
request_id = sent[0]["request_id"]
assert request_id not in sup._pending_controls
assert request_id in sup._late_control_handlers
late = {"type": "control.ack", "request_id": request_id, "result": {"status": "compressed"}}
sup._handle_host_frame(late)
assert fired == [late]
# One-shot: a duplicate ack for the same request is ignored.
sup._handle_host_frame(late)
assert fired == [late]
assert request_id not in sup._late_control_handlers
def test_control_timeout_without_handler_still_drops_late_ack():
sup, sent = _supervisor()
with pytest.raises(queue.Empty):
sup.control("sid", route_name="session.compress", wait=True, timeout=0.05)
assert sup._late_control_handlers == {}
sup._handle_host_frame({"type": "control.ack", "request_id": sent[0]["request_id"]})
def test_late_control_error_and_bare_error_frames_fire_handler():
sup, sent = _supervisor()
fired: list = []
for _ in range(2):
with pytest.raises(queue.Empty):
sup.control("sid", route_name="session.compress", wait=True, timeout=0.01,
on_late_ack=fired.append)
rid_a, rid_b = sent[0]["request_id"], sent[1]["request_id"]
sup._handle_host_frame({"type": "control.error", "request_id": rid_a, "message": "boom"})
sup._handle_host_frame({"type": "error", "request_id": rid_b, "message": "bad frame"})
assert [f["request_id"] for f in fired] == [rid_a, rid_b]
def test_late_ack_handlers_are_bounded_by_ttl_and_cap(monkeypatch):
from tui_gateway import host_supervisor as hs
monkeypatch.setattr(hs, "_LATE_CONTROL_MAX", 3)
sup, _sent = _supervisor()
for i in range(5):
sup._register_late_control_handler(f"r{i}", lambda _f: None)
assert len(sup._late_control_handlers) == 3
assert set(sup._late_control_handlers) == {"r2", "r3", "r4"}
# TTL: an old registration is dropped on the next registration.
monkeypatch.setattr(hs, "_LATE_CONTROL_TTL_SECS", 0.0)
time.sleep(0.01)
sup._register_late_control_handler("fresh", lambda _f: None)
assert set(sup._late_control_handlers) == {"fresh"}
def test_host_crash_fails_outstanding_late_ack_handlers():
sup, sent = _supervisor()
fired: list = []
with pytest.raises(queue.Empty):
sup.control("sid", route_name="session.compress", wait=True, timeout=0.01,
on_late_ack=fired.append)
sup._fail_pending_turns(reason="crash", message="compute host exited with code 1")
assert len(fired) == 1
assert fired[0]["type"] == "control.error"
assert fired[0]["request_id"] == sent[0]["request_id"]
assert sup._late_control_handlers == {}
# ── session.compress RPC: pending answer + late adoption ────────────────────
@pytest.fixture
def compute_host_gateway(monkeypatch):
sup, sent = _supervisor()
emitted: list = []
monkeypatch.setattr(server, "_compute_host_supervisor", sup)
monkeypatch.setattr(server, "_emit", lambda event, sid, payload=None: emitted.append((event, sid, payload)))
monkeypatch.setattr(server, "_session_uses_compute_host", lambda _s, cfg=None: True)
monkeypatch.setattr(server, "_compute_host_compress_wait_seconds", lambda cfg=None: 0.05)
monkeypatch.setattr(server, "_session_info", lambda _agent, _session=None: {"model": "mirrored"})
session = _session()
server._sessions["sid"] = session
try:
yield sup, sent, emitted, session
finally:
server._sessions.pop("sid", None)
def _late_ack(request_id: str) -> dict:
return {
"type": "control.ack",
"sid": "sid",
"request_id": request_id,
"route_name": "session.compress",
"result": {"status": "compressed", "removed": 12, "summary": {"headline": "Compressed 14 → 2"}},
"session_key": "rotated-session-key",
"history_version": 9,
"message_count": 2,
"session_info": {"model": "host-model", "usage": {"total": 111}},
}
def test_session_compress_reports_pending_and_adopts_late_ack(compute_host_gateway):
sup, sent, emitted, session = compute_host_gateway
resp = server.handle_request({"id": "1", "method": "session.compress", "params": {"session_id": "sid"}})
assert "error" not in resp, resp
assert resp["result"]["status"] == "pending"
assert resp["result"]["turn_isolation"] is True
assert "background" in resp["result"]["message"]
assert sent[0]["route_name"] == "session.compress"
# Nothing adopted yet, the host is still working.
assert session["session_key"] == "old-session-key"
assert emitted == []
sup._handle_host_frame(_late_ack(sent[0]["request_id"]))
assert session["session_key"] == "rotated-session-key"
assert session["history_version"] == 9
assert session["_metadata_message_count"] == 2
assert session["_metadata_mirror"]["model"] == "host-model"
events = [(event, payload) for event, _sid, payload in emitted]
assert ("session.info", {"model": "mirrored"}) in events
assert ("status.update", {"kind": "compacted", "text": "✓ Context compression complete"}) in events
def test_session_compress_late_control_error_surfaces_as_error_event(compute_host_gateway):
sup, sent, emitted, session = compute_host_gateway
resp = server.handle_request({"id": "1", "method": "session.compress", "params": {"session_id": "sid"}})
assert resp["result"]["status"] == "pending"
sup._handle_host_frame({"type": "control.error", "request_id": sent[0]["request_id"], "message": "provider down"})
assert session["session_key"] == "old-session-key"
assert ("error", "sid", {"message": "compression failed: provider down"}) in emitted
def test_session_compress_late_ack_ignored_after_session_closed(compute_host_gateway):
sup, sent, emitted, session = compute_host_gateway
server.handle_request({"id": "1", "method": "session.compress", "params": {"session_id": "sid"}})
server._sessions.pop("sid")
sup._handle_host_frame(_late_ack(sent[0]["request_id"]))
assert session["session_key"] == "old-session-key"
assert emitted == []
def test_slash_compress_route_reports_pending_and_adopts_late_ack(compute_host_gateway):
sup, sent, emitted, session = compute_host_gateway
resp = server.handle_request(
{"id": "1", "method": "slash.exec", "params": {"session_id": "sid", "command": "/compress"}}
)
assert "error" not in resp, resp
assert "compression still running in the background" in resp["result"]["output"]
assert sent[0]["route_name"] == "slash.compress"
sup._handle_host_frame({**_late_ack(sent[0]["request_id"]), "route_name": "slash.compress"})
assert session["session_key"] == "rotated-session-key"
assert any(event == "session.info" for event, _sid, _p in emitted)
# ── wait budget follows compression.context_total_ceiling_seconds ───────────
def test_compress_wait_budget_follows_config_ceiling():
assert server._compute_host_compress_wait_seconds({"compression": {}}) == 630.0
assert server._compute_host_compress_wait_seconds(
{"compression": {"context_total_ceiling_seconds": 200}}
) == 230.0
# Never below the historical 120s floor, never above the RPC-safe cap.
assert server._compute_host_compress_wait_seconds(
{"compression": {"context_total_ceiling_seconds": 10, "context_timeout_seconds": 0}}
) == 120.0
assert server._compute_host_compress_wait_seconds(
{"compression": {"context_total_ceiling_seconds": 99999}}
) == server._COMPUTE_HOST_COMPRESS_WAIT_CAP_SECS