From 34535b4e7c1fc074f35b822580e461da4562b72b Mon Sep 17 00:00:00 2001 From: chengyongru Date: Thu, 2 Jul 2026 13:31:58 +0800 Subject: [PATCH] fix(webui): hide subagent backfill payloads --- nanobot/agent/loop.py | 16 +++++- nanobot/agent/runner.py | 3 ++ nanobot/session/history_visibility.py | 22 +++++++++ nanobot/session/webui_turns.py | 4 +- nanobot/webui/session_automations.py | 4 +- nanobot/webui/session_list_index.py | 8 +-- nanobot/webui/transcript.py | 22 +++++++-- tests/agent/test_runner_injections.py | 62 ++++++++++++++++++++++++ tests/channels/test_websocket_channel.py | 46 ++++++++++++++++++ tests/utils/test_webui_transcript.py | 42 ++++++++++++++++ tests/webui/test_session_list_index.py | 15 ++++++ 11 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 nanobot/session/history_visibility.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 0b4a2c1e..b0e8114c 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -62,6 +62,7 @@ from nanobot.session.goal_state import ( runner_wall_llm_timeout_s, sustained_goal_active, ) +from nanobot.session.history_visibility import HIDDEN_HISTORY_META from nanobot.session.keys import UNIFIED_SESSION_KEY, session_key_for_channel from nanobot.session.manager import ( Session, @@ -795,7 +796,20 @@ class AgentLoop: content, media = self._prepare_message_media(content, media) media = media or None user_content = self.context._build_user_content(content, media) - return {"role": "user", "content": user_content} + row: dict[str, Any] = {"role": "user", "content": user_content} + metadata = pending_msg.metadata if isinstance(pending_msg.metadata, dict) else {} + if ( + pending_msg.sender_id == "subagent" + and metadata.get("injected_event") == "subagent_result" + ): + marker: dict[str, Any] = {"kind": "subagent_result"} + task_id = metadata.get("subagent_task_id") + if isinstance(task_id, str) and task_id: + marker["subagent_task_id"] = task_id + row["subagent_task_id"] = task_id + row[HIDDEN_HISTORY_META] = marker + row["injected_event"] = "subagent_result" + return row items: list[dict[str, Any]] = [] while len(items) < limit: diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 645e1f48..267ae69e 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -20,6 +20,7 @@ from nanobot.agent.context_governance import ( from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest +from nanobot.session.history_visibility import is_hidden_history_message from nanobot.utils.file_edit_events import ( StreamingFileEditTracker, build_file_edit_end_event, @@ -155,6 +156,8 @@ class AgentRunner: messages and injection.get("role") == "user" and messages[-1].get("role") == "user" + and not is_hidden_history_message(injection) + and not is_hidden_history_message(messages[-1]) ): merged = dict(messages[-1]) merged["content"] = cls._merge_message_content( diff --git a/nanobot/session/history_visibility.py b/nanobot/session/history_visibility.py new file mode 100644 index 00000000..eedb8543 --- /dev/null +++ b/nanobot/session/history_visibility.py @@ -0,0 +1,22 @@ +"""Visibility helpers for persisted session history messages.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from nanobot.session.automation_turns import is_automation_history_message + +HIDDEN_HISTORY_META = "_hidden_history" + + +def _has_hidden_history_marker(message: Mapping[str, Any] | None) -> bool: + if not message: + return False + marker = message.get(HIDDEN_HISTORY_META) + return marker is True or isinstance(marker, Mapping) + + +def is_hidden_history_message(message: Mapping[str, Any] | None) -> bool: + """True for persisted messages that should not be shown as chat turns.""" + return _has_hidden_history_marker(message) or is_automation_history_message(message) diff --git a/nanobot/session/webui_turns.py b/nanobot/session/webui_turns.py index 7a049b38..3b7a4b63 100644 --- a/nanobot/session/webui_turns.py +++ b/nanobot/session/webui_turns.py @@ -31,8 +31,8 @@ from nanobot.bus.runtime_events import ( TurnRunStatusChanged, ) from nanobot.providers.base import LLMProvider -from nanobot.session.automation_turns import is_automation_history_message from nanobot.session.goal_state import goal_state_ws_blob +from nanobot.session.history_visibility import is_hidden_history_message from nanobot.session.manager import Session, SessionManager from nanobot.utils.helpers import strip_think, truncate_text from nanobot.utils.llm_runtime import LLMRuntime @@ -77,7 +77,7 @@ def _title_inputs(session: Session) -> tuple[str, str]: for message in session.messages: if message.get("_command") is True: continue - if is_automation_history_message(message): + if is_hidden_history_message(message): continue role = message.get("role") content = message.get("content") diff --git a/nanobot/webui/session_automations.py b/nanobot/webui/session_automations.py index ef99689b..37cec5dd 100644 --- a/nanobot/webui/session_automations.py +++ b/nanobot/webui/session_automations.py @@ -6,7 +6,7 @@ from collections.abc import Collection from typing import Any, Protocol from nanobot.cron.types import CronJob -from nanobot.session.automation_turns import is_automation_history_message +from nanobot.session.history_visibility import is_hidden_history_message from nanobot.session.manager import _message_preview_text from nanobot.triggers.local_types import LocalTrigger @@ -328,7 +328,7 @@ def _session_preview(messages: Any) -> str: for message in messages: if not isinstance(message, dict): continue - if is_automation_history_message(message): + if is_hidden_history_message(message): continue text = _message_preview_text(message) if not text: diff --git a/nanobot/webui/session_list_index.py b/nanobot/webui/session_list_index.py index b8bf0478..545173c2 100644 --- a/nanobot/webui/session_list_index.py +++ b/nanobot/webui/session_list_index.py @@ -16,7 +16,7 @@ from typing import Any from loguru import logger from nanobot.config.paths import get_webui_dir -from nanobot.session.automation_turns import is_automation_history_message +from nanobot.session.history_visibility import is_hidden_history_message from nanobot.session.manager import ( _SESSION_LIST_PREVIEW_MAX_CHARS, _SESSION_LIST_PREVIEW_MAX_RECORDS, @@ -154,7 +154,7 @@ def _preview_from_messages(messages: list[dict[str, Any]]) -> str: or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS ): break - if is_automation_history_message(item): + if is_hidden_history_message(item): continue text = _message_preview_text(item) if not text: @@ -216,7 +216,7 @@ def _latest_updated_at(stored: str | None, activity: str | None) -> str | None: def _visible_message_timestamp(item: dict[str, Any]) -> str | None: - if is_automation_history_message(item): + if is_hidden_history_message(item): return None if item.get("role") not in _VISIBLE_TRANSCRIPT_ROLES: return None @@ -298,7 +298,7 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, continue if item.get("_type") == "metadata": continue - if is_automation_history_message(item): + if is_hidden_history_message(item): continue text = _message_preview_text(item) if not text: diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index 9c412bf7..a1b4dec1 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -17,7 +17,8 @@ from urllib.parse import unquote, urlparse from loguru import logger from nanobot.config.paths import get_webui_dir -from nanobot.session.automation_turns import is_automation_history_message, is_automation_kind +from nanobot.session.automation_turns import is_automation_kind +from nanobot.session.history_visibility import is_hidden_history_message from nanobot.session.manager import SessionManager from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY @@ -782,7 +783,7 @@ def write_session_messages_as_transcript( target_chat_id = _chat_id_from_session_key(target_key) rows: list[dict[str, Any]] = [] for msg in messages: - if is_automation_history_message(msg): + if is_hidden_history_message(msg): continue role = msg.get("role") content = msg.get("content") @@ -854,13 +855,28 @@ def build_user_transcript_event( return event +def _is_legacy_raw_subagent_result(message: dict[str, Any]) -> bool: + content = message.get("content") + if not isinstance(content, str): + return False + text = content.replace("\r\n", "\n").strip() + return ( + text.startswith("[Subagent '") + and "\n\nTask:" in text + and "\n\nResult:" in text + and "Summarize this naturally" in text + ) + + def _session_user_event( session_key: str, message: dict[str, Any], ) -> dict[str, Any] | None: if message.get("role") != "user": return None - if is_automation_history_message(message): + if is_hidden_history_message(message): + return None + if _is_legacy_raw_subagent_result(message): return None content = message.get("content") text = content if isinstance(content, str) else "" diff --git a/tests/agent/test_runner_injections.py b/tests/agent/test_runner_injections.py index fd5ea8dd..507b0d6d 100644 --- a/tests/agent/test_runner_injections.py +++ b/tests/agent/test_runner_injections.py @@ -465,6 +465,68 @@ async def test_loop_injected_followup_preserves_image_media(tmp_path): ) +@pytest.mark.asyncio +async def test_subagent_pending_injection_is_hidden_history_and_not_merged(tmp_path): + from nanobot.agent.loop import AgentLoop + from nanobot.bus.events import InboundMessage + from nanobot.bus.queue import MessageBus + from nanobot.session.history_visibility import HIDDEN_HISTORY_META + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + call_count = {"n": 0} + + async def chat_with_retry(*, messages, **kwargs): + call_count["n"] += 1 + if call_count["n"] == 1: + return LLMResponse(content="first answer", tool_calls=[], usage={}) + return LLMResponse(content="second answer", tool_calls=[], usage={}) + + provider.chat_with_retry = chat_with_retry + loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") + loop.tools.get_definitions = MagicMock(return_value=[]) + + payload = ( + "[Subagent 'x' completed successfully]\n\n" + "Task: t\n\n" + "Result:\nr\n\n" + "Summarize this naturally for the user." + ) + pending_queue = asyncio.Queue() + await pending_queue.put(InboundMessage( + channel="cli", + sender_id="user", + chat_id="c", + content="visible follow-up", + )) + await pending_queue.put(InboundMessage( + channel="system", + sender_id="subagent", + chat_id="cli:c", + content=payload, + metadata={"injected_event": "subagent_result", "subagent_task_id": "sub-1"}, + )) + + final_content, _, all_msgs, _, had_injections = await loop._run_agent_loop( + [{"role": "user", "content": "hello"}], + channel="cli", + chat_id="c", + pending_queue=pending_queue, + ) + + assert final_content == "second answer" + assert had_injections is True + assert call_count["n"] == 2 + injected_users = [message for message in all_msgs if message.get("role") == "user"][-2:] + assert [message["content"] for message in injected_users] == ["visible follow-up", payload] + assert injected_users[1][HIDDEN_HISTORY_META] == { + "kind": "subagent_result", + "subagent_task_id": "sub-1", + } + assert injected_users[1]["injected_event"] == "subagent_result" + + @pytest.mark.asyncio async def test_runner_merges_multiple_injected_user_messages_without_losing_media(): """Multiple injected follow-ups should not create lossy consecutive user messages.""" diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 0b26dd61..83c15933 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -3021,3 +3021,49 @@ def test_handle_webui_thread_get_does_not_backfill_trigger_internal_prompt( body = json.loads(resp.body.decode()) assert [message["role"] for message in body["messages"]] == ["assistant"] assert [message["content"] for message in body["messages"]] == ["PR #4502 已经开始 review。"] + + +def test_handle_webui_thread_get_does_not_backfill_hidden_subagent_result( + tmp_path, + monkeypatch, +) -> None: + from urllib.parse import quote + + from websockets.datastructures import Headers + from websockets.http11 import Request + + from nanobot.session.history_visibility import HIDDEN_HISTORY_META + from nanobot.webui.transcript import append_transcript_object + + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + workspace = tmp_path / "workspace" + sessions = SessionManager(workspace) + key = "websocket:c-subagent" + session = sessions.get_or_create(key) + session.add_message( + "user", + "internal subagent result", + **{HIDDEN_HISTORY_META: {"kind": "subagent_result", "subagent_task_id": "sub-1"}}, + ) + session.add_message("assistant", "subagent summary") + sessions.save(session) + append_transcript_object( + key, + {"event": "message", "chat_id": "c-subagent", "text": "subagent summary"}, + ) + + bus = MagicMock() + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler(bus, session_manager=sessions, workspace_path=workspace), + ) + channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0 + enc = quote(key, safe="") + req = Request(f"/api/sessions/{enc}/webui-thread", Headers([("Authorization", "Bearer tok")])) + resp = channel.gateway.http._handle_webui_thread_get(req, enc) + + assert resp.status_code == 200 + body = json.loads(resp.body.decode()) + assert [message["role"] for message in body["messages"]] == ["assistant"] + assert [message["content"] for message in body["messages"]] == ["subagent summary"] diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index ec59ce03..7242b792 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -2,6 +2,7 @@ from __future__ import annotations +from nanobot.session.history_visibility import HIDDEN_HISTORY_META from nanobot.webui.transcript import ( WEBUI_TRANSCRIPT_SCHEMA_VERSION, append_fork_marker, @@ -698,6 +699,47 @@ def test_backfill_does_not_misalign_when_session_only_has_transcript_tail( ] +def test_backfill_skips_internal_subagent_results(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + key = "websocket:t-subagent" + for ev in ( + {"event": "message", "chat_id": "t-subagent", "text": "summary one"}, + {"event": "turn_end", "chat_id": "t-subagent"}, + {"event": "message", "chat_id": "t-subagent", "text": "summary two"}, + {"event": "turn_end", "chat_id": "t-subagent"}, + ): + append_transcript_object(key, ev) + + legacy_raw = ( + "[Subagent 'legacy' completed successfully]\n\n" + "Task: t\n\n" + "Result:\nr\n\n" + "Summarize this naturally for the user." + ) + out = build_webui_thread_response( + key, + session_messages=[ + {"role": "user", "content": legacy_raw}, + {"role": "assistant", "content": "summary one"}, + { + "role": "user", + "content": "marked result", + HIDDEN_HISTORY_META: { + "kind": "subagent_result", + "subagent_task_id": "sub-1", + }, + }, + {"role": "assistant", "content": "summary two"}, + ], + ) + + assert out is not None + assert [(message["role"], message["content"]) for message in out["messages"]] == [ + ("assistant", "summary one"), + ("assistant", "summary two"), + ] + + def test_replay_infers_video_media_from_attachment_name() -> None: msgs = replay_transcript_to_ui_messages( [ diff --git a/tests/webui/test_session_list_index.py b/tests/webui/test_session_list_index.py index 7920ecbc..3dd6e1ac 100644 --- a/tests/webui/test_session_list_index.py +++ b/tests/webui/test_session_list_index.py @@ -7,6 +7,7 @@ from pathlib import Path import nanobot.webui.session_list_index as session_list_index from nanobot.cron.session_turns import CRON_HISTORY_META from nanobot.session.automation_turns import AUTOMATION_HISTORY_META +from nanobot.session.history_visibility import HIDDEN_HISTORY_META from nanobot.session.manager import SessionManager @@ -103,6 +104,20 @@ def test_webui_session_list_skips_trigger_internal_user_preview(tmp_path: Path) assert list_webui_sessions(manager)[0]["preview"] == "PR #4502 已经开始 review。" +def test_webui_session_list_skips_hidden_history_user_preview(tmp_path: Path) -> None: + manager = SessionManager(tmp_path) + session = manager.get_or_create("websocket:hidden-preview") + session.add_message( + "user", + "internal subagent result", + **{HIDDEN_HISTORY_META: {"kind": "subagent_result", "subagent_task_id": "sub-1"}}, + ) + session.add_message("assistant", "subagent summary") + manager.save(session) + + assert list_webui_sessions(manager)[0]["preview"] == "subagent summary" + + def test_webui_session_list_uses_webui_transcript_activity_for_sort( tmp_path: Path, monkeypatch,