a6ee31f55a
* 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>
1513 lines
53 KiB
Python
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"
|