The gateway asked the user questions (approval, clarify, sudo, secret,
vault, MCP setup, the desktop read/act bridges) by emitting a
`<x>.request` EVENT carrying a hand-minted request_id, blocking the
agent thread on a module dict keyed by that id, and exposing a paired
`<x>.respond` METHOD per kind — thirteen pairs, four registries
(`_pending`, `_answers`, `_batch_clarify`, `_EXPIRING_REQUESTS`) and a
per-kind reconnect snapshot (`pending_clarify` / `pending_approval`)
that only two of the thirteen kinds ever got. JSON-RPC already has the
primitive: the server sends a request frame with an id and the client
answers with a response frame bearing the same id.
`tui_gateway/server_requests.py` owns the one mechanism:
send() block the agent thread until the response frame
(`srq-<n>` ids; ints belong to the client)
send_async() fire-and-callback variant (bot relay)
cancel*() withdraw with ONE `request.cancel {id, method, reason}`
event (timeout / interrupt / process exit /
answered elsewhere) instead of per-kind *.expire
open_requests() the still-open frames, replayed by session.resume,
session.activate and session.events.since so a
reconnecting client re-renders every kind, not two
clarify.lock stays a real client→server RPC (locks one batch
answer early); locked answers merge into the final
set even when the closing response carries only the
tail the user answered last
A client that does not implement a method answers -32601 and the agent
fails fast (the old fixed-timeout "unavailable" probes for tour/preview
still work — a wire error IS an answer). Approval: the queue entry's
settle hook withdraws the request when `/approve` from another surface,
a timeout or an interrupt resolves it first, so no window keeps a dead
card. Compute-host children own their waits; the parent mirrors their
open frames for replay and relays `clarify.lock` + response frames.
Clients: `JsonRpcRequestChannel` gains `onRequest` (unhandled → -32601,
dedup by id) and `JsonRpcGatewayClient` re-delivers `open_requests`
from the replay result. Desktop gets `gateway-event/server-requests.ts`
(one handler per method, replacing the request branches of
`input-requests.ts` / `desktop-bridge.ts`) and a `store/server-requests`
registry so every answer site calls `respondToServerRequest(id, result)`
synchronously; the TUI gets `createServerRequestHandler.ts` +
`serverRequestStore.ts`. `gateway-events.json` now pins both halves
(events + server request methods); the two contract tests check both.
Live (real stdio gateway, real `clarify_callback` on the agent thread):
before, `clarify.request` event + `clarify.respond` RPC, batch final
answers lost ('' returned); after, `{"id":"srq-…","method":"clarify"}`
frame, `session.events.since.open_requests` replays it, response frame
`{"answer":"yes"}` reaches the agent, batch lock + final response
merge to `{"q0":"1","q1":"free text"}`.
112 lines
3.6 KiB
Python
112 lines
3.6 KiB
Python
"""Tests for /undo handling in tui_gateway.
|
|
|
|
The TUI routes ``/undo`` through ``command.dispatch`` (it's in
|
|
``_PENDING_INPUT_COMMANDS`` because the CLI handler queues input the
|
|
slash-worker subprocess can't read). The server handles it directly,
|
|
mutates SessionDB to soft-delete rows, refreshes the in-memory session
|
|
history, fires the memory-provider hook with ``rewound=True``, and
|
|
returns ``{"type": "prefill", "message": <text>, "notice": ...}`` so
|
|
the Ink client drops the message into the composer for editing.
|
|
|
|
``/undo N`` backs up N user turns at once (default 1). See issue #21910.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import threading
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from hermes_state import SessionDB
|
|
|
|
|
|
@pytest.fixture()
|
|
def hermes_home(tmp_path, monkeypatch):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
yield home
|
|
|
|
|
|
@pytest.fixture()
|
|
def server(hermes_home):
|
|
# Mocks are scoped to the initial import only (see
|
|
# tests/tui_gateway/test_protocol.py for the rationale).
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"hermes_cli.env_loader": MagicMock(),
|
|
"hermes_cli.banner": MagicMock(),
|
|
},
|
|
):
|
|
mod = importlib.import_module("tui_gateway.server")
|
|
|
|
methods = dict(mod._methods)
|
|
yield mod
|
|
# Restore in place instead of clear+reload: importlib.reload
|
|
# re-registers atexit hooks (duplicate ThreadPoolExecutor shutdowns
|
|
# race the stderr buffer at interpreter exit — same class as PR #34217)
|
|
# and re-captures module-level paths like _hermes_home against this
|
|
# test's soon-deleted tmpdir, breaking later files in the same process.
|
|
mod._methods.clear()
|
|
mod._methods.update(methods)
|
|
mod._sessions.clear()
|
|
__import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests()
|
|
mod._db = None
|
|
|
|
|
|
@pytest.fixture()
|
|
def db(hermes_home):
|
|
return SessionDB(db_path=hermes_home / "state.db")
|
|
|
|
|
|
@pytest.fixture()
|
|
def session_with_history(server, db):
|
|
"""Build a session with 3 user turns + assistant replies persisted in DB."""
|
|
sid = "sid-undo"
|
|
session_key = "tui-undo-1"
|
|
db.create_session(session_key, source="tui")
|
|
for i in range(1, 4):
|
|
db.append_message(session_key, "user", f"question {i}")
|
|
db.append_message(session_key, "assistant", f"answer {i}")
|
|
history = db.get_messages_as_conversation(session_key)
|
|
agent = MagicMock()
|
|
agent._memory_manager = MagicMock()
|
|
agent._last_flushed_db_idx = len(history)
|
|
s = {
|
|
"session_key": session_key,
|
|
"history": list(history),
|
|
"history_lock": threading.Lock(),
|
|
"history_version": 0,
|
|
"running": False,
|
|
"agent": agent,
|
|
"attached_images": [],
|
|
"cols": 120,
|
|
}
|
|
server._sessions[sid] = s
|
|
# Wire the DB cache so _get_db() returns our fixture.
|
|
server._db = db
|
|
return sid, session_key, s, agent
|
|
|
|
|
|
def _call(server, method, **params):
|
|
return server._methods[method](1, params)
|
|
|
|
|
|
def test_undo_returns_prefill_with_target_text(server, session_with_history):
|
|
sid, session_key, s, agent = session_with_history
|
|
resp = _call(server, "command.dispatch", session_id=sid, name="undo", arg="")
|
|
result = resp["result"]
|
|
assert result["type"] == "prefill"
|
|
# Default /undo backs up one user turn — "question 3"
|
|
assert result["message"] == "question 3"
|
|
assert "Undid" in result["notice"]
|
|
assert s["history"]
|
|
assert all("_row_id" in message for message in s["history"])
|
|
|
|
|