Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
"""Approvals config saves must re-emit session.info to live gateway sessions.
|
||||
|
||||
Regression for the desktop "YOLO toggle does nothing / flips back off" bug:
|
||||
the settings page saves ``approvals.mode`` through REST ``PUT /api/config``
|
||||
(and the raw editor through ``PUT /api/config/raw``), which wrote config.yaml
|
||||
and emitted nothing. Enforcement follows the file immediately (the approval
|
||||
gate re-reads config per command), but every live session's YOLO/approval
|
||||
indicator repaints only on a ``session.info`` event, so the UI kept showing
|
||||
stale bypass state, in the dangerous direction. The ``config.set`` RPC path
|
||||
already re-emits after a mode flip; these tests pin the REST paths to the
|
||||
same contract.
|
||||
"""
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(_isolate_hermes_home):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
from hermes_cli import web_server
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def broadcast_calls(monkeypatch):
|
||||
"""Stub the in-memory gateway module seam and record broadcasts."""
|
||||
import sys
|
||||
|
||||
calls = []
|
||||
# tests/conftest.py's session-reaper teardown walks tui_gateway.server
|
||||
# attributes; the stub must carry an empty _sessions to survive it.
|
||||
stub = types.SimpleNamespace(
|
||||
broadcast_session_info=lambda: calls.append(True),
|
||||
_sessions={},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "tui_gateway.server", stub)
|
||||
return calls
|
||||
|
||||
|
||||
class TestApprovalsSaveBroadcast:
|
||||
def test_get_shaped_record_roundtrip_does_not_broadcast(self, client, broadcast_calls):
|
||||
"""The settings page PUTs the defaulted GET record back verbatim on
|
||||
every autosave. That must not broadcast: disk holds sparse YAML while
|
||||
GET returns defaults, so a block-level compare is always-unequal (the
|
||||
review-caught spam bug). Only an effective mode change may emit."""
|
||||
record = client.get("/api/config").json()
|
||||
assert "approvals" in record
|
||||
|
||||
first = client.put("/api/config", json={"config": record})
|
||||
assert first.status_code == 200
|
||||
second = client.put("/api/config", json={"config": record})
|
||||
assert second.status_code == 200
|
||||
assert not broadcast_calls, (
|
||||
"autosaving the unmodified GET record broadcast session.info; "
|
||||
"every settings autosave would walk all live sessions"
|
||||
)
|
||||
|
||||
flipped = {**record, "approvals": {**record["approvals"], "mode": "off"}}
|
||||
resp = client.put("/api/config", json={"config": flipped})
|
||||
assert resp.status_code == 200
|
||||
assert len(broadcast_calls) == 1, (
|
||||
"an actual approvals.mode change in the GET-shaped record must "
|
||||
"broadcast exactly once"
|
||||
)
|
||||
|
||||
def test_approvals_mode_change_broadcasts(self, client, broadcast_calls):
|
||||
resp = client.put("/api/config", json={"config": {"approvals": {"mode": "off"}}})
|
||||
assert resp.status_code == 200
|
||||
assert broadcast_calls, (
|
||||
"PUT /api/config changed approvals.mode but no session.info "
|
||||
"broadcast reached the gateway, so live sessions keep painting "
|
||||
"stale YOLO/approval state"
|
||||
)
|
||||
|
||||
def test_non_approvals_change_does_not_broadcast(self, client, broadcast_calls):
|
||||
resp = client.put("/api/config", json={"config": {"display": {"skin": "mono"}}})
|
||||
assert resp.status_code == 200
|
||||
assert not broadcast_calls, (
|
||||
"a save that never touched approvals must not spam session.info"
|
||||
)
|
||||
|
||||
def test_approvals_noop_save_does_not_broadcast(self, client, broadcast_calls):
|
||||
first = client.put("/api/config", json={"config": {"approvals": {"mode": "off"}}})
|
||||
assert first.status_code == 200
|
||||
broadcast_calls.clear()
|
||||
|
||||
again = client.put("/api/config", json={"config": {"approvals": {"mode": "off"}}})
|
||||
assert again.status_code == 200
|
||||
assert not broadcast_calls, (
|
||||
"saving an identical approvals block is a no-op and must not "
|
||||
"re-emit session.info"
|
||||
)
|
||||
|
||||
def test_own_profile_named_default_broadcasts(self, client, broadcast_calls):
|
||||
"""Dashboard/desktop often send ?profile=default for this process's
|
||||
own home. That is not an other-profile save and must still emit."""
|
||||
resp = client.put(
|
||||
"/api/config?profile=default",
|
||||
json={"config": {"approvals": {"mode": "off"}}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert broadcast_calls, (
|
||||
"?profile=default is this process's own HERMES_HOME; skipping "
|
||||
"the broadcast leaves live sessions painting stale YOLO state"
|
||||
)
|
||||
|
||||
def test_other_profile_save_does_not_broadcast(self, client, broadcast_calls, monkeypatch, tmp_path):
|
||||
from hermes_cli import web_server
|
||||
|
||||
profile_dir = tmp_path / "profiles" / "other"
|
||||
profile_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(web_server, "_resolve_profile_dir", lambda name: profile_dir)
|
||||
|
||||
resp = client.put(
|
||||
"/api/config",
|
||||
json={"config": {"approvals": {"mode": "off"}}, "profile": "other"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert not broadcast_calls, (
|
||||
"a profile-scoped save targets a different HERMES_HOME than this "
|
||||
"process's gateway sessions; broadcasting our own sessions' "
|
||||
"unchanged state is wrong"
|
||||
)
|
||||
|
||||
def test_gateway_not_imported_is_a_noop(self, client, monkeypatch):
|
||||
import sys
|
||||
|
||||
monkeypatch.delitem(sys.modules, "tui_gateway.server", raising=False)
|
||||
resp = client.put("/api/config", json={"config": {"approvals": {"mode": "smart"}}})
|
||||
assert resp.status_code == 200
|
||||
assert "tui_gateway.server" not in sys.modules, (
|
||||
"the broadcast seam must not IMPORT the gateway; a process "
|
||||
"without one has no sessions to notify"
|
||||
)
|
||||
|
||||
def test_raw_save_deleting_approvals_block_broadcasts(self, client, broadcast_calls):
|
||||
seed = client.put(
|
||||
"/api/config/raw",
|
||||
json={"yaml_text": "approvals:\n mode: 'off'\n"},
|
||||
)
|
||||
assert seed.status_code == 200
|
||||
broadcast_calls.clear()
|
||||
|
||||
# Full-document replacement that drops the approvals block entirely:
|
||||
# effective mode falls back to default (manual), so indicators must
|
||||
# repaint.
|
||||
resp = client.put(
|
||||
"/api/config/raw",
|
||||
json={"yaml_text": "display:\n skin: default\n"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert broadcast_calls, (
|
||||
"deleting the approvals block changes the effective mode and "
|
||||
"must broadcast"
|
||||
)
|
||||
|
||||
def test_raw_save_approvals_change_broadcasts(self, client, broadcast_calls):
|
||||
resp = client.put(
|
||||
"/api/config/raw",
|
||||
json={"yaml_text": "approvals:\n mode: 'off'\n"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert broadcast_calls, (
|
||||
"PUT /api/config/raw changed approvals but no session.info "
|
||||
"broadcast reached the gateway"
|
||||
)
|
||||
|
||||
def test_raw_save_without_approvals_change_does_not_broadcast(self, client, broadcast_calls):
|
||||
seed = client.put(
|
||||
"/api/config/raw",
|
||||
json={"yaml_text": "approvals:\n mode: manual\ndisplay:\n skin: default\n"},
|
||||
)
|
||||
assert seed.status_code == 200
|
||||
broadcast_calls.clear()
|
||||
|
||||
resp = client.put(
|
||||
"/api/config/raw",
|
||||
json={"yaml_text": "approvals:\n mode: manual\ndisplay:\n skin: mono\n"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert not broadcast_calls
|
||||
|
||||
|
||||
class TestGatewayBroadcastHelper:
|
||||
def test_broadcast_session_info_emits_for_live_sessions(self, _isolate_hermes_home, monkeypatch):
|
||||
"""tui_gateway.server.broadcast_session_info walks _sessions and emits."""
|
||||
from tui_gateway import server
|
||||
|
||||
emitted = []
|
||||
monkeypatch.setattr(
|
||||
server, "_emit_session_info_for_session",
|
||||
lambda sid, sess: emitted.append(sid),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
server, "_sessions",
|
||||
{"s1": {"agent": object()}, "s2": {"agent": object()}},
|
||||
)
|
||||
|
||||
server.broadcast_session_info()
|
||||
|
||||
assert sorted(emitted) == ["s1", "s2"]
|
||||
|
||||
def test_approvals_slash_mirror_broadcasts(self, _isolate_hermes_home, monkeypatch):
|
||||
"""/approvals <mode> through the slash worker persists config out of
|
||||
band; the mirror must repaint live sessions and the bare read-only
|
||||
form must not."""
|
||||
from tui_gateway import server
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(server, "broadcast_session_info", lambda: calls.append(True))
|
||||
|
||||
session = {"agent": None}
|
||||
server._mirror_slash_side_effects("sid1", session, "/approvals off")
|
||||
assert calls, "/approvals <mode> writes approvals.mode and must broadcast"
|
||||
|
||||
calls.clear()
|
||||
server._mirror_slash_side_effects("sid1", session, "/approvals")
|
||||
assert not calls, "bare /approvals only reads the mode"
|
||||
Reference in New Issue
Block a user