feat(tui): add agent interaction workflows
This commit is contained in:
@@ -434,18 +434,34 @@ class WebSocketChannel(BaseChannel):
|
||||
self._subs.setdefault(chat_id, set()).add(connection)
|
||||
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
||||
|
||||
def _attached_model_fields(self, chat_id: str) -> dict[str, str | None]:
|
||||
"""Expose the session's canonical preset on the attach handshake."""
|
||||
def _attached_model_fields(self, chat_id: str) -> dict[str, Any]:
|
||||
"""Expose small session runtime facts on the attach handshake."""
|
||||
sessions = self.gateway.session_manager
|
||||
if sessions is None:
|
||||
return {}
|
||||
snapshot = sessions.read_session_metadata(f"websocket:{chat_id}")
|
||||
metadata = snapshot.get("metadata") if isinstance(snapshot, dict) else None
|
||||
raw_metadata = snapshot.get("metadata") if snapshot is not None else None
|
||||
metadata = cast(dict[str, object], raw_metadata) if isinstance(raw_metadata, dict) else None
|
||||
fields: dict[str, Any] = {}
|
||||
try:
|
||||
return {"model_preset": model_preset_from_metadata(metadata)}
|
||||
fields["model_preset"] = model_preset_from_metadata(metadata)
|
||||
except ValueError:
|
||||
self.logger.warning("ignoring invalid model preset metadata for chat_id={}", chat_id)
|
||||
return {"model_preset": None}
|
||||
fields["model_preset"] = None
|
||||
if isinstance(metadata, dict):
|
||||
usage = metadata.get("_last_usage")
|
||||
if isinstance(usage, dict):
|
||||
sanitized_usage: dict[str, int | float] = {}
|
||||
for key, value in cast(dict[object, object], usage).items():
|
||||
if (
|
||||
isinstance(key, str)
|
||||
and isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and value >= 0
|
||||
):
|
||||
sanitized_usage[key] = value
|
||||
fields["usage"] = sanitized_usage
|
||||
return fields
|
||||
|
||||
def _detach(self, connection: ServerConnection, chat_id: str) -> None:
|
||||
chats = self._conn_chats.get(connection)
|
||||
@@ -1564,6 +1580,7 @@ class WebSocketChannel(BaseChannel):
|
||||
msg.chat_id,
|
||||
model_name=event.model,
|
||||
model_preset=event.model_preset,
|
||||
context_window_tokens=event.context_window_tokens,
|
||||
)
|
||||
return
|
||||
if isinstance(event, GoalStateSyncEvent):
|
||||
@@ -1608,6 +1625,8 @@ class WebSocketChannel(BaseChannel):
|
||||
msg.chat_id,
|
||||
latency_ms=event.latency_ms,
|
||||
goal_state=event.goal_state,
|
||||
usage=event.usage,
|
||||
context_window_tokens=event.context_window_tokens,
|
||||
metadata=msg.metadata,
|
||||
turn_owner=turn_owner if isinstance(turn_owner, str) else None,
|
||||
)
|
||||
@@ -1822,16 +1841,25 @@ class WebSocketChannel(BaseChannel):
|
||||
latency_ms: int | None = None,
|
||||
*,
|
||||
goal_state: dict[str, Any] | None = None,
|
||||
usage: dict[str, int] | None = None,
|
||||
context_window_tokens: int | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
turn_owner: str | None = None,
|
||||
) -> None:
|
||||
"""Signal that the agent has fully finished processing the current turn."""
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
body: dict[str, Any] = {"event": "turn_end", "chat_id": chat_id}
|
||||
turn_id = (metadata or {}).get(WEBUI_TURN_METADATA_KEY)
|
||||
if isinstance(turn_id, str) and turn_id:
|
||||
body["turn_id"] = turn_id
|
||||
if latency_ms is not None:
|
||||
body["latency_ms"] = int(latency_ms)
|
||||
if goal_state is not None:
|
||||
body["goal_state"] = goal_state
|
||||
if usage:
|
||||
body["usage"] = usage
|
||||
if context_window_tokens is not None:
|
||||
body["context_window_tokens"] = int(context_window_tokens)
|
||||
canonical_webui_turn = (metadata or {}).get("webui") is True
|
||||
prior_persistence_failure = (
|
||||
canonical_webui_turn
|
||||
@@ -1932,6 +1960,7 @@ class WebSocketChannel(BaseChannel):
|
||||
*,
|
||||
model_name: Any,
|
||||
model_preset: Any = None,
|
||||
context_window_tokens: Any = None,
|
||||
) -> None:
|
||||
"""Notify one chat's subscribers which model is handling its current request."""
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
@@ -1948,6 +1977,8 @@ class WebSocketChannel(BaseChannel):
|
||||
}
|
||||
if isinstance(model_preset, str) and model_preset.strip():
|
||||
body["model_preset"] = model_preset.strip()
|
||||
if isinstance(context_window_tokens, int) and context_window_tokens > 0:
|
||||
body["context_window_tokens"] = context_window_tokens
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" turn_model_updated ")
|
||||
|
||||
@@ -1926,6 +1926,7 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
|
||||
event=TurnModelUpdatedEvent(
|
||||
model="deepseek/deepseek-chat",
|
||||
model_preset="Deep Research",
|
||||
context_window_tokens=128_000,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -1936,10 +1937,37 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
|
||||
"chat_id": "chat-1",
|
||||
"model_name": "deepseek/deepseek-chat",
|
||||
"model_preset": "Deep Research",
|
||||
"context_window_tokens": 128_000,
|
||||
}
|
||||
chat_two.send.assert_not_awaited()
|
||||
|
||||
|
||||
def test_attach_fields_restore_the_session_model_and_latest_usage() -> None:
|
||||
manager = MagicMock()
|
||||
manager.read_session_metadata.return_value = {
|
||||
"metadata": {
|
||||
SESSION_MODEL_PRESET_METADATA_KEY: "Deep Research",
|
||||
"_last_usage": {
|
||||
"prompt_tokens": 120,
|
||||
"completion_tokens": 8,
|
||||
"negative": -1,
|
||||
"boolean": True,
|
||||
},
|
||||
}
|
||||
}
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=manager),
|
||||
)
|
||||
|
||||
assert channel._attached_model_fields("chat-1") == {
|
||||
"model_preset": "Deep Research",
|
||||
"usage": {"prompt_tokens": 120, "completion_tokens": 8},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None:
|
||||
bus = MagicMock()
|
||||
@@ -2938,11 +2966,21 @@ async def test_send_turn_end_includes_latency_ms_when_present() -> None:
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content="",
|
||||
event=TurnEndEvent(latency_ms=1500),
|
||||
event=TurnEndEvent(
|
||||
latency_ms=1500,
|
||||
usage={"prompt_tokens": 80, "completion_tokens": 20, "cached_tokens": 40},
|
||||
context_window_tokens=128_000,
|
||||
),
|
||||
))
|
||||
|
||||
assert _sent_ws_payloads(mock_ws) == [
|
||||
{"event": "turn_end", "chat_id": "chat-1", "latency_ms": 1500},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "chat-1",
|
||||
"latency_ms": 1500,
|
||||
"usage": {"prompt_tokens": 80, "completion_tokens": 20, "cached_tokens": 40},
|
||||
"context_window_tokens": 128_000,
|
||||
},
|
||||
{"event": "session_updated", "chat_id": "chat-1", "scope": "thread"},
|
||||
]
|
||||
|
||||
@@ -4787,6 +4825,7 @@ async def test_handle_session_context_get_reads_detached_session() -> None:
|
||||
session = Session(
|
||||
key="websocket:context-route",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
metadata={"_last_usage": {"prompt_tokens": 12, "completion_tokens": 3}},
|
||||
)
|
||||
manager = MagicMock()
|
||||
manager.read_session_snapshot.return_value = session
|
||||
@@ -4801,7 +4840,9 @@ async def test_handle_session_context_get_reads_detached_session() -> None:
|
||||
response = await gateway.http._handle_session_context_get(request, encoded)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body.decode())["replay_messages"] == 1
|
||||
body = json.loads(response.body.decode())
|
||||
assert body["replay_messages"] == 1
|
||||
assert body["last_usage"] == {"prompt_tokens": 12, "completion_tokens": 3}
|
||||
manager.read_session_snapshot.assert_called_once_with(session.key)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user