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:
brooklyn!
2026-04-29 18:01:18 -07:00
committed by GitHub
parent 98f5be13fa
commit fc7f55f490
7 changed files with 309 additions and 29 deletions

View File

@@ -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.

View File

@@ -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