Files
hermes-agent/tests/tui_gateway/test_protocol.py
T
shannonsands a6ee31f55a feat(wisdom): add Hermes Collective Wisdom Agent V1 (#94266)
* feat(wisdom): add trusted publish and install foundation

* feat(wisdom): add private contribution loop

* feat(wisdom): add managed consumption workflows

* fix(wisdom): close cross-repository safety gaps

* fix(wisdom): align local package and lifecycle policy

* fix(wisdom): require explicit profile setup

* docs(wisdom): repin reconciled gateway head

* fix(wisdom): fence content downloads and approval receipts

* docs(wisdom): record generation-fenced downloads

* docs(wisdom): record unified delivery PR

* fix(ci): stop passing invalid classifier inputs

* docs(wisdom): remove internal requirements ledger

* feat(wisdom): localize dashboard and desktop copy

* feat(wisdom): complete local contribution and consumption UX

* style(wisdom): satisfy desktop lint

* chore(wisdom): refresh requirements pin

* test(dashboard): allow formatted profile copy

* test(wisdom): stabilize desktop interaction coverage

* fix(wisdom): surface dashboard action failures

* fix(wisdom): add repeatable Portal demo login

* feat(wisdom): add actionable skill notifications

* feat(wisdom): add notification install and update actions

* fix(wisdom): make Telegram skill alerts actionable

* fix(wisdom): always refresh demo Agent login

* feat(wisdom): embed Telegram notification actions

* fix(wisdom): preserve Telegram notifications after actions

* fix(wisdom): keep Telegram notification cards readable

* feat(wisdom): add Telegram candidate approval flow

* feat(wisdom): explain Telegram qualification reasons

* fix(wisdom): reconcile cross-surface candidate actions

* feat(telegram): add Collective Wisdom management command

* chore(wisdom): refresh Gateway contract pin

* chore(wisdom): advance Gateway contract pin

* feat(wisdom): align command UX across clients

* feat(slack): add Collective Wisdom management parity

* feat(wisdom): add security and professionalism reviews

* feat(wisdom): add first-time qualification guidance

* feat(wisdom): simplify qualification sharing choices

* feat(skills): add optional editorial metadata

* feat(wisdom): enrich legacy skill presentation

* fix(wisdom): harden review and update boundaries

* fix(wisdom): emit canonical review timestamps

* fix(wisdom): align with merged gateway and main

* wisdom: add agent-led sharing core (policy, evidence, schemas, templates, delivery, weekly job, share/install flows)

- hermes_wisdom/agent_led/: policy resolution (server > local > defaults),
  7-day evidence builder that excludes bundled/hub/managed skills and
  dismissed/handled/recently-suggested content hashes, strict pydantic
  schemas for agent output with repair-or-reject, fixed copy templates
  (Share / Teammate / Published / Update / Mute), idempotent retried
  delivery ledger with stale-action resolution, weekly review job,
  resumable Share and Install flows.
- prompts/: candidate review, recipient recommendation, share packaging.
- tests/wisdom/test_agent_led.py: 30 tests.

* wisdom: agent-led renderers and button action dispatcher

- render.py: Telegram HTML, Slack blocks, Desktop payload; editorial name
  is the emphasized line, product label stays separate.
- actions.py: resolve opaque wa:<action>:<dedup> targets via the delivery
  ledger; Not now -> dismissal, Mute -> fixed options, Share -> resumable
  packaging flow, Install/Update -> plan command. Never publishes/installs.

* wisdom: CLI verbs, agent_led config default, conversational catalog skill

- hermes wisdom browse/review-week/act/share/dismiss/mute (all --json).
- wisdom.agent_led config block, default enabled.
- SKILL.md rewritten so natural-language catalog questions map to the CLI
  verbs, share/install flows and fixed notification templates.

* wisdom: wire agent-led weekly review into gateway tick and Telegram buttons

- gateway housekeeping tick calls maybe_run_weekly_review with a home
  channel sender when a Telegram adapter is available.
- Telegram: wa: callbacks resolved through the ledger (stale-safe), mute
  duration keyboard, send_wisdom_agent_recommendation rich card + fallback.

* fix(wisdom): integrate local mediation and harden model and setup boundaries

* fix(wisdom): honor authoritative recommendation policy and defer on failure

* fix(wisdom): synchronize opaque suppression and recheck delivery preferences

* feat(wisdom): route weekly selection through the session-owned assessment queue

* fix(wisdom): prepare and submit the reviewed generated share package

* feat(wisdom): separate native Share preparation from publication consent

* feat(wisdom): sync native mute choices through a leased preference outbox

* feat(wisdom): bind native mute controls to durable preference choices

* feat(wisdom): add scoped desktop and dashboard notification settings

* fix(wisdom): revalidate feed recommendations before assessment and delivery

* fix(wisdom): persist validated delivery receipts before completing notices

* feat(wisdom): add private notification claim and receipt client

* Persist Wisdom send reservations and recover delivery acknowledgements

* Route legacy Wisdom controls through current native review

* Add typed private Wisdom operation outcome client

* fix(wisdom): make agent-led advice usable in the local demo

* fix(wisdom): keep requested consent outside proactive limits

* fix(wisdom): distinguish unavailable assessments and preserve digest text

* fix(wisdom): assess ongoing usefulness beyond the current task

* fix(wisdom): restore immediate qualification sharing controls

* fix(wisdom): separate qualification review from installation advice

* fix(wisdom): collapse review checklists and simplify sharing copy

* fix(wisdom): show compact sharing progress and publication receipts

* fix(wisdom): require credential prefixes rather than matching skill names

* fix(wisdom): finish package checks before presenting sharing consent

* fix(wisdom): scan local skills before qualification cards

* fix(wisdom): update moderation results on existing sharing cards

* fix(wisdom): keep sharing review accessible from receipt cards

* fix(wisdom): align mediated review cards and collapsible checks

* fix(wisdom): clarify clean security summary wording

* fix(wisdom): normalize consent plans and add explicit recheck

* fix(wisdom): keep install and update receipts concise

* fix(wisdom): collapse assessments and deduplicate operation cards

* fix(wisdom): restore private Portal review from native cards

* fix(wisdom): sync Portal publication to original consent card

* fix(wisdom): show local skill version on sharing cards

* fix(wisdom): skip agent recommendations for self-published versions

* fix(wisdom): simplify candidate notices and local-edit recovery copy

* feat(wisdom): submit locally reviewed packages with one confirmation

* feat(wisdom): expose safe receipt and outcome sync recovery

* wisdom: onboarding notice says detect and share, names the user's own skill

Copy review from the product owner on the first and returning
qualification notices (fixed delivery mode):
- the feature blurb now says the org enabled detection *and sharing*
- both notices say the detected skill is one the user created
- both close with an exclamation mark

Applied identically to hermes_wisdom.notice, the desktop and web i18n
strings, and the tests that assert the sentences.

* wisdom: one opener, no approval line, ask to share after the skill is shown

Product owner review of the candidate card.

- The Hermes written card now opens with the same sentence as the fixed card
  ("Your organisation has enabled Collective Wisdom, a feature designed to
  automatically detect and share useful skills across all team members.")
  instead of its own blurb, so there is one first time message.
- "Nothing is shared without your approval." removed from Telegram, Slack
  and Desktop. The buttons already make the permission explicit.
- "Would you like to share?" no longer appears before the skill is named.
  It is now the last line, after the skill name, description, why suggested
  and the checks, and reads "Would you like to share it?" (matching the
  agent led template wording).

Tests updated for the new order; proposalNotice removed from all desktop locales.

* wisdom: American spelling, organization

Product owner decision: user facing copy uses American spelling.
Changes "Your organisation" to "Your organization" in the chat notice,
the Hermes written card opener, the desktop and web strings, and the
tests that assert them. Identifiers such as nas_organisation:* and the
German and French locales are untouched.

* wisdom: candidate card copy round 4 (owner review)

Apply the product owner's round 4 copy decisions to the Hermes Collective
Wisdom candidate card on Telegram, Slack, Desktop and the shared views:

1. Hermes-written cards are titled "Hermes Collective Wisdom" instead of
   the bare "Collective Wisdom".
2. The "Reusable skill ready to review" line is gone from the candidate
   card (Telegram rich card and plain fallback, legacy agent-led share
   template).
3. The skill name and description are labelled: "Skill name: <name>" and
   "What it does: <description>" (Telegram, Slack, Desktop).
4. "Why suggested:" is now "Why others might benefit:".
5. A passing professionalism review reads "Safe to share at work ✓ (no
   inappropriate content found)" with no per-check bullets and no "Pass";
   a failed review reads "Needs a look before sharing at work (possible
   inappropriate content)" and lists only the checks that flagged
   something. Pending/unavailable wording is unchanged.
6. Telegram button toasts: "Will ask later...", "Preparing more
   details...", "Sharing...".
7. Qualification reasons: "You used this skill consistently across many
   days." and "You've really refined this skill."
8. prompts/wisdom_candidate_review.md asks for a compelling
   editorial_name, a simple one_line_description and a compelling
   why_coworkers_benefit under 300 characters; "Be concise and
   convincing." becomes "Be concise and compelling: the goal is that the
   user wants to share it."

Tests updated for the new strings; review_text() gains direct coverage.

* wisdom: re-apply owner copy after rebase

- Native share cards (advice_view/interaction_view): drop the approval line, ask "Would you like to share it?" as the last line after the checks
- Hermes-written completion card titled "Hermes Collective Wisdom"
- Qualification reasons use the owner wording (consistently across many days / really refined)
- American spelling (organization) in remaining English copy
- Desktop test asserts the current Share button; web test matches the returning notice

* fix(wisdom): pin reconciled Gateway and verify Unicode hash vectors

Pin Gateway 60cd2d6b613ae3cd4a6e65155d1142006d907e78 and byte-identical producer artifacts. Verify every content-order case and package-manifest binding. Validation: 186 focused Python tests, Ruff and contract verifier.

* fix(wisdom): reconcile optional SDK tests and frontend lint

* fix(wisdom): default to agent-written notification summaries

* fix(wisdom): restore deferred install review and browse controls

* feat(wisdom): inspect installed setup with exact package provenance

* feat(wisdom): run native-approved installed setup steps with durable evidence

* fix(wisdom): recover interrupted setup with explicit native consent

* feat(wisdom): hand native installs into guided setup review

* fix(wisdom): continue requested setup with fixed notification copy

* fix(wisdom): preserve setup while waiting for a session model

* fix(wisdom): expose canonical setup review controls on desktop

* fix(wisdom): resume setup after recorded automatic updates

* fix(wisdom): make missing setup prerequisites recheckable

* chore(wisdom): align Agent with verified Gateway contract

* fix(wisdom): stop guessing team slugs in portal links

* fix(wisdom): retire pending advice on account sign-out

* fix(wisdom): cancel advice after terminal account revocation

* fix(wisdom): fence feed responses across account sign-out

* fix(wisdom): checkpoint signed-out feed before reactivation

* fix(wisdom): link proactive advice to scoped notification settings

* fix(wisdom): coalesce queued publication recommendations by version

* fix(wisdom): keep package review navigation local and deferable

* fix(wisdom): reflect installed state in discovery controls

* fix(wisdom): show exact checks before command confirmation

* chore(wisdom): pin bounded analytics privacy contract

* chore(wisdom): pin retired legacy notification contract

* feat(wisdom): review publisher usage with exact sharing copy

* fix(wisdom): align discovery and review check summaries

* fix(wisdom): show expired consent before confirmation

* fix(wisdom): require fresh review for legacy install controls

* fix(wisdom): preserve review expiry across check toggles

* fix(wisdom): retain update policy in native install reviews

* fix(wisdom): surface failed native card edits

* fix(wisdom): persist local command approval reviews

* fix(wisdom): use saved approvals for messaging commands

* test(wisdom): provide scan result in setup handoff fixture

* test(wisdom): exercise Telegram approvals with saved review state

* fix(wisdom): retain suppression policy for offline deferral

* fix(wisdom): reconsider candidates after deferred suppression expires

* fix(wisdom): bind review checks and report verified readiness separately

* fix(wisdom): persist accepted publication intent and recover exact outcomes

* fix(sync): pin UTF-8 tree ordering across writers

* chore(wisdom): pin organisation-scoped Gateway authorization

* fix(wisdom): restrict consent delivery to user-facing sessions

* chore(wisdom): refresh reviewed Gateway contract pin

* fix(wisdom): preserve kept tools in Blank Slate exclusions

* test(auth): reset anonymous fixture with a profile-scoped cache

* fix(wisdom): gate local surfaces and work on current profile entitlement

* fix(wisdom): invalidate quiet tool cache on entitlement changes

* test(wisdom): authorize local consent gateway fixtures

* fix(wisdom): keep entitlement decoding free of native crypto imports

* test(wisdom): provide local entitlement to demo CLI subprocess

* ci: leave upstream workflow unchanged in Wisdom PR

* fix(wisdom): ship package and contracts in Nix wheels

---------

Co-authored-by: hbizi <36184542+hbizi@users.noreply.github.com>
2026-09-11 19:04:06 +10:00

1513 lines
53 KiB
Python

"""Tests for tui_gateway JSON-RPC protocol plumbing."""
import io
import json
import os
import subprocess
import sys
import threading
import time
import types
from unittest.mock import MagicMock, patch
from pathlib import Path
import pytest
_original_stdout = sys.stdout
@pytest.fixture(autouse=True)
def _restore_stdout():
yield
sys.stdout = _original_stdout
@pytest.fixture()
def server():
# The sys.modules mocks only need to cover the *initial* import — once
# tui_gateway.server is cached, they are inert. Keeping them active for
# the whole test poisons any module first imported inside a test body:
# e.g. hermes_cli.active_sessions would bind the mocked get_hermes_home
# (a fixed shared path) forever, leaking active-session registry entries
# across every later test in the process. Scope the patch to the import.
with patch.dict("sys.modules", {
"hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")),
"hermes_cli.env_loader": MagicMock(),
"hermes_cli.banner": MagicMock(),
"hermes_state": MagicMock(),
}):
import importlib
mod = importlib.import_module("tui_gateway.server")
# Snapshot the RPC registry: several tests below stub handlers
# ("slash.exec", "fast.ping", ...) directly in the module-level dict,
# which is shared with every other test file in the process.
methods = dict(mod._methods)
real_stdout = mod._real_stdout
yield mod
# Reset module-level state without re-importing. importlib.reload
# would re-register the module's atexit hooks (ThreadPoolExecutor
# shutdown, _shutdown_sessions); the duplicates race the stderr
# buffer at interpreter shutdown and surface as Fatal Python error:
# _enter_buffered_busy. Restoring the dicts in place gives the next
# test a clean slate.
mod._methods.clear()
mod._methods.update(methods)
mod._real_stdout = real_stdout
for sid in list(mod._sessions):
mod._close_session_by_id(sid, end_reason="test_cleanup")
mod._pending.clear()
mod._answers.clear()
mod._live_transports.clear()
def test_shared_fixture_cleanup_uses_full_session_teardown(server, monkeypatch):
"""The cross-file autouse cleanup must close every retained resource."""
from tests import conftest
closed = {"worker": 0, "agent": 0, "lease": 0}
class _Closable:
def __init__(self, key):
self.key = key
def close(self):
closed[self.key] += 1
class _Lease:
def release(self):
closed["lease"] += 1
monkeypatch.setattr(server, "_get_db", lambda: None)
server._sessions["leaked"] = {
"session_key": "leaked",
"agent": _Closable("agent"),
"slash_worker": _Closable("worker"),
"active_session_lease": _Lease(),
"history": [],
}
conftest._teardown_tui_server_sessions(server)
assert server._sessions == {}
assert closed == {"worker": 1, "agent": 1, "lease": 1}
@pytest.fixture()
def capture(server):
"""Redirect server's real stdout to a StringIO and return (server, buf)."""
buf = io.StringIO()
server._real_stdout = buf
return server, buf
# ── JSON-RPC envelope ────────────────────────────────────────────────
def test_unknown_method(server):
resp = server.handle_request({"id": "1", "method": "bogus"})
assert resp["error"]["code"] == -32601
def test_ok_envelope(server):
assert server._ok("r1", {"x": 1}) == {
"jsonrpc": "2.0", "id": "r1", "result": {"x": 1},
}
def test_err_envelope(server):
assert server._err("r2", 4001, "nope") == {
"jsonrpc": "2.0", "id": "r2", "error": {"code": 4001, "message": "nope"},
}
@pytest.mark.parametrize("kind", ["legacy", "hard-only", "dynamic-getattr"])
def test_session_interrupt_uses_explicit_stop_compatibility(server, monkeypatch, kind):
calls = []
class _Legacy:
def interrupt(self):
calls.append("legacy")
class _HardOnly:
def hard_interrupt(self):
calls.append("hard")
class _Dynamic:
def interrupt(self):
calls.append("legacy")
def __getattr__(self, name):
if name == "hard_interrupt":
return lambda: calls.append("fabricated-hard")
raise AttributeError(name)
agent = {
"legacy": _Legacy(),
"hard-only": _HardOnly(),
"dynamic-getattr": _Dynamic(),
}[kind]
session = {
"agent": agent,
"history_lock": threading.Lock(),
"running": True,
"queued_prompt": "later",
"session_key": "session-key",
"_run_thread": None,
}
monkeypatch.setattr(server, "_tts_stream_stop", lambda: None)
monkeypatch.setattr(server, "_sess_nowait", lambda _params, _rid: (session, None))
monkeypatch.setattr(server, "_sess", lambda _params, _rid: (session, None))
monkeypatch.setattr(server, "_session_uses_compute_host", lambda _session: False)
monkeypatch.setattr(server, "_clear_pending", lambda _sid: None)
response = server._methods["session.interrupt"](
"stop", {"session_id": "ui-session"}
)
assert response["result"]["status"] == "interrupted"
assert calls == ["hard" if kind == "hard-only" else "legacy"]
# ── write_json ────────────────────────────────────────────────
def test_write_json(capture):
server, buf = capture
assert server.write_json({"test": True})
assert json.loads(buf.getvalue()) == {"test": True}
def test_live_session_payload_replays_pending_approval(server, monkeypatch):
"""A reattached client receives the approval that was emitted while detached."""
from tools import approval
from tools import approval_gateway_wait
session = {
"agent": types.SimpleNamespace(),
"cols": 80,
"created_at": 1.0,
"history": [],
"history_lock": threading.Lock(),
"running": True,
"session_key": "stored-session",
}
first = {
"choices": ["once", "deny"],
"command": "rm -rf /tmp/example",
"description": "recursive delete",
}
second = {"command": "rm -rf /tmp/later", "description": "later"}
saved_queue = approval._gateway_queues.pop("stored-session", None)
approval._gateway_queues["stored-session"] = [
approval_gateway_wait._ApprovalEntry(first),
approval_gateway_wait._ApprovalEntry(second),
]
monkeypatch.setattr(server, "_approval_request_payload", lambda data: dict(data or {}))
try:
payload = server._live_session_payload("runtime-session", session)
finally:
approval._gateway_queues.pop("stored-session", None)
if saved_queue is not None:
approval._gateway_queues["stored-session"] = saved_queue
assert payload["pending_approval"] is not first
replayed = payload["pending_approval"]
# request_id is injected by _ApprovalEntry so reconnecting clients can
# correlate their approval.respond with the exact queued request.
assert replayed.pop("request_id")
assert replayed == first
def test_live_session_payload_replays_pending_clarify(server):
"""A reattached client also receives a clarify question emitted while detached."""
session = {
"agent": types.SimpleNamespace(),
"cols": 80,
"created_at": 1.0,
"history": [],
"history_lock": threading.Lock(),
"running": True,
"session_key": "stored-session",
}
clarify_payload = {
"choices": ["staging", "production"],
"question": "Which deployment target?",
"request_id": "rid-clarify",
}
with server._prompt_lock:
server._pending["rid-clarify"] = ("runtime-session", threading.Event())
server._pending_prompt_payloads["rid-clarify"] = (
"clarify.request",
dict(clarify_payload),
)
try:
payload = server._live_session_payload("runtime-session", session)
other = server._live_session_payload("other-session", session)
finally:
with server._prompt_lock:
server._pending.pop("rid-clarify", None)
server._pending_prompt_payloads.pop("rid-clarify", None)
assert payload["pending_clarify"] == clarify_payload
# Snapshot, not a live reference into the registry.
assert payload["pending_clarify"] is not clarify_payload
# Scoped to the owning runtime session only.
assert "pending_clarify" not in other
def test_disable_flush_env_var_actually_wires_to_module_constant(monkeypatch):
"""End-to-end: setting `HERMES_TUI_GATEWAY_NO_FLUSH=1` and importing
`tui_gateway.transport` fresh actually flips `_DISABLE_FLUSH` true.
Reloads only the transport module — server.py is untouched so its
atexit hooks/worker pool stay intact."""
import importlib
monkeypatch.setenv("HERMES_TUI_GATEWAY_NO_FLUSH", "1")
transport_mod = importlib.reload(importlib.import_module("tui_gateway.transport"))
try:
assert transport_mod._DISABLE_FLUSH is True
finally:
# Restore the env-disabled state so other tests see the default.
monkeypatch.delenv("HERMES_TUI_GATEWAY_NO_FLUSH", raising=False)
importlib.reload(transport_mod)
# ── _emit ────────────────────────────────────────────────────────────
def test_emit_with_payload(capture):
server, buf = capture
assert server._emit("test.event", "s1", {"key": "val"}) is True
msg = json.loads(buf.getvalue())
assert msg["method"] == "event"
assert msg["params"]["type"] == "test.event"
assert msg["params"]["session_id"] == "s1"
assert msg["params"]["payload"]["key"] == "val"
def test_emit_preserves_disconnected_transport_result(capture, monkeypatch):
server, _ = capture
monkeypatch.setattr(server, "write_json", lambda _: False)
assert server._emit("notification.show", "s1", {"key": "wisdom.advice"}) is False
# ── Blocking prompt round-trip ───────────────────────────────────────
def test_block_and_respond(capture):
server, _ = capture
result = [None]
threading.Thread(
target=lambda: result.__setitem__(0, server._block("test.prompt", "s1", {"q": "?"}, timeout=5)),
).start()
for _ in range(100):
if server._pending:
break
threading.Event().wait(0.01)
rid = next(iter(server._pending))
server._answers[rid] = "my_answer"
# _pending values are (sid, Event) tuples — unpack to set the Event
_, ev = server._pending[rid]
ev.set()
threading.Event().wait(0.1)
assert result[0] == "my_answer"
@pytest.mark.parametrize(
"event",
["secret.request", "sudo.request", "clarify.request", "terminal.read.request"],
)
def test_sensitive_prompt_timeout_emits_expiry(capture, event):
server, buf = capture
assert server._block(event, "s1", {}, timeout=0) == ""
messages = [json.loads(line) for line in buf.getvalue().splitlines()]
request, expiry = [message["params"] for message in messages]
assert request["type"] == event
assert expiry["type"] == event.removesuffix(".request") + ".expire"
assert expiry["session_id"] == "s1"
assert expiry["payload"]["request_id"] == request["payload"]["request_id"]
@pytest.mark.parametrize(
("method", "value_key"),
[
("secret.respond", "value"),
("sudo.respond", "password"),
("clarify.respond", "answer"),
("terminal.read.respond", "text"),
],
)
def test_late_prompt_response_is_idempotent(server, method, value_key):
"""All four blocking bridges tolerate a late reply after their request has
expired — the `*.respond` returns a graceful `{"status": "expired"}` instead
of the raw 4009 protocol error a client would otherwise surface verbatim."""
response = server.handle_request(
{
"id": "late-response",
"method": method,
"params": {"request_id": "expired-request", value_key: ""},
}
)
assert response["result"] == {"status": "expired"}
# ── clarify batch (multi-question) bridge ────────────────────────────
def _drain_batch_block(server, qids, timeout=5, payload=None):
"""Run a batch _block on a worker thread and return (thread, result box,
emitted request payload). The caller resolves questions via
handle_request and then joins."""
box = {}
def run():
box["answer"] = server._block(
"clarify.request",
"s1",
dict(payload or {"questions": [{"qid": q, "question": q} for q in qids]}),
timeout=timeout,
batch_qids=list(qids),
)
thread = threading.Thread(target=run, daemon=True)
thread.start()
# Wait for the request to be registered so respond calls can find it.
deadline = time.monotonic() + 2
while time.monotonic() < deadline:
with server._prompt_lock:
if server._batch_clarify:
rid = next(iter(server._batch_clarify))
return thread, box, rid
time.sleep(0.01)
raise AssertionError("batch clarify request never registered")
def test_clarify_batch_resolves_when_all_questions_locked(capture):
server, buf = capture
thread, box, rid = _drain_batch_block(server, ["q0", "q1"])
first = server.handle_request({
"id": "a1", "method": "clarify.respond",
"params": {"request_id": rid, "question_id": "q1", "answer": "beta"},
})
assert first["result"]["status"] == "ok"
assert first["result"]["remaining"] == ["q0"]
assert thread.is_alive() # one question left — still blocking
second = server.handle_request({
"id": "a2", "method": "clarify.respond",
"params": {"request_id": rid, "question_id": "q0", "answer": "alpha"},
})
assert second["result"]["status"] == "ok"
assert second["result"]["remaining"] == []
thread.join(timeout=5)
assert not thread.is_alive()
assert json.loads(box["answer"]) == {"answers": {"q0": "alpha", "q1": "beta"}}
def test_clarify_batch_answer_update_overwrites_before_completion(server):
thread, box, rid = _drain_batch_block(server, ["q0", "q1"])
server.handle_request({
"id": "a1", "method": "clarify.respond",
"params": {"request_id": rid, "question_id": "q0", "answer": "first"},
})
server.handle_request({
"id": "a2", "method": "clarify.respond",
"params": {"request_id": rid, "question_id": "q0", "answer": "changed"},
})
server.handle_request({
"id": "a3", "method": "clarify.respond",
"params": {"request_id": rid, "question_id": "q1", "answer": "done"},
})
thread.join(timeout=5)
assert json.loads(box["answer"])["answers"]["q0"] == "changed"
def test_clarify_batch_empty_answer_is_a_locked_skip(server):
"""Skipping one question locks an empty answer — it counts toward
completion instead of leaving the batch waiting."""
thread, box, rid = _drain_batch_block(server, ["q0", "q1"])
server.handle_request({
"id": "a1", "method": "clarify.respond",
"params": {"request_id": rid, "question_id": "q0", "answer": ""},
})
server.handle_request({
"id": "a2", "method": "clarify.respond",
"params": {"request_id": rid, "question_id": "q1", "answer": "kept"},
})
thread.join(timeout=5)
assert json.loads(box["answer"]) == {"answers": {"q0": "", "q1": "kept"}}
def test_clarify_batch_unknown_question_id_rejected(server):
thread, box, rid = _drain_batch_block(server, ["q0"])
response = server.handle_request({
"id": "bad", "method": "clarify.respond",
"params": {"request_id": rid, "question_id": "q9", "answer": "x"},
})
assert response["error"]["code"] == 4002
server.handle_request({
"id": "ok", "method": "clarify.respond",
"params": {"request_id": rid, "question_id": "q0", "answer": "fine"},
})
thread.join(timeout=5)
def test_clarify_batch_timeout_keeps_locked_answers(capture):
"""Locked answers survive the deadline: the tool sees the partials plus
timed_out instead of an empty string."""
server, buf = capture
thread, box, rid = _drain_batch_block(server, ["q0", "q1"], timeout=1)
server.handle_request({
"id": "a1", "method": "clarify.respond",
"params": {"request_id": rid, "question_id": "q0", "answer": "kept"},
})
thread.join(timeout=10)
assert not thread.is_alive()
result = json.loads(box["answer"])
assert result == {"answers": {"q0": "kept"}, "timed_out": True}
# The expire notification still fires for the un-finished batch.
messages = [json.loads(line) for line in buf.getvalue().splitlines()]
assert any(m["params"]["type"] == "clarify.expire" for m in messages)
def test_clarify_batch_cancel_all_returns_empty(server):
"""A respond without question_id cancels the whole batch (Esc path)."""
thread, box, rid = _drain_batch_block(server, ["q0", "q1"])
server.handle_request({
"id": "cancel", "method": "clarify.respond",
"params": {"request_id": rid, "answer": ""},
})
thread.join(timeout=5)
assert box["answer"] == ""
def test_clarify_batch_late_question_respond_is_idempotent(server):
response = server.handle_request({
"id": "late", "method": "clarify.respond",
"params": {"request_id": "gone", "question_id": "q0", "answer": "x"},
})
assert response["result"] == {"status": "expired"}
def test_clarify_batch_state_cleared_after_resolution(server):
thread, box, rid = _drain_batch_block(server, ["q0"])
server.handle_request({
"id": "a", "method": "clarify.respond",
"params": {"request_id": rid, "question_id": "q0", "answer": "x"},
})
thread.join(timeout=5)
with server._prompt_lock:
assert rid not in server._batch_clarify
assert rid not in server._pending
def test_clarify_block_helper_builds_batch_payload(capture):
"""_clarify_block forwards only wire fields (qid/question/choices/
multi_select) — the tool-side normalized entries carry extra keys the
renderer must not see."""
server, buf = capture
normalized = [
{
"qid": "q0", "id": "approach", "question": "Which?",
"choices": ["a (Recommended)", "b"], "choices_offered": ["a", "b"],
"multi_select": False,
},
]
box = {}
def run():
box["answer"] = server._clarify_block("s1", "", None, questions=normalized)
thread = threading.Thread(target=run, daemon=True)
thread.start()
deadline = time.monotonic() + 2
rid = None
while time.monotonic() < deadline and rid is None:
with server._prompt_lock:
rid = next(iter(server._batch_clarify), None)
time.sleep(0.01)
assert rid
server.handle_request({
"id": "a", "method": "clarify.respond",
"params": {"request_id": rid, "question_id": "q0", "answer": "a"},
})
thread.join(timeout=5)
messages = [json.loads(line) for line in buf.getvalue().splitlines()]
request = messages[0]["params"]
assert request["type"] == "clarify.request"
sent = request["payload"]["questions"][0]
assert set(sent) == {"qid", "question", "choices", "multi_select"}
assert "id" not in sent and "choices_offered" not in sent
def test_approval_pending_replays_unresolved_requests(server, monkeypatch):
from tools import approval
server._sessions["ui-1"] = {"session_key": "agent-1", "history": []}
pending = [{"request_id": "req-1", "command": "danger"}]
monkeypatch.setattr(approval, "list_gateway_approvals", lambda key: pending if key == "agent-1" else [])
response = server.handle_request(
{"id": "r1", "method": "approval.pending", "params": {"session_id": "ui-1"}}
)
assert response["result"] == {"approvals": pending}
def test_approval_received_acknowledges_exact_request(server, monkeypatch):
from tools import approval
server._sessions["ui-1"] = {"session_key": "agent-1", "history": []}
calls = []
monkeypatch.setattr(
approval,
"ack_gateway_approval",
lambda key, request_id: calls.append((key, request_id)) or True,
)
response = server.handle_request(
{
"id": "r2",
"method": "approval.received",
"params": {"session_id": "ui-1", "request_id": "req-1"},
}
)
assert response["result"] == {"acknowledged": True}
assert calls == [("agent-1", "req-1")]
def test_approval_response_correlates_request_id(server, monkeypatch):
from tools import approval
server._sessions["ui-1"] = {"session_key": "agent-1", "history": []}
calls = []
monkeypatch.setattr(
approval,
"resolve_gateway_approval",
lambda key, choice, **kwargs: calls.append((key, choice, kwargs)) or 1,
)
response = server.handle_request(
{
"id": "r3",
"method": "approval.respond",
"params": {"session_id": "ui-1", "request_id": "req-1", "choice": "once"},
}
)
assert response["result"] == {"resolved": 1}
assert calls == [("agent-1", "once", {"resolve_all": False, "request_id": "req-1"})]
def test_approval_respond_falls_back_to_request_id_lookup(server, monkeypatch):
"""A stale live sid must not 4001 an approval answer when the request_id
resolves to a live session (durable-identity fallback, #91684)."""
from tools import approval
live = {"session_key": "agent-live", "history": []}
server._sessions["ui-live"] = live
calls = []
monkeypatch.setattr(
approval,
"list_gateway_approvals",
lambda key: [{"request_id": "req-91684"}] if key == "agent-live" else [],
)
monkeypatch.setattr(
approval,
"resolve_gateway_approval",
lambda key, choice, **kwargs: calls.append((key, choice, kwargs)) or 1,
)
response = server.handle_request(
{
"id": "r-fallback",
"method": "approval.respond",
"params": {
"session_id": "gone-sid",
"request_id": "req-91684",
"choice": "once",
},
}
)
assert response["result"] == {"resolved": 1}
assert calls == [
("agent-live", "once", {"resolve_all": False, "request_id": "req-91684"})
]
def test_approval_respond_falls_back_to_stored_session_id(server, monkeypatch):
"""session_id holding a STORED id maps to the live runtime record."""
from tools import approval
live = {"session_key": "stored-91684", "history": []}
server._sessions["ui-stored"] = live
calls = []
monkeypatch.setattr(approval, "list_gateway_approvals", lambda key: [])
monkeypatch.setattr(
approval,
"resolve_gateway_approval",
lambda key, choice, **kwargs: calls.append((key, choice, kwargs)) or 1,
)
response = server.handle_request(
{
"id": "r-stored",
"method": "approval.respond",
"params": {"session_id": "stored-91684", "choice": "deny"},
}
)
assert response["result"] == {"resolved": 1}
assert calls == [
("stored-91684", "deny", {"resolve_all": False, "request_id": None})
]
def test_approval_respond_4001_when_nothing_resolves(server, monkeypatch):
from tools import approval
monkeypatch.setattr(approval, "list_gateway_approvals", lambda key: [])
response = server.handle_request(
{
"id": "r-nope",
"method": "approval.respond",
"params": {"session_id": "nope", "request_id": "req-x", "choice": "once"},
}
)
assert response["error"]["code"] == 4001
def test_clear_pending(server):
ev = threading.Event()
# _pending values are (sid, Event) tuples
server._pending["r1"] = ("sid-x", ev)
server._clear_pending()
assert ev.is_set()
assert server._answers["r1"] == ""
# ── Session lookup ───────────────────────────────────────────────────
def test_sess_missing(server):
_, err = server._sess({"session_id": "nope"}, "r1")
assert err["error"]["code"] == 4001
# ── session.resume payload ────────────────────────────────────────────
def test_session_resume_returns_hydrated_messages(server, monkeypatch):
class _DB:
def get_session(self, _sid):
return {"id": "20260409_010101_abc123"}
def get_session_by_title(self, _title):
return None
def reopen_session(self, _sid):
return None
def get_resume_conversations(self, session_id):
return (
self.get_messages_as_conversation(session_id, repair_alternation=True),
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
def get_ancestor_display_prefix(self, _sid):
return []
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
return [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "yo", "reasoning": "thoughts"},
{"role": "tool", "content": "searched"},
{"role": "assistant", "content": " "},
{"role": "assistant", "content": None},
{"role": "narrator", "content": "skip"},
]
monkeypatch.setattr(server, "_get_db", lambda: _DB())
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None, session_db=None, **_kwargs: object())
monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80, **_kwargs: None)
monkeypatch.setattr(server, "_session_info", lambda _agent, _session=None: {"model": "test/model"})
resp = server.handle_request(
{
"id": "r1",
"method": "session.resume",
# eager_build: exercise the synchronous build path (this test
# monkeypatches _make_agent/_init_session/_session_info).
"params": {"session_id": "20260409_010101_abc123", "cols": 100, "eager_build": True},
}
)
assert "error" not in resp
assert resp["result"]["message_count"] == 3
assert resp["result"]["messages"] == [
{"role": "user", "text": "hello"},
{"role": "assistant", "text": "yo", "reasoning": "thoughts"},
{"role": "tool", "name": "tool", "context": ""},
]
def test_session_resume_rejects_runaway_transcript_before_history_load(
server, monkeypatch
):
class _DB:
def get_session(self, sid):
return {"id": sid, "message_count": 20_001}
def get_session_by_title(self, _title):
return None
def resolve_resume_session_id(self, sid):
return sid
def reopen_session(self, _sid):
raise AssertionError("oversized session must be rejected before reopen")
monkeypatch.setattr(server, "_get_db", lambda: _DB())
response = server.handle_request(
{
"id": "r1",
"method": "session.resume",
"params": {
"session_id": "runaway-session",
"omit_messages": True,
},
}
)
assert response["error"]["code"] == 4130
assert "safe resume limit is 20000" in response["error"]["message"]
def test_session_resume_deferred_and_omitted_paths_guard_the_tip_only(server, monkeypatch):
"""A deep compression lineage behind a small tip must open on Desktop.
Desktop's cold resume sends ``defer_history`` + ``omit_messages`` and pages
the transcript over REST, so the process only ever holds the tip segment.
Counting the whole lineage there returned 4130 for the healthiest sessions
(85 compaction segments / ~29k rows / ~700-row tip: Bot Chat stuck on
"Waking up…"). The guard must count what each path loads.
"""
calls = []
class _DB:
def get_session(self, sid):
return {"id": sid, "message_count": 28_730}
def get_session_by_title(self, _title):
return None
def resolve_resume_session_id(self, sid):
return sid
def assert_resume_safe(self, sid, max_messages=None, *, tip_only=False):
calls.append(tip_only)
if not tip_only:
from hermes_state import SessionResumeTooLargeError
raise SessionResumeTooLargeError(20_001, 20_000)
return 666
def reopen_session(self, _sid):
raise RuntimeError("stop before history load")
monkeypatch.setattr(server, "_get_db", lambda: _DB())
monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
for params in (
{"defer_history": True, "omit_messages": True, "source": "desktop"},
{"omit_messages": True},
{"lazy": True},
):
calls.clear()
response = server.handle_request(
{
"id": "r-tip",
"method": "session.resume",
"params": {"session_id": "deep-lineage", **params},
}
)
err = response.get("error") or {}
assert err.get("code") != 4130, params
assert calls == [True], params
# The non-deferred, non-omitted resume materializes the full lineage in
# memory, so it keeps the lineage-wide bound.
calls.clear()
response = server.handle_request(
{"id": "r-full", "method": "session.resume", "params": {"session_id": "deep-lineage"}}
)
assert response["error"]["code"] == 4130
assert calls == [False]
def test_deferred_hydration_falls_back_to_tip_when_lineage_exceeds_limit(server, monkeypatch):
"""The hydration worker never loads a lineage the guard would refuse."""
import threading
from hermes_state import SessionResumeTooLargeError
tip = [{"role": "user", "content": "tip"}]
reads = []
class _DB:
def reopen_session(self, _sid):
return True
def assert_resume_safe(self, sid, max_messages=None, *, tip_only=False):
if not tip_only:
raise SessionResumeTooLargeError(20_001, 20_000)
return 1
def get_resume_conversations(self, _sid):
reads.append("lineage")
raise AssertionError("must not materialize the runaway lineage")
def get_ancestor_display_prefix(self, _sid):
reads.append("prefix")
raise AssertionError("must not materialize the runaway lineage")
def get_messages_as_conversation(self, sid, **kwargs):
reads.append(("tip", kwargs.get("repair_alternation")))
return list(tip)
built = threading.Event()
monkeypatch.setattr(server, "_start_agent_build", lambda _sid, _session: built.set())
monkeypatch.setattr(server, "_maybe_schedule_auto_continue", lambda *_a, **_k: None)
session = server._deferred_session_record(
"deep-lineage", cols=80, cwd="/tmp", history=[], lease=None
)
session["resume_history_ready"] = threading.Event()
session["resume_hydrating"] = True
session["resume_message_count"] = 28_730
server._sessions["hyd"] = session
try:
server._schedule_resume_hydration("hyd", "deep-lineage", _DB())
assert session["resume_history_ready"].wait(timeout=5)
assert built.wait(timeout=5)
assert session.get("resume_history_error") is None
assert session["history"] == tip
assert session["display_history_prefix"] == []
assert session["resume_message_count"] == 1
assert reads == [("tip", True)]
finally:
server._sessions.pop("hyd", None)
def test_session_resume_guard_failure_fails_open(server, monkeypatch):
"""A transient guard error must not block resume (fail open, log only)."""
reopened = []
class _DB:
def get_session(self, sid):
return {"id": sid}
def get_session_by_title(self, _title):
return None
def resolve_resume_session_id(self, sid):
return sid
def assert_resume_safe(self, _sid):
raise RuntimeError("database is locked")
def reopen_session(self, sid):
reopened.append(sid)
return True
monkeypatch.setattr(server, "_get_db", lambda: _DB())
response = server.handle_request(
{
"id": "r-open",
"method": "session.resume",
"params": {
"session_id": "transient-guard-session",
"omit_messages": True,
},
}
)
# The guard must not block: no 4130, and any downstream failure must not
# be the guard's own "resume safety check failed" error. Reopen being
# attempted proves execution moved past the guard.
err = response.get("error") or {}
assert err.get("code") != 4130
assert "resume safety check failed" not in str(err.get("message", ""))
assert reopened == ["transient-guard-session"]
def test_session_resume_active_turn_payload_matches_desktop_fixture(server, monkeypatch):
"""A live resume serializes the exact timer payload consumed by Desktop."""
fixture = json.loads(
(Path(__file__).parents[1] / "fixtures" / "session-resume-active-turn.json").read_text(
encoding="utf-8"
)
)
class _DB:
def get_session(self, session_id):
return {"id": session_id}
def get_session_by_title(self, _title):
return None
def resolve_resume_session_id(self, session_id):
return session_id
active_turn = {
"assistant": "partial answer",
"started_at": fixture["turn_started_at"],
"streaming": True,
"user": "current prompt",
}
server._sessions[fixture["session_id"]] = {
"agent": types.SimpleNamespace(session_id=fixture["session_key"]),
"created_at": fixture["started_at"],
"history": [{"content": "earlier prompt", "role": "user"}],
"history_lock": threading.Lock(),
"inflight_turn": active_turn,
"running": True,
"session_key": fixture["session_key"],
}
monkeypatch.setattr(server, "_get_db", lambda: _DB())
monkeypatch.setattr(server, "_session_info", lambda _agent, _session=None: fixture["info"])
# JSON round-trip the real RPC envelope: the desktop fixture must stay
# faithful to what the gateway actually serializes, not a copied shape.
response = json.loads(
json.dumps(
server.handle_request(
{
"id": "resume-running",
"method": "session.resume",
"params": {"session_id": fixture["session_key"]},
}
)
)
)
result = response["result"]
assert result["running"] is True
assert result["turn_started_at"] == active_turn["started_at"]
assert result == fixture
def test_enforce_session_cap_evicts_oldest_detached_only(server, monkeypatch):
"""The LRU cap frees the least-recently-active DETACHED sessions when over
the limit, and never a live-transport / running / mid-build one."""
monkeypatch.setattr(server, "_load_cfg", lambda: {"max_live_sessions": 2})
evicted: list[str] = []
monkeypatch.setattr(
server,
"_close_session_by_id",
lambda sid, end_reason=None, predicate=None: evicted.append(sid),
)
def _ready() -> threading.Event:
ev = threading.Event()
ev.set()
return ev
detached = server._detached_ws_transport
live = object() # no _closed attr -> live transport, never evictable
server._sessions.clear()
server._sessions.update(
{
"old_detached": {"transport": detached, "last_active": 100.0, "agent_ready": _ready()},
"new_detached": {"transport": detached, "last_active": 300.0, "agent_ready": _ready()},
"running_detached": {
"transport": detached,
"last_active": 50.0,
"running": True,
"agent_ready": _ready(),
},
"focused_live": {"transport": live, "last_active": 200.0, "agent_ready": _ready()},
}
)
server._enforce_session_cap()
# 4 sessions, cap 2 -> evict 2. Only detached+idle+built are eligible, oldest
# first; the running one and the live-transport one are exempt.
assert evicted == ["old_detached", "new_detached"]
@pytest.mark.parametrize("closed_transport", [False, True])
def test_idle_reaper_rearms_missing_ws_orphan_timer(server, monkeypatch, tmp_path, closed_transport):
"""A detached lane cannot keep its lease forever if initial timer setup was lost."""
from hermes_cli.active_sessions import (
active_session_registry_snapshot,
try_acquire_active_session,
)
home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(home))
sid = "detached-without-reaper"
sibling_sid = "live-sibling"
orphan_lease, message = try_acquire_active_session(
session_id=sid,
surface="desktop",
config={},
registry_home=home,
track_liveness=True,
)
assert orphan_lease is not None and message is None
sibling_lease, message = try_acquire_active_session(
session_id=sibling_sid,
surface="desktop",
config={},
registry_home=home,
track_liveness=True,
)
assert sibling_lease is not None and message is None
def _session(session_key, lease, transport):
return {
"active_session_lease": lease,
"created_at": time.time(),
"history": [],
"history_lock": threading.Lock(),
"last_active": time.time(),
"session_key": session_key,
"source": "tui",
"transport": transport,
}
server._sessions.clear()
class ClosedTransport:
_closed = True
dead_transport = ClosedTransport() if closed_transport else server._detached_ws_transport
server._sessions.update({
sid: _session(sid, orphan_lease, dead_transport),
sibling_sid: _session(sibling_sid, sibling_lease, object()),
})
server._pending_ws_reaps.clear()
monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.05)
monkeypatch.setattr(server, "_SESSION_TTL_S", 3600.0)
monkeypatch.setattr(server, "_flush_dirty_sessions", lambda: 0)
monkeypatch.setattr(server, "_enforce_session_cap", lambda: None)
server._reap_idle_sessions()
deadline = time.monotonic() + 2.0
while (sid in server._sessions or not orphan_lease.released) and time.monotonic() < deadline:
time.sleep(0.01)
assert sid not in server._sessions
assert orphan_lease.released is True
assert sibling_sid in server._sessions
assert [entry["session_id"] for entry in active_session_registry_snapshot(home)] == [sibling_sid]
repo_root = Path(__file__).resolve().parents[2]
env = os.environ.copy()
env["HERMES_HOME"] = str(home)
env["PYTHONPATH"] = os.pathsep.join(
part for part in (str(repo_root), env.get("PYTHONPATH", "")) if part
)
successor = subprocess.run(
[
sys.executable,
"-c",
(
"from hermes_cli.active_sessions import try_acquire_active_session; "
f"lease, refusal = try_acquire_active_session(session_id={sid!r}, surface='desktop', "
"config={}, track_liveness=True); "
"assert lease is not None and refusal is None, refusal; lease.release()"
),
],
cwd=repo_root,
env=env,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=30,
)
assert successor.returncode == 0, successor.stderr
assert [entry["session_id"] for entry in active_session_registry_snapshot(home)] == [sibling_sid]
def test_sync_session_key_after_compress_reanchors_active_session_lease(
server, monkeypatch, tmp_path
):
home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(home))
from hermes_cli.active_sessions import (
active_session_registry_snapshot,
try_acquire_active_session,
)
lease, message = try_acquire_active_session(
session_id="session-old",
surface="tui",
config={"max_concurrent_sessions": 1},
metadata={"live_session_id": "ui-1"},
)
assert message is None
assert lease is not None
session = {
"active_session_lease": lease,
"agent": types.SimpleNamespace(session_id="session-new"),
"session_key": "session-old",
}
fake_approval = types.SimpleNamespace(
disable_session_yolo=lambda *_args, **_kwargs: None,
enable_session_yolo=lambda *_args, **_kwargs: None,
is_session_yolo_enabled=lambda *_args, **_kwargs: False,
register_gateway_notify=lambda *_args, **_kwargs: None,
unregister_gateway_notify=lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(server, "_restart_slash_worker", lambda *_args, **_kwargs: None)
with patch.dict(sys.modules, {"tools.approval": fake_approval}):
server._sync_session_key_after_compress("ui-1", session)
snapshot = active_session_registry_snapshot()
assert session["session_key"] == "session-new"
assert lease.session_id == "session-new"
assert [entry["session_id"] for entry in snapshot] == ["session-new"]
lease.release()
def test_make_agent_accepts_list_system_prompt(server, monkeypatch):
captured = {}
class _Agent:
def __init__(self, **kwargs):
captured.update(kwargs)
self.model = kwargs.get("model", "")
monkeypatch.setitem(sys.modules, "run_agent", types.SimpleNamespace(AIAgent=_Agent))
monkeypatch.setitem(
sys.modules,
"hermes_cli.runtime_provider",
types.SimpleNamespace(
resolve_runtime_provider=lambda **_kwargs: {
"provider": "test",
"base_url": None,
"api_key": None,
"api_mode": None,
}
),
)
monkeypatch.setattr(server, "_load_cfg", lambda: {"agent": {"system_prompt": ["one", "two"]}})
monkeypatch.setattr(server, "_resolve_startup_runtime", lambda: ("test/model", "test"))
monkeypatch.setattr(server, "_get_db", lambda: None)
server._make_agent("sid", "session-key", session_id="session-key")
assert captured["ephemeral_system_prompt"] == "one\ntwo"
# ── Config I/O ───────────────────────────────────────────────────────
def test_config_roundtrip(server, tmp_path):
server._hermes_home = tmp_path
server._save_cfg({"model": "test/model"})
assert server._load_cfg()["model"] == "test/model"
# ── _cli_exec_blocked ────────────────────────────────────────────────
@pytest.mark.parametrize("argv", [
[],
["setup"],
["gateway"],
["sessions", "browse"],
["config", "edit"],
])
def test_cli_exec_blocked(server, argv):
assert server._cli_exec_blocked(argv) is not None
# ── slash.exec skill command interception ────────────────────────────
def test_slash_exec_rejects_skill_commands(server):
"""slash.exec must reject skill commands so the TUI falls through to command.dispatch."""
# Register a mock session
sid = "test-session"
server._sessions[sid] = {"session_key": sid, "agent": None}
# Mock scan_skill_commands to return a known skill
fake_skills = {"/hermes-agent-dev": {"name": "hermes-agent-dev", "description": "Dev workflow"}}
with patch("agent.skill_commands.get_skill_commands", return_value=fake_skills):
resp = server.handle_request({
"id": "r1",
"method": "slash.exec",
"params": {"command": "hermes-agent-dev", "session_id": sid},
})
# Should return an error so the TUI's .catch() fires command.dispatch
assert "error" in resp
assert resp["error"]["code"] == 4018
assert "skill command" in resp["error"]["message"]
def test_slash_exec_scopes_skill_lookup_to_session_profile(server, tmp_path):
"""slash.exec must resolve get_skill_commands() against the session's own
profile_home rather than the gateway process's ambient HERMES_HOME
(#88023). A Desktop session that switches profiles mid-session shares
the same gateway process, so a skill declared only under the new
profile's skills.external_dirs must still be recognized here — else the
command falls through to the slash-worker dead path instead of routing
to command.dispatch.
"""
import agent.skill_commands as sc_mod
empty_local_dir = tmp_path / "no-local-skills"
empty_local_dir.mkdir()
profile_b = tmp_path / "profile_b"
external_b = tmp_path / "external_b"
profile_b.mkdir()
skill_dir = external_b / "b-only"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: b-only\ndescription: Only in profile b.\n---\n\n# b-only\n\nDo the thing.\n"
)
(profile_b / "config.yaml").write_text(
f"skills:\n external_dirs:\n - {external_b}\n"
)
sid = "test-session-profile-b"
server._sessions[sid] = {
"session_key": sid,
"agent": None,
"profile_home": str(profile_b),
}
with (
patch("tools.skills_tool.SKILLS_DIR", empty_local_dir),
patch.object(sc_mod, "_skill_commands", {}),
patch.object(sc_mod, "_skill_commands_platform", None),
patch.object(sc_mod, "_skill_commands_home", None),
):
resp = server.handle_request({
"id": "r1",
"method": "slash.exec",
"params": {"command": "b-only", "session_id": sid},
})
# The gateway's own HERMES_HOME (the test-isolation tempdir, no
# skills.external_dirs) has no "b-only" skill — the only way this
# resolves is by scoping the lookup to the session's profile_home.
assert "error" in resp
assert resp["error"]["code"] == 4018
assert "skill command" in resp["error"]["message"]
def test_command_dispatch_queue_sends_message(server):
"""command.dispatch /queue returns {type: 'send', message: ...} for the TUI."""
sid = "test-session"
server._sessions[sid] = {"session_key": sid}
resp = server.handle_request({
"id": "r1",
"method": "command.dispatch",
"params": {"name": "queue", "arg": "tell me about quantum computing", "session_id": sid},
})
assert "error" not in resp
result = resp["result"]
assert result["type"] == "send"
assert result["message"] == "tell me about quantum computing"
def test_skills_manage_search_uses_tools_hub_sources(server):
result = type("Result", (), {
"description": "Build better terminal demos",
"name": "showroom",
})()
auth = MagicMock(return_value="auth")
router = MagicMock(return_value=["source"])
search = MagicMock(return_value=[result])
fake_search = types.SimpleNamespace(create_source_router=router, unified_search=search)
fake_github = types.SimpleNamespace(GitHubAuth=auth)
with patch.dict(sys.modules, {"tools.skills_hub_search": fake_search, "tools.skills_hub_github": fake_github}):
resp = server.handle_request({
"id": "skills-search",
"method": "skills.manage",
"params": {"action": "search", "query": "showroom"},
})
assert "error" not in resp
assert resp["result"] == {
"results": [{"description": "Build better terminal demos", "name": "showroom"}]
}
auth.assert_called_once_with()
router.assert_called_once_with("auth")
search.assert_called_once_with("showroom", ["source"], source_filter="all", limit=20)
# ── dispatch(): pool routing for long handlers (#12546) ──────────────
def test_dispatch_runs_short_handlers_inline(server):
"""Non-long handlers return their response synchronously from dispatch()."""
server._methods["fast.ping"] = lambda rid, params: server._ok(rid, {"pong": True})
resp = server.dispatch({"id": "r1", "method": "fast.ping", "params": {}})
assert resp == {"jsonrpc": "2.0", "id": "r1", "result": {"pong": True}}
@pytest.mark.parametrize("completion_method", ["complete.path", "complete.slash"])
def test_completion_handlers_are_pool_routed(completion_method, server):
"""complete.path/complete.slash must run on the pool, never the reader thread.
Regression for #21123: completion ran inline, so a slow git ls-files /
skill-scan blocked prompt.submit and froze the TUI for the 120s RPC timeout.
"""
assert completion_method in server._LONG_HANDLERS
@pytest.mark.parametrize(
"voice_or_wake_method",
["voice.toggle", "voice.record", "voice.tts", "wake.start", "wake.status"],
)
def test_voice_and_wake_handlers_are_pool_routed(voice_or_wake_method, server):
"""Voice and wake RPCs must run on the pool, never the WS reader thread.
Regression: voice.toggle (status) triggers check_voice_requirements() →
STT provider auto-detect → a SYNCHRONOUS faster-whisper lazy install (uv/pip
subprocess, up to a 300s timeout). Inline on the WS reader loop it blocked
prompt.submit / session.list frames queued behind it — the desktop showed
sent messages that never reached the agent. Same bug class as #21123 /
#50005: anything that can stall for seconds must stay off the reader thread.
wake.start and wake.status share the same STT lazy-install path via
check_wake_word_requirements() → _stt_ready() → _get_provider(), and
wake.start additionally calls lazy_deps.ensure() for wake-word engine deps.
The desktop polls wake.status on every gateway-ready.
"""
assert voice_or_wake_method in server._LONG_HANDLERS
def test_skin_live_switch_end_to_end(server, tmp_path, monkeypatch):
"""Real config + skin files: activating a skin (as `hermes config set` does)
makes the per-tool reconcile broadcast skin.changed with the resolved palette.
Exercises _load_cfg → _skin_sig → resolve_skin → _emit with no mocks in between."""
import hermes_cli.skin_engine as skin_engine
(tmp_path / "skins").mkdir()
(tmp_path / "skins" / "midnight.yaml").write_text(
"name: midnight\ndescription: t\ncolors:\n banner_title: '#00ffcc'\n background: '#001010'\n"
)
monkeypatch.setattr(skin_engine, "get_hermes_home", lambda: tmp_path)
monkeypatch.setattr(server, "_hermes_home", tmp_path)
monkeypatch.setattr(server, "_last_skin_sig", None, raising=False)
server._cfg_cache = server._cfg_mtime = server._cfg_path = None
emitted = []
monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append((ev, payload)))
# Baseline (default) — seeds the signature.
(tmp_path / "config.yaml").write_text("display:\n skin: default\n", encoding="utf-8")
server._broadcast_skin_if_changed()
emitted.clear()
# Activate midnight, as `hermes config set display.skin midnight` would.
time.sleep(0.01) # ensure the config mtime moves
(tmp_path / "config.yaml").write_text("display:\n skin: midnight\n", encoding="utf-8")
server._broadcast_skin_if_changed()
assert [ev for ev, _ in emitted] == ["skin.changed"]
assert emitted[0][1]["name"] == "midnight"
assert emitted[0][1]["colors"]["banner_title"] == "#00ffcc"
def test_broadcast_skin_if_changed_on_any_signature_move(server, monkeypatch):
"""A skin the agent changes mid-turn goes live once per real move: a name
switch (incl. switch-then-revert) OR an in-place color edit to the active skin
(same name, new file mtime). An unchanged signature never re-broadcasts."""
emitted = []
# switch, no-op, switch, then a color edit (same name, bumped mtime).
sigs = iter([("neon", 1.0), ("neon", 1.0), ("forest", 1.0), ("forest", 2.0)])
monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append((ev, payload)))
monkeypatch.setattr(server, "_last_skin_sig", None, raising=False)
monkeypatch.setattr(server, "_skin_sig", lambda: next(sigs))
monkeypatch.setattr(server, "resolve_skin", lambda: {"name": "x", "colors": {}})
for _ in range(4):
server._broadcast_skin_if_changed()
assert [ev for ev, _ in emitted] == ["skin.changed"] * 3
# ── global-event broadcast (session-less events reach every WS client) ──
class _RecordingTransport:
"""Minimal Transport stand-in that records the frames written to it."""
def __init__(self) -> None:
self.frames: list[dict] = []
def write(self, obj: dict) -> bool:
self.frames.append(obj)
return True
def close(self) -> None:
pass
def test_unregister_live_transport_stops_delivery(capture):
"""A disconnected peer (unregistered in the ws finally block) receives nothing
— and a stale write is never attempted against its closed socket."""
server, buf = capture
a = _RecordingTransport()
server.register_live_transport(a)
server.unregister_live_transport(a)
server._broadcast_global_event("skin.changed", {"name": "x"})
assert a.frames == []
# No live transports left → fell back to stdio.
assert json.loads(buf.getvalue())["params"]["type"] == "skin.changed"