feat(webui): polish chat layout and titles
Align the WebUI sidebar and chat chrome with the updated design, and generate WebUI session titles asynchronously without blocking turns. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
Xubin Ren
co-authored by
Cursor
parent
d8fd4c80bf
commit
790a03ec28
@@ -1,5 +1,6 @@
|
||||
"""Tests for structured tool-event progress metadata emitted by AgentLoop."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
@@ -291,6 +292,48 @@ class TestToolEventProgress:
|
||||
assert (outbound[-1].metadata or {}).get("_turn_end") is True
|
||||
assert outbound[-1].chat_id == "chat1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_title_generation_runs_after_turn_end(self, tmp_path: Path) -> None:
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
title_started = asyncio.Event()
|
||||
release_title = asyncio.Event()
|
||||
calls = 0
|
||||
|
||||
async def chat_with_retry(*_args: object, **_kwargs: object) -> LLMResponse:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
return LLMResponse(content="Done", tool_calls=[])
|
||||
title_started.set()
|
||||
await release_title.wait()
|
||||
return LLMResponse(content="Generated title", tool_calls=[])
|
||||
|
||||
provider.chat_with_retry = AsyncMock(side_effect=chat_with_retry)
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
await asyncio.wait_for(loop._dispatch(InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u1",
|
||||
chat_id="chat1",
|
||||
content="say hello",
|
||||
metadata={"webui": True},
|
||||
)), timeout=0.5)
|
||||
|
||||
outbound = [await bus.consume_outbound(), await bus.consume_outbound()]
|
||||
assert outbound[0].content == "Done"
|
||||
assert (outbound[1].metadata or {}).get("_turn_end") is True
|
||||
|
||||
await asyncio.wait_for(title_started.wait(), timeout=0.5)
|
||||
release_title.set()
|
||||
session_updated = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5)
|
||||
|
||||
assert (session_updated.metadata or {}).get("_session_updated") is True
|
||||
assert provider.chat_with_retry.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None:
|
||||
bus = MessageBus()
|
||||
|
||||
@@ -8,7 +8,13 @@ from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
from nanobot.session.manager import Session
|
||||
from nanobot.utils.webui_titles import (
|
||||
WEBUI_SESSION_METADATA_KEY,
|
||||
WEBUI_TITLE_METADATA_KEY,
|
||||
maybe_generate_webui_title,
|
||||
)
|
||||
|
||||
|
||||
def _mk_loop() -> AgentLoop:
|
||||
@@ -22,9 +28,56 @@ def _mk_loop() -> AgentLoop:
|
||||
def _make_full_loop(tmp_path: Path) -> AgentLoop:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Test title"))
|
||||
return AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.provider.chat_with_retry = AsyncMock(
|
||||
return_value=LLMResponse(content='"优化 WebUI 侧边栏。"', finish_reason="stop")
|
||||
)
|
||||
session = loop.sessions.get_or_create("websocket:chat-title")
|
||||
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
|
||||
session.add_message("user", "帮我优化一下 webui 的 sidebar")
|
||||
session.add_message("assistant", "可以,我会先调整布局和视觉层级。")
|
||||
loop.sessions.save(session)
|
||||
|
||||
generated = await maybe_generate_webui_title(
|
||||
sessions=loop.sessions,
|
||||
session_key="websocket:chat-title",
|
||||
provider=loop.provider,
|
||||
model=loop.model,
|
||||
)
|
||||
|
||||
assert generated is True
|
||||
assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏"
|
||||
loop.provider.chat_with_retry.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_webui_title_skips_plain_websocket_sessions(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.provider.chat_with_retry = AsyncMock(
|
||||
return_value=LLMResponse(content="Plain websocket title", finish_reason="stop")
|
||||
)
|
||||
session = loop.sessions.get_or_create("websocket:custom-client")
|
||||
session.add_message("user", "hello from a custom websocket client")
|
||||
loop.sessions.save(session)
|
||||
|
||||
generated = await maybe_generate_webui_title(
|
||||
sessions=loop.sessions,
|
||||
session_key="websocket:custom-client",
|
||||
provider=loop.provider,
|
||||
model=loop.model,
|
||||
)
|
||||
|
||||
assert generated is False
|
||||
assert WEBUI_TITLE_METADATA_KEY not in session.metadata
|
||||
loop.provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
|
||||
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
|
||||
loop = _mk_loop()
|
||||
session = Session(key="test:runtime-only")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from nanobot.session.manager import Session
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
|
||||
|
||||
def _assert_no_orphans(history: list[dict]) -> None:
|
||||
@@ -31,6 +31,18 @@ def _tool_turn(prefix: str, idx: int) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def test_list_sessions_includes_metadata_title(tmp_path):
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create("websocket:chat-title")
|
||||
session.metadata["title"] = "自动生成标题"
|
||||
manager.save(session)
|
||||
|
||||
rows = manager.list_sessions()
|
||||
|
||||
assert rows[0]["key"] == "websocket:chat-title"
|
||||
assert rows[0]["title"] == "自动生成标题"
|
||||
|
||||
|
||||
# --- Original regression test (from PR 2075) ---
|
||||
|
||||
def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls():
|
||||
|
||||
@@ -167,6 +167,40 @@ def test_issue_route_secret_matches_empty_secret() -> None:
|
||||
assert _issue_route_secret_matches(Headers([("Authorization", "Bearer anything")]), "") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) -> None:
|
||||
channel = _ch(bus)
|
||||
conn = MagicMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{"type": "message", "chat_id": "chat-1", "content": "hello", "webui": True},
|
||||
)
|
||||
|
||||
msg = bus.publish_inbound.await_args.args[0]
|
||||
assert msg.channel == "websocket"
|
||||
assert msg.chat_id == "chat-1"
|
||||
assert msg.metadata["webui"] is True
|
||||
assert msg.metadata["_wants_stream"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_websocket_message_does_not_mark_webui(bus: MagicMock) -> None:
|
||||
channel = _ch(bus)
|
||||
conn = MagicMock()
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"custom-client",
|
||||
{"type": "message", "chat_id": "chat-1", "content": "hello"},
|
||||
)
|
||||
|
||||
msg = bus.publish_inbound.await_args.args[0]
|
||||
assert "webui" not in msg.metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delivers_json_message_with_media_and_reply() -> None:
|
||||
bus = MagicMock()
|
||||
@@ -306,6 +340,25 @@ async def test_send_turn_end_emits_turn_end_event() -> None:
|
||||
assert body == {"event": "turn_end", "chat_id": "chat-1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_updated_emits_session_updated_event() -> None:
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content="",
|
||||
metadata={"_session_updated": True},
|
||||
))
|
||||
|
||||
mock_ws.send.assert_awaited_once()
|
||||
body = json.loads(mock_ws.send.await_args.args[0])
|
||||
assert body == {"event": "session_updated", "chat_id": "chat-1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_non_connection_closed_exception_is_raised() -> None:
|
||||
bus = MagicMock()
|
||||
|
||||
Reference in New Issue
Block a user