mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
* fix(desktop): route /compress through session.compress RPC with transcript replacement Salvages #44462, #53755, and #68218 into a single canonical fix for the desktop /compress cluster. The desktop routed /compress through slash.exec, which sends it to the _SlashWorker subprocess. Compressing a large session outlives both the desktop's 30s WS timeout and the worker's 45s pipe timeout — the client gives up, runExec's blanket catch swallows the error, and command.dispatch surfaces a misleading "not a quick/plugin/skill command: compress" (#44456). Even when compression succeeded via the _mirror_slash_side_effects path, the desktop never received the post-compress message list, so summarized bubbles stayed on screen forever — /compress looked like a no-op. This change routes /compress to the dedicated session.compress RPC (the TUI's path), combining the best of all three PRs: - 120s client timeout matching the TUI's HERMES_TUI_RPC_TIMEOUT_MS (#44462) - Transcript replacement from the response `messages` via toChatMessages, the same converter session.resume uses (#68218, teknium1 review on #44462) - Session-isolation guard: updateSessionState only publishes for the active runtime, so a late result after a session switch can't clobber the foreground transcript (#53755, teknium1 review on #53755) - Coalescing: dedup concurrent compress requests per session (#53755) - Progress toast ("compressing context...") outside the transcript (#53755) - Error unmasking in runExec: when slash.exec fails and command.dispatch only adds "not a quick/plugin/skill command" routing noise, surface the original worker error instead (#44462) - /compact alias + focus_topic forwarding Co-authored-by: AlliDev <AIalliAI@users.noreply.github.com> Co-authored-by: PinkEVO <PINKIIILQWQ@users.noreply.github.com> * feat(desktop): route slash commands with dedicated RPCs to those RPCs Salvages #63513 — introduces a new `rpc` kind on DesktopCommandSurface so commands with a first-class gateway @method handler bypass slash.exec / command.dispatch entirely, and a `renderRpcResult` utility that shapes each RPC's structured reply into readable transcript text. Migrates 6 commands from exec() to rpc(...): /agents → agents.list /save → session.save /status → session.status /steer → session.steer /stop → process.stop /usage → session.usage /compress stays as action('compress') — it needs transcript replacement from the response `messages`, which the generic rpc path can't do (per teknium1 review on #44462/#63513). Also includes the json-rpc-gateway timeout message improvement: the error now includes the configured timeout duration ("request timed out after 120s: session.compress") so a user can tell whether the default 30s fired or a per-call override. Co-authored-by: Jelvin <SmallNew2003@users.noreply.github.com> * fix(desktop): preserve provider choice during config initialization * fix(desktop): preserve slash command and host compression semantics Keep commands whose CLI behavior exceeds their current RPC contracts on slash.exec. Propagate the full compression timeout through compute-host control, return structured host compression outcomes with metadata, and retain successful compression feedback in the desktop transcript. Add regressions for timeout forwarding, host aborts and metadata sync, structured host control responses, command routing parity, and numeric stop counts. * fix(desktop): harden compression state handling Preserve the invoking stored-session binding for delayed compression results, normalize replacement histories, and serialize provider selection. Stabilize gateway platform tests and guard the desktop Git facade during renderer teardown. --------- Co-authored-by: AlliDev <AIalliAI@users.noreply.github.com> Co-authored-by: PinkEVO <PINKIIILQWQ@users.noreply.github.com> Co-authored-by: Jelvin <SmallNew2003@users.noreply.github.com>
622 lines
21 KiB
Python
622 lines
21 KiB
Python
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tui_gateway.compute_host import ComputeHost, _default_workers
|
|
from tui_gateway.host_supervisor import (
|
|
MUTATOR_ROUTE_TABLE,
|
|
HostSupervisor,
|
|
append_log_record,
|
|
)
|
|
|
|
|
|
def _json_lines(out: io.StringIO) -> list[dict]:
|
|
frames = []
|
|
for line in out.getvalue().splitlines():
|
|
if line.strip():
|
|
frames.append(json.loads(line))
|
|
return frames
|
|
|
|
|
|
def _wait_for_frame(out: io.StringIO, predicate, timeout: float = 2.0) -> dict:
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
for frame in _json_lines(out):
|
|
if predicate(frame):
|
|
return frame
|
|
time.sleep(0.01)
|
|
raise AssertionError(f"timed out waiting for frame; saw={_json_lines(out)}")
|
|
|
|
|
|
def test_compute_host_workers_inherit_tui_pool_env_or_8(monkeypatch):
|
|
monkeypatch.delenv("HERMES_TUI_RPC_POOL_WORKERS", raising=False)
|
|
monkeypatch.delenv("HERMES_COMPUTE_HOST_WORKERS", raising=False)
|
|
assert _default_workers() == 8
|
|
|
|
monkeypatch.setenv("HERMES_TUI_RPC_POOL_WORKERS", "11")
|
|
assert _default_workers() == 11
|
|
|
|
# Dead-RC tombstone: malformed env falls back to 8, not the old except-branch 4.
|
|
monkeypatch.setenv("HERMES_TUI_RPC_POOL_WORKERS", "not-an-int")
|
|
assert _default_workers() == 8
|
|
|
|
|
|
def test_compute_host_frame_protocol_round_trip():
|
|
out = io.StringIO()
|
|
host = ComputeHost(stdout=out, max_workers=2, heartbeat_secs=0)
|
|
try:
|
|
host.handle_frame({"type": "session.seed", "sid": "alpha", "request_id": "seed", "history": []})
|
|
host.handle_frame(
|
|
{
|
|
"type": "turn.start",
|
|
"sid": "alpha",
|
|
"request_id": "turn-1",
|
|
"prompt": "hello",
|
|
"delta_count": 3,
|
|
"delay_s": 0,
|
|
}
|
|
)
|
|
|
|
end = _wait_for_frame(out, lambda f: f.get("type") == "turn.end" and f.get("request_id") == "turn-1")
|
|
assert end["history_version"] == 1
|
|
frames = _json_lines(out)
|
|
assert [f["type"] for f in frames if f.get("request_id") == "turn-1"] == [
|
|
"turn.started",
|
|
"delta",
|
|
"delta",
|
|
"delta",
|
|
"turn.end",
|
|
]
|
|
finally:
|
|
host.close()
|
|
|
|
|
|
def test_compute_host_interrupt_control_is_not_queued_behind_turn():
|
|
out = io.StringIO()
|
|
host = ComputeHost(stdout=out, max_workers=1, heartbeat_secs=0)
|
|
try:
|
|
host.handle_frame({"type": "session.seed", "sid": "alpha", "request_id": "seed", "history": []})
|
|
host.handle_frame(
|
|
{
|
|
"type": "turn.start",
|
|
"sid": "alpha",
|
|
"request_id": "turn-slow",
|
|
"prompt": "hello",
|
|
"delta_count": 200,
|
|
"delay_s": 0.01,
|
|
}
|
|
)
|
|
_wait_for_frame(out, lambda f: f.get("type") == "delta" and f.get("request_id") == "turn-slow")
|
|
|
|
host.handle_frame({"type": "interrupt", "sid": "alpha", "request_id": "stop-1"})
|
|
ack = _wait_for_frame(out, lambda f: f.get("type") == "interrupt.ack" and f.get("request_id") == "stop-1")
|
|
assert ack["applied"] is True
|
|
|
|
end = _wait_for_frame(out, lambda f: f.get("type") == "turn.end" and f.get("request_id") == "turn-slow")
|
|
assert end["interrupted"] is True
|
|
typed = [f["type"] for f in _json_lines(out)]
|
|
assert typed.index("interrupt.ack") < typed.index("turn.end")
|
|
finally:
|
|
host.close()
|
|
|
|
|
|
def test_compute_host_flushes_sessions_on_orphan_shutdown(monkeypatch):
|
|
from tui_gateway import server
|
|
|
|
out = io.StringIO()
|
|
host = ComputeHost(stdout=out, max_workers=1, heartbeat_secs=0)
|
|
session = {"session_key": "key"}
|
|
calls: list[tuple[dict, str]] = []
|
|
server._sessions["flush-sid"] = session
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_finalize_session",
|
|
lambda sess, end_reason="tui_close": calls.append((sess, end_reason)),
|
|
)
|
|
try:
|
|
host.flush_all_sessions(reason="orphan")
|
|
assert calls == [(session, "compute_host_orphan")]
|
|
finally:
|
|
server._sessions.pop("flush-sid", None)
|
|
host.close()
|
|
|
|
|
|
def test_compute_host_parent_guard_exits_when_parent_pid_changes(monkeypatch):
|
|
out = io.StringIO()
|
|
host = ComputeHost(stdout=out, max_workers=1, heartbeat_secs=0)
|
|
host._parent_pid = 111
|
|
monkeypatch.setattr(os, "getppid", lambda: 222)
|
|
|
|
def _exit(code):
|
|
raise SystemExit(code)
|
|
|
|
monkeypatch.setattr(os, "_exit", _exit)
|
|
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
host._parent_guard_loop()
|
|
|
|
assert exc_info.value.code == 0
|
|
orphan = next(frame for frame in _json_lines(out) if frame.get("type") == "orphan")
|
|
assert orphan["old_ppid"] == 111
|
|
assert orphan["ppid"] == 222
|
|
assert isinstance(orphan["host_ns"], int)
|
|
|
|
|
|
def test_mutator_route_table_matches_prd_inventory():
|
|
assert MUTATOR_ROUTE_TABLE == {
|
|
"prompt.submit": "turn-path",
|
|
"session.interrupt": "turn-path",
|
|
"reload.mcp": "run-concurrent",
|
|
"session.save": "run-concurrent",
|
|
"session.compress": "idle-gated",
|
|
"prompt.submit.truncate": "idle-gated",
|
|
"slash.model": "idle-gated",
|
|
"slash.personality": "idle-gated",
|
|
"slash.prompt": "idle-gated",
|
|
"slash.compress": "idle-gated",
|
|
"session.reset": "idle-gated",
|
|
"session.history.reload": "idle-gated",
|
|
"slash.retry": "idle-gated",
|
|
}
|
|
|
|
|
|
def test_compute_host_compress_control_runs_identity_guard_in_host(monkeypatch):
|
|
from tui_gateway import server
|
|
|
|
out = io.StringIO()
|
|
host = ComputeHost(stdout=out, max_workers=1, heartbeat_secs=0)
|
|
|
|
class _Agent:
|
|
model = "host-model"
|
|
provider = "host-provider"
|
|
tools = []
|
|
_cached_system_prompt = ""
|
|
session_input_tokens = 1
|
|
session_output_tokens = 1
|
|
session_prompt_tokens = 1
|
|
session_completion_tokens = 1
|
|
session_total_tokens = 2
|
|
session_api_calls = 1
|
|
context_compressor = None
|
|
|
|
session = {
|
|
"agent": _Agent(),
|
|
"session_key": "before-key",
|
|
"history": [
|
|
{"role": "user", "content": "before"},
|
|
{"role": "assistant", "content": "before"},
|
|
],
|
|
"history_lock": threading.Lock(),
|
|
"history_version": 2,
|
|
"running": False,
|
|
"manual_compression_lock": threading.Lock(),
|
|
}
|
|
calls: dict[str, object] = {}
|
|
|
|
def _compress(sess, focus_topic=None, **_kwargs):
|
|
assert sess is session
|
|
calls["compress_focus"] = focus_topic
|
|
with sess["history_lock"]:
|
|
sess["history"] = [{"role": "summary", "content": "compressed in host"}]
|
|
sess["history_version"] = 3
|
|
|
|
def _sync(sid, sess):
|
|
assert sess is session
|
|
calls["sync"] = sid
|
|
sess["session_key"] = "after-key"
|
|
|
|
server._sessions["sid"] = session
|
|
monkeypatch.setenv("HERMES_COMPUTE_HOST_CHILD", "1")
|
|
monkeypatch.setattr(server, "_compress_session_history", _compress)
|
|
monkeypatch.setattr(server, "_sync_session_key_after_compress", _sync)
|
|
monkeypatch.setattr(server, "_emit", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_session_info",
|
|
lambda _agent, _session=None: {
|
|
"model": "host-model",
|
|
"provider": "host-provider",
|
|
"usage": {"total": 2},
|
|
},
|
|
)
|
|
|
|
try:
|
|
host.handle_frame(
|
|
{
|
|
"type": "control",
|
|
"sid": "sid",
|
|
"request_id": "compress-1",
|
|
"route_name": "slash.compress",
|
|
"command": "/compress focus",
|
|
}
|
|
)
|
|
ack = _wait_for_frame(
|
|
out,
|
|
lambda f: f.get("type") == "control.ack" and f.get("request_id") == "compress-1",
|
|
)
|
|
finally:
|
|
server._sessions.pop("sid", None)
|
|
host.close()
|
|
|
|
assert calls == {"compress_focus": "focus", "sync": "sid"}
|
|
assert ack["route_name"] == "slash.compress"
|
|
assert ack["session_key"] == "after-key"
|
|
assert ack["history_version"] == 3
|
|
assert ack["message_count"] == 1
|
|
assert ack["session_info"]["model"] == "host-model"
|
|
|
|
|
|
def test_compute_host_session_compress_returns_structured_result(monkeypatch):
|
|
from tui_gateway import server
|
|
|
|
out = io.StringIO()
|
|
host = ComputeHost(stdout=out, max_workers=1, heartbeat_secs=0)
|
|
session = {
|
|
"agent": None,
|
|
"session_key": "host-key",
|
|
"history": [{"role": "user", "content": "preserved"}],
|
|
"history_lock": threading.Lock(),
|
|
"history_version": 3,
|
|
"running": False,
|
|
}
|
|
calls: list[dict] = []
|
|
|
|
def compress_handler(_rid, params):
|
|
calls.append(params)
|
|
return {
|
|
"result": {
|
|
"status": "aborted",
|
|
"messages": [{"role": "user", "content": "preserved"}],
|
|
"summary": {"aborted": True, "headline": "Compression aborted"},
|
|
}
|
|
}
|
|
|
|
server._sessions["sid"] = session
|
|
monkeypatch.setitem(server._methods, "session.compress", compress_handler)
|
|
monkeypatch.setattr(server, "_session_info", lambda _agent, _session: {"model": "host-model"})
|
|
|
|
try:
|
|
host.handle_frame(
|
|
{
|
|
"type": "control",
|
|
"sid": "sid",
|
|
"request_id": "compress-structured",
|
|
"route_name": "session.compress",
|
|
"command": "/compress auth",
|
|
}
|
|
)
|
|
ack = _wait_for_frame(
|
|
out,
|
|
lambda frame: frame.get("type") == "control.ack" and frame.get("request_id") == "compress-structured",
|
|
)
|
|
finally:
|
|
server._sessions.pop("sid", None)
|
|
host.close()
|
|
|
|
assert calls == [{"session_id": "sid", "focus_topic": "auth"}]
|
|
assert ack["result"]["status"] == "aborted"
|
|
assert ack["result"]["summary"]["aborted"] is True
|
|
assert ack["session_key"] == "host-key"
|
|
assert ack["history_version"] == 3
|
|
assert ack["message_count"] == 1
|
|
assert ack["session_info"] == {"model": "host-model"}
|
|
|
|
|
|
def test_append_log_record_single_write_lines(tmp_path):
|
|
path = tmp_path / "agent.log"
|
|
|
|
def writer(i: int) -> None:
|
|
append_log_record(path, f"line-{i:03d}-" + ("x" * 2000))
|
|
|
|
threads = [threading.Thread(target=writer, args=(i,)) for i in range(32)]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join()
|
|
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
assert len(lines) == 32
|
|
assert sorted(line.split("-", 2)[1] for line in lines) == [f"{i:03d}" for i in range(32)]
|
|
assert all(line.endswith("x" * 2000) for line in lines)
|
|
|
|
|
|
def test_supervisor_startup_reconcile_pid_reuse_guard(tmp_path, monkeypatch):
|
|
registry = tmp_path / "dashboard-compute-host.json"
|
|
registry.write_text(json.dumps({"host_pid": os.getpid(), "boot_id": "stale"}), encoding="utf-8")
|
|
|
|
killed: list[int] = []
|
|
supervisor = HostSupervisor(registry_path=registry, argv=[sys.executable, "-c", ""], autostart=False)
|
|
monkeypatch.setattr(supervisor, "_pid_matches_compute_host", lambda _pid: False)
|
|
monkeypatch.setattr(supervisor, "_terminate_pid", lambda pid, **_kw: killed.append(pid))
|
|
|
|
result = supervisor.reconcile_startup_orphan()
|
|
|
|
assert result == "pid-reuse-ignored"
|
|
assert killed == []
|
|
assert not registry.exists()
|
|
|
|
|
|
def test_supervisor_crash_emits_turn_error_and_respawns(tmp_path):
|
|
script = tmp_path / "fake_host.py"
|
|
script.write_text(
|
|
"""
|
|
import json, os, sys
|
|
print(json.dumps({'type':'hello','host_pid':os.getpid(),'boot_id':'boot-1','build_sha':'test','hermes_home':os.environ.get('HERMES_HOME','')}), flush=True)
|
|
for raw in sys.stdin:
|
|
frame=json.loads(raw)
|
|
if frame.get('type') == 'shutdown':
|
|
print(json.dumps({'type':'shutdown.ack','request_id':frame.get('request_id')}), flush=True)
|
|
break
|
|
if frame.get('type') == 'turn.start':
|
|
print(json.dumps({'type':'turn.started','sid':frame.get('sid'),'request_id':frame.get('request_id')}), flush=True)
|
|
sys.stdout.flush()
|
|
os._exit(7)
|
|
""".strip(),
|
|
encoding="utf-8",
|
|
)
|
|
registry = tmp_path / "dashboard-compute-host.json"
|
|
completions: list[dict] = []
|
|
rpc_events: list[dict] = []
|
|
supervisor = HostSupervisor(
|
|
registry_path=registry,
|
|
argv=[sys.executable, str(script)],
|
|
rpc_sink=rpc_events.append,
|
|
respawn_max=2,
|
|
heartbeat_secs=1,
|
|
expected_build_sha="test",
|
|
autostart=False,
|
|
)
|
|
try:
|
|
supervisor.start()
|
|
supervisor.submit_turn(
|
|
{"type": "turn.start", "sid": "sid-1", "request_id": "turn-1", "text": "hello"},
|
|
on_complete=completions.append,
|
|
)
|
|
deadline = time.monotonic() + 5
|
|
while time.monotonic() < deadline and not completions:
|
|
time.sleep(0.02)
|
|
assert completions, "host crash did not complete pending turn"
|
|
assert completions[0]["type"] == "turn.error"
|
|
assert completions[0]["reason"] == "crash"
|
|
|
|
deadline = time.monotonic() + 5
|
|
while time.monotonic() < deadline and not supervisor.is_running():
|
|
time.sleep(0.02)
|
|
assert supervisor.is_running()
|
|
finally:
|
|
supervisor.shutdown()
|
|
|
|
|
|
def _make_compress_host_session(events: list) -> dict:
|
|
class _Agent:
|
|
model = "host-model"
|
|
provider = "host-provider"
|
|
tools = []
|
|
_cached_system_prompt = ""
|
|
session_input_tokens = 1
|
|
session_output_tokens = 1
|
|
session_prompt_tokens = 1
|
|
session_completion_tokens = 1
|
|
session_total_tokens = 2
|
|
session_api_calls = 1
|
|
session_id = "rotated-id"
|
|
|
|
agent = _Agent()
|
|
agent.context_compressor = type("ContextEngineStub", (), {})()
|
|
agent.context_compressor.on_session_start = (
|
|
lambda *_args, **_kwargs: events.append("notify")
|
|
)
|
|
return {
|
|
"agent": agent,
|
|
"session_key": "before-key",
|
|
"history": [
|
|
{"role": "user", "content": "before"},
|
|
{"role": "assistant", "content": "before"},
|
|
],
|
|
"history_lock": threading.Lock(),
|
|
"history_version": 2,
|
|
"running": False,
|
|
"manual_compression_lock": threading.Lock(),
|
|
}
|
|
|
|
|
|
def test_compute_host_compress_control_notifies_engine_after_commit(monkeypatch):
|
|
"""The compute-host slash.compress route must fire the context-engine
|
|
boundary hook exactly once, and only AFTER the host commits the compressed
|
|
history + session-key sync (salvaged #65670, extended to this route)."""
|
|
from agent.conversation_compression import (
|
|
_queue_context_engine_compression_notification,
|
|
finalize_context_engine_compression_notification,
|
|
)
|
|
from tui_gateway import server
|
|
|
|
out = io.StringIO()
|
|
host = ComputeHost(stdout=out, max_workers=1, heartbeat_secs=0)
|
|
events: list[str] = []
|
|
session = _make_compress_host_session(events)
|
|
|
|
def _compress(sess, focus_topic=None, **_kwargs):
|
|
# Simulate agent._compress_context(defer_context_engine_notification=True)
|
|
_queue_context_engine_compression_notification(
|
|
sess["agent"],
|
|
new_session_id="rotated-id",
|
|
old_session_id="before-key",
|
|
)
|
|
with sess["history_lock"]:
|
|
sess["history"] = [{"role": "summary", "content": "compressed"}]
|
|
sess["history_version"] = 3
|
|
|
|
def _sync(sid, sess):
|
|
events.append("sync")
|
|
sess["session_key"] = "after-key"
|
|
|
|
server._sessions["sid"] = session
|
|
monkeypatch.setenv("HERMES_COMPUTE_HOST_CHILD", "1")
|
|
monkeypatch.setattr(server, "_compress_session_history", _compress)
|
|
monkeypatch.setattr(server, "_sync_session_key_after_compress", _sync)
|
|
monkeypatch.setattr(server, "_emit", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_session_info",
|
|
lambda _agent, _session=None: {"model": "host-model", "usage": {"total": 2}},
|
|
)
|
|
|
|
try:
|
|
host.handle_frame(
|
|
{
|
|
"type": "control",
|
|
"sid": "sid",
|
|
"request_id": "compress-1",
|
|
"route_name": "slash.compress",
|
|
"command": "/compress",
|
|
}
|
|
)
|
|
ack = _wait_for_frame(
|
|
out,
|
|
lambda f: f.get("type") == "control.ack" and f.get("request_id") == "compress-1",
|
|
)
|
|
finally:
|
|
server._sessions.pop("sid", None)
|
|
host.close()
|
|
|
|
# Exactly one notification, after the session-key commit.
|
|
assert events == ["sync", "notify"]
|
|
assert ack["session_key"] == "after-key"
|
|
# Nothing pending leaks onto the agent for a later compress to misfire.
|
|
assert (
|
|
finalize_context_engine_compression_notification(
|
|
session["agent"], committed=True
|
|
)
|
|
is False
|
|
)
|
|
|
|
|
|
def test_compute_host_compress_control_failure_discards_notification(monkeypatch):
|
|
"""When the host-side compress mirror fails after compression queued the
|
|
boundary notification, the pending hook must be discarded — never left to
|
|
fire against a boundary the host rejected."""
|
|
from agent.conversation_compression import (
|
|
_queue_context_engine_compression_notification,
|
|
finalize_context_engine_compression_notification,
|
|
)
|
|
from tui_gateway import server
|
|
|
|
out = io.StringIO()
|
|
host = ComputeHost(stdout=out, max_workers=1, heartbeat_secs=0)
|
|
events: list[str] = []
|
|
session = _make_compress_host_session(events)
|
|
|
|
def _compress(sess, focus_topic=None, **_kwargs):
|
|
_queue_context_engine_compression_notification(
|
|
sess["agent"],
|
|
new_session_id="rotated-id",
|
|
old_session_id="before-key",
|
|
)
|
|
|
|
def _boom(*_args, **_kwargs):
|
|
raise RuntimeError("synthetic host commit failure")
|
|
|
|
server._sessions["sid"] = session
|
|
monkeypatch.setenv("HERMES_COMPUTE_HOST_CHILD", "1")
|
|
monkeypatch.setattr(server, "_compress_session_history", _compress)
|
|
monkeypatch.setattr(server, "_sync_session_key_after_compress", _boom)
|
|
monkeypatch.setattr(server, "_emit", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_session_info",
|
|
lambda _agent, _session=None: {"model": "host-model", "usage": {"total": 2}},
|
|
)
|
|
|
|
try:
|
|
host.handle_frame(
|
|
{
|
|
"type": "control",
|
|
"sid": "sid",
|
|
"request_id": "compress-2",
|
|
"route_name": "slash.compress",
|
|
"command": "/compress",
|
|
}
|
|
)
|
|
ack = _wait_for_frame(
|
|
out,
|
|
lambda f: f.get("type") == "control.ack" and f.get("request_id") == "compress-2",
|
|
)
|
|
finally:
|
|
server._sessions.pop("sid", None)
|
|
host.close()
|
|
|
|
assert events == []
|
|
assert "live session sync failed" in str(ack.get("output") or "")
|
|
# The pending notification was discarded, not left on the agent.
|
|
assert (
|
|
finalize_context_engine_compression_notification(
|
|
session["agent"], committed=True
|
|
)
|
|
is False
|
|
)
|
|
assert events == []
|
|
|
|
|
|
def test_compute_host_compact_alias_routes_to_compress_mirror(monkeypatch):
|
|
"""slash.compress control frames forward the user's raw alias verbatim;
|
|
/compact must reach the compress mirror (and its deferred-notification
|
|
finalize wiring), not silently no-op."""
|
|
from agent.conversation_compression import (
|
|
_queue_context_engine_compression_notification,
|
|
)
|
|
from tui_gateway import server
|
|
|
|
out = io.StringIO()
|
|
host = ComputeHost(stdout=out, max_workers=1, heartbeat_secs=0)
|
|
events: list[str] = []
|
|
session = _make_compress_host_session(events)
|
|
calls: dict[str, object] = {}
|
|
|
|
def _compress(sess, focus_topic=None, **_kwargs):
|
|
calls["focus"] = focus_topic
|
|
_queue_context_engine_compression_notification(
|
|
sess["agent"],
|
|
new_session_id="rotated-id",
|
|
old_session_id="before-key",
|
|
)
|
|
|
|
server._sessions["sid"] = session
|
|
monkeypatch.setenv("HERMES_COMPUTE_HOST_CHILD", "1")
|
|
monkeypatch.setattr(server, "_compress_session_history", _compress)
|
|
monkeypatch.setattr(
|
|
server, "_sync_session_key_after_compress", lambda *_a: events.append("sync")
|
|
)
|
|
monkeypatch.setattr(server, "_emit", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_session_info",
|
|
lambda _agent, _session=None: {"model": "host-model", "usage": {"total": 2}},
|
|
)
|
|
|
|
try:
|
|
host.handle_frame(
|
|
{
|
|
"type": "control",
|
|
"sid": "sid",
|
|
"request_id": "compact-1",
|
|
"route_name": "slash.compress",
|
|
"command": "/compact focus topic",
|
|
}
|
|
)
|
|
ack = _wait_for_frame(
|
|
out,
|
|
lambda f: f.get("type") == "control.ack" and f.get("request_id") == "compact-1",
|
|
)
|
|
finally:
|
|
server._sessions.pop("sid", None)
|
|
host.close()
|
|
|
|
assert calls == {"focus": "focus topic"}
|
|
assert events == ["sync", "notify"]
|
|
assert ack["route_name"] == "slash.compress"
|