fix(tui): responsive /compress with live progress + CLI-parity feedback (#17661)
* fix(tui): offload manual compaction RPC Route TUI session compression through the existing long-handler pool so slow compaction does not block other gateway RPCs. * fix(tui): show compaction progress immediately Print a local status line before the compress RPC starts so slow manual compaction does not look like a no-op. * feat(tui): rich /compress feedback parity with CLI Show pre-compaction message count and rough token estimate immediately, emit a status update so the bottom bar reflects ongoing compaction, and report a multi-line summary (headline + token delta + optional note) using the shared summarize_manual_compression helper. * fix(tui): show live compaction estimate in transcript Mirror compression progress status into the transcript so users see the backend message count and token estimate while /compress is still running. * fix(tui): single live compaction line with spinner glyph Drop the redundant local "compressing context..." placeholder and prefix the live backend status line with a braille spinner glyph so /compress reads as a single in-progress row. * fix(tui): address review nits on /compress feedback Reuse the precomputed token estimate inside _compress_session_history so the gateway does not redo the O(n) work while holding history_lock, keep the status bar pinned during long manual compactions instead of auto-restoring after 4s, and drop the redundant noop bullet that doubled with the system role glyph. * fix(tui): release history_lock during compaction LLM call Move the snapshot/commit pattern into _compress_session_history so the lock is held only across the in-memory bookkeeping, not during agent._compress_context. Also emit a final neutral status update from session.compress so the pinned compressing indicator clears even on errors. * fix(tui): rebuild prompt cleanly + sync session_key after compress Pass system_message=None so AIAgent._compress_context rebuilds the system prompt without nesting the cached identity block. Reuse the handler's pre-snapshotted history inside _compress_session_history to avoid a second O(n) copy under the lock. After compaction, when AIAgent._compress_context rotates session_id, sync the gateway session_key, migrate approval notify + yolo state, restart the slash worker, and clear the stale pending title. Mirrors HermesCLI._manual_compress. * Avoid /compress lock re-entry in slash side effects. Stop pre-locking history before _compress_session_history in slash command mirroring, keep session-key sync parity with manual compression, and add a regression test that asserts /compress is invoked without holding history_lock.
This commit is contained in:
@@ -1566,7 +1566,7 @@ def test_session_compress_uses_compress_helper(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_compress_session_history",
|
||||
lambda session, focus_topic=None: (2, {"total": 42}),
|
||||
lambda session, focus_topic=None, **_kw: (2, {"total": 42}),
|
||||
)
|
||||
monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"})
|
||||
|
||||
@@ -1577,7 +1577,52 @@ def test_session_compress_uses_compress_helper(monkeypatch):
|
||||
|
||||
assert resp["result"]["removed"] == 2
|
||||
assert resp["result"]["usage"]["total"] == 42
|
||||
emit.assert_called_once_with("session.info", "sid", {"model": "x"})
|
||||
emit.assert_any_call("session.info", "sid", {"model": "x"})
|
||||
# Final status.update clears the pinned "compressing" indicator so the
|
||||
# status bar can revert to the neutral state when compaction finishes.
|
||||
emit.assert_any_call(
|
||||
"status.update", "sid", {"kind": "status", "text": "ready"}
|
||||
)
|
||||
|
||||
|
||||
def test_session_compress_syncs_session_key_after_rotation(monkeypatch):
|
||||
"""When AIAgent._compress_context rotates session_id (compression split),
|
||||
the gateway session_key must follow so subsequent approval routing,
|
||||
DB title/history lookups, and slash worker resume target the new
|
||||
continuation session — mirrors HermesCLI._manual_compress's
|
||||
session_id sync (cli.py).
|
||||
"""
|
||||
agent = types.SimpleNamespace(session_id="rotated-id")
|
||||
server._sessions["sid"] = _session(agent=agent)
|
||||
server._sessions["sid"]["session_key"] = "old-key"
|
||||
server._sessions["sid"]["pending_title"] = "stale title"
|
||||
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_compress_session_history",
|
||||
lambda session, focus_topic=None, **_kw: (2, {"total": 42}),
|
||||
)
|
||||
monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"})
|
||||
restart_calls = []
|
||||
monkeypatch.setattr(
|
||||
server, "_restart_slash_worker", lambda s: restart_calls.append(s)
|
||||
)
|
||||
|
||||
try:
|
||||
with patch("tui_gateway.server._emit"):
|
||||
server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.compress",
|
||||
"params": {"session_id": "sid"},
|
||||
}
|
||||
)
|
||||
|
||||
assert server._sessions["sid"]["session_key"] == "rotated-id"
|
||||
assert server._sessions["sid"]["pending_title"] is None
|
||||
assert len(restart_calls) == 1
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_prompt_submit_sets_approval_session_key(monkeypatch):
|
||||
@@ -2423,6 +2468,39 @@ def test_mirror_slash_side_effects_allowed_when_idle(monkeypatch):
|
||||
assert applied["model"]
|
||||
|
||||
|
||||
def test_mirror_slash_compress_does_not_prelock_history(monkeypatch):
|
||||
"""Regression guard: /compress side effect must not hold history_lock
|
||||
when calling _compress_session_history (the helper snapshots under
|
||||
the same non-reentrant lock internally)."""
|
||||
import types
|
||||
|
||||
seen = {"compress": False, "sync": False}
|
||||
emitted = []
|
||||
|
||||
def _fake_compress(session, focus_topic=None, **_kw):
|
||||
seen["compress"] = True
|
||||
assert not session["history_lock"].locked()
|
||||
return (0, {"total": 0})
|
||||
|
||||
def _fake_sync(_sid, _session):
|
||||
seen["sync"] = True
|
||||
|
||||
monkeypatch.setattr(server, "_compress_session_history", _fake_compress)
|
||||
monkeypatch.setattr(server, "_sync_session_key_after_compress", _fake_sync)
|
||||
monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"})
|
||||
monkeypatch.setattr(server, "_emit", lambda *args: emitted.append(args))
|
||||
|
||||
session = _session(running=False)
|
||||
session["agent"] = types.SimpleNamespace(model="x")
|
||||
|
||||
warning = server._mirror_slash_side_effects("sid", session, "/compress")
|
||||
|
||||
assert warning == ""
|
||||
assert seen["compress"]
|
||||
assert seen["sync"]
|
||||
assert ("session.info", "sid", {"model": "x"}) in emitted
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# session.create / session.close race: fast /new churn must not orphan the
|
||||
# slash_worker subprocess or the global approval-notify registration.
|
||||
|
||||
@@ -298,7 +298,7 @@ def test_session_resume_returns_hydrated_messages(server, monkeypatch):
|
||||
def reopen_session(self, _sid):
|
||||
return None
|
||||
|
||||
def get_messages_as_conversation(self, _sid):
|
||||
def get_messages_as_conversation(self, _sid, include_ancestors=False):
|
||||
return [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "yo"},
|
||||
@@ -641,6 +641,29 @@ def test_dispatch_long_handler_does_not_block_fast_handler(server):
|
||||
released.set()
|
||||
|
||||
|
||||
def test_dispatch_session_compress_does_not_block_fast_handler(server):
|
||||
"""Manual TUI compaction can take minutes, so it must not block the RPC loop."""
|
||||
released = threading.Event()
|
||||
|
||||
def slow_compress(rid, params):
|
||||
released.wait(timeout=5)
|
||||
return server._ok(rid, {"done": True})
|
||||
|
||||
server._methods["session.compress"] = slow_compress
|
||||
server._methods["fast.ping"] = lambda rid, params: server._ok(rid, {"pong": True})
|
||||
|
||||
t0 = time.monotonic()
|
||||
assert server.dispatch({"id": "slow", "method": "session.compress", "params": {}}) is None
|
||||
|
||||
fast_resp = server.dispatch({"id": "fast", "method": "fast.ping", "params": {}})
|
||||
fast_elapsed = time.monotonic() - t0
|
||||
|
||||
assert fast_resp["result"] == {"pong": True}
|
||||
assert fast_elapsed < 0.5, f"fast handler blocked for {fast_elapsed:.2f}s behind session.compress"
|
||||
|
||||
released.set()
|
||||
|
||||
|
||||
def test_dispatch_long_handler_exception_produces_error_response(capture):
|
||||
"""An exception inside a pool-dispatched handler still yields a JSON-RPC error."""
|
||||
server, buf = capture
|
||||
|
||||
Reference in New Issue
Block a user