feat(tui): integrate with Herdr host

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 783d381710
commit 79d51be71f
17 changed files with 622 additions and 32 deletions
+4 -4
View File
@@ -539,8 +539,8 @@ class WebSocketChannel(BaseChannel):
self._webui_connections.discard(connection)
self._discard_webui_request_lock_if_idle(connection)
async def _maybe_push_active_goal_state(self, chat_id: str) -> None:
"""Replay an active sustained goal from session metadata after *chat_id* is subscribed.
async def _maybe_push_persisted_goal_state(self, chat_id: str) -> None:
"""Replay actionable goal state after *chat_id* is subscribed.
Goal metadata lives on the session JSONL and survives gateway restarts, but
connected clients normally see it via ``goal_state`` / ``turn_end`` frames.
@@ -554,7 +554,7 @@ class WebSocketChannel(BaseChannel):
if not isinstance(meta, dict):
meta = {}
blob = goal_state_ws_blob(cast(dict[str, Any], meta))
if not blob.get("active"):
if not blob.get("active") and blob.get("status") != "blocked":
return
await self.send_goal_state(chat_id, blob)
@@ -572,7 +572,7 @@ class WebSocketChannel(BaseChannel):
async def _hydrate_after_subscribe(self, chat_id: str) -> None:
"""Replay persisted or actively running per-chat state after subscribe."""
await self._maybe_push_active_goal_state(chat_id)
await self._maybe_push_persisted_goal_state(chat_id)
await self._maybe_push_turn_run_wall_clock(chat_id)
async def _send_event(
@@ -3186,7 +3186,7 @@ async def test_maybe_push_active_goal_state_noop_without_session_manager() -> No
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel._maybe_push_active_goal_state("chat-1")
await channel._maybe_push_persisted_goal_state("chat-1")
mock_ws.send.assert_not_called()
@@ -3202,7 +3202,7 @@ async def test_maybe_push_active_goal_state_skips_when_no_goal_on_disk() -> None
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel._maybe_push_active_goal_state("chat-1")
await channel._maybe_push_persisted_goal_state("chat-1")
mock_ws.send.assert_not_called()
@@ -3227,7 +3227,7 @@ async def test_maybe_push_active_goal_state_notifies_when_goal_active_on_disk()
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel._maybe_push_active_goal_state("chat-1")
await channel._maybe_push_persisted_goal_state("chat-1")
mock_ws.send.assert_awaited_once()
body = json.loads(mock_ws.send.await_args.args[0])
assert body["event"] == "goal_state"
@@ -3237,6 +3237,39 @@ async def test_maybe_push_active_goal_state_notifies_when_goal_active_on_disk()
assert body["goal_state"]["ui_summary"] == "Docs"
@pytest.mark.asyncio
async def test_maybe_push_goal_state_restores_blocked_attention_on_disk() -> None:
bus = MagicMock()
sm = MagicMock()
sm.read_session_file.return_value = {
"metadata": {
"goal_state": {
"status": "blocked",
"objective": "deploy safely",
"ui_summary": "Approval required",
},
},
"messages": [],
}
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=sm),
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel._maybe_push_persisted_goal_state("chat-1")
body = json.loads(mock_ws.send.await_args.args[0])
assert body["goal_state"] == {
"active": False,
"status": "blocked",
"ui_summary": "Approval required",
"objective": "deploy safely",
}
@pytest.mark.asyncio
async def test_maybe_push_turn_run_wall_clock_skips_when_no_active_turn() -> None:
bus = MagicMock()
+6 -2
View File
@@ -98,16 +98,20 @@ def goal_state_runtime_lines(metadata: Mapping[str, Any] | None) -> list[str]:
def goal_state_ws_blob(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
"""JSON-safe snapshot for WebSocket ``goal_state`` events (one chat_id per frame)."""
goal = parse_goal_state(_session_goal_raw(metadata)) if metadata else None
if isinstance(goal, dict) and goal.get("status") == "active":
if isinstance(goal, dict) and goal.get("status") in {"active", "blocked"}:
status = str(goal.get("status"))
objective = str(goal.get("objective") or "").strip()
if len(objective) > _MAX_OBJECTIVE_WS:
objective = objective[:_MAX_OBJECTIVE_WS].rstrip() + ""
summary = str(goal.get("ui_summary") or "").strip()[:120]
blob: dict[str, Any] = {"active": True}
blob: dict[str, Any] = {"active": status == "active", "status": status}
if summary:
blob["ui_summary"] = summary
if objective:
blob["objective"] = objective
recap = str(goal.get("recap") or "").strip()[:240]
if recap:
blob["recap"] = recap
return blob
return {"active": False}