From 55b550ee01b933cc135601a82e33c64992e73b0e Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Tue, 30 Jun 2026 01:03:16 +0800
Subject: [PATCH] fix(trigger): hide external trigger inputs
---
docs/chat-commands.md | 3 +-
nanobot/agent/loop.py | 8 +--
nanobot/cron/session_turns.py | 48 ++++++++-----
nanobot/session/automation_turns.py | 90 ++++++++++++++++++++++++
nanobot/session/webui_turns.py | 4 +-
nanobot/triggers/runner.py | 3 +-
nanobot/triggers/session_turns.py | 51 ++++++++++++++
nanobot/webui/session_automations.py | 4 +-
nanobot/webui/session_list_index.py | 10 +--
nanobot/webui/transcript.py | 20 ++++--
tests/agent/test_loop_save_turn.py | 47 +++++++++++++
tests/channels/test_websocket_channel.py | 46 ++++++++++++
tests/utils/test_webui_transcript.py | 18 +++++
tests/webui/test_session_list_index.py | 15 ++++
14 files changed, 327 insertions(+), 40 deletions(-)
create mode 100644 nanobot/session/automation_turns.py
create mode 100644 nanobot/triggers/session_turns.py
diff --git a/docs/chat-commands.md b/docs/chat-commands.md
index 31377340..96729be9 100644
--- a/docs/chat-commands.md
+++ b/docs/chat-commands.md
@@ -78,7 +78,8 @@ nanobot trigger trg_8K4P2Q9X "Review PR #4502"
Replace `"Review PR #4502"` with the message you want nanobot to receive. The
trigger is bound to the session where it was created, so the message goes back
to that same chat. Keep `nanobot gateway` running so trigger messages can be
-delivered.
+delivered. The trigger message starts an automation turn; it is not shown in
+the chat as a normal user message.
For longer or generated content, omit the message argument and pipe stdin:
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 7e28d1e9..61ad0614 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -47,9 +47,6 @@ from nanobot.bus.runtime_events import (
)
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
-from nanobot.cron.session_turns import (
- cron_history_overrides,
-)
from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot
from nanobot.security.workspace_access import (
@@ -58,6 +55,7 @@ from nanobot.security.workspace_access import (
reset_workspace_scope,
)
from nanobot.session import turn_continuation
+from nanobot.session.automation_turns import automation_history_overrides
from nanobot.session.goal_state import (
goal_state_runtime_lines,
runner_wall_llm_timeout_s,
@@ -612,10 +610,10 @@ class AgentLoop:
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
extra.update(kwargs)
text = msg.content if isinstance(msg.content, str) else ""
- text_override, cron_extra = cron_history_overrides(msg.metadata)
+ text_override, automation_extra = automation_history_overrides(msg.metadata)
if text_override is not None:
text = text_override
- extra.update(cron_extra)
+ extra.update(automation_extra)
session.add_message("user", text, **extra)
self._mark_pending_user_turn(session)
self.sessions.save(session)
diff --git a/nanobot/cron/session_turns.py b/nanobot/cron/session_turns.py
index a85c4793..27622b83 100644
--- a/nanobot/cron/session_turns.py
+++ b/nanobot/cron/session_turns.py
@@ -5,16 +5,43 @@ from __future__ import annotations
from typing import Any, Mapping
from nanobot.cron.types import CronJob
+from nanobot.session.automation_turns import (
+ AutomationTurnSpec,
+ automation_history_overrides_for_spec,
+ automation_trigger,
+)
CRON_TRIGGER_META = "_cron_trigger"
CRON_DEFER_UNTIL_IDLE_META = "_cron_defer_until_session_idle"
CRON_HISTORY_META = "_cron_turn"
+def _cron_history_text(trigger: Mapping[str, Any]) -> str | None:
+ persist_content = trigger.get("persist_content")
+ return (
+ persist_content
+ if isinstance(persist_content, str) and persist_content.strip()
+ else None
+ )
+
+
+CRON_AUTOMATION_SPEC = AutomationTurnSpec(
+ kind="cron",
+ trigger_meta_key=CRON_TRIGGER_META,
+ legacy_history_meta_key=CRON_HISTORY_META,
+ history_fields={
+ "cron_job_id": "job_id",
+ "cron_job_name": "job_name",
+ "cron_run_id": "run_id",
+ "cron_prompt_ref": "prompt_ref",
+ },
+ text_builder=_cron_history_text,
+)
+
+
def cron_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
"""Return structured cron trigger metadata when present."""
- raw = (metadata or {}).get(CRON_TRIGGER_META)
- return raw if isinstance(raw, dict) else None
+ return automation_trigger(metadata, CRON_AUTOMATION_SPEC)
def is_cron_turn(metadata: Mapping[str, Any] | None) -> bool:
@@ -38,22 +65,7 @@ def cron_run_id(metadata: Mapping[str, Any] | None) -> str | None:
def cron_history_overrides(metadata: Mapping[str, Any] | None) -> tuple[str | None, dict[str, Any]]:
"""Return session-history text/metadata overrides for a cron turn."""
- trigger = cron_trigger(metadata)
- if not trigger:
- return None, {}
- persist_content = trigger.get("persist_content")
- text = (
- persist_content
- if isinstance(persist_content, str) and persist_content.strip()
- else None
- )
- return text, {
- CRON_HISTORY_META: True,
- "cron_job_id": trigger.get("job_id"),
- "cron_job_name": trigger.get("job_name"),
- "cron_run_id": trigger.get("run_id"),
- "cron_prompt_ref": trigger.get("prompt_ref"),
- }
+ return automation_history_overrides_for_spec(metadata, CRON_AUTOMATION_SPEC)
def is_bound_cron_job(job: CronJob) -> bool:
diff --git a/nanobot/session/automation_turns.py b/nanobot/session/automation_turns.py
new file mode 100644
index 00000000..116ac9c3
--- /dev/null
+++ b/nanobot/session/automation_turns.py
@@ -0,0 +1,90 @@
+"""Shared handling for session-bound automation turns."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping
+from dataclasses import dataclass, field
+from functools import lru_cache
+from typing import Any
+
+AUTOMATION_HISTORY_META = "_automation_turn"
+
+
+@dataclass(frozen=True)
+class AutomationTurnSpec:
+ """Source-specific wiring for one session-bound automation turn type."""
+
+ kind: str
+ trigger_meta_key: str
+ legacy_history_meta_key: str | None = None
+ history_fields: Mapping[str, str] = field(default_factory=dict)
+ text_builder: Callable[[Mapping[str, Any]], str | None] | None = None
+
+
+def automation_trigger(
+ metadata: Mapping[str, Any] | None,
+ spec: AutomationTurnSpec,
+) -> dict[str, Any] | None:
+ """Return source trigger metadata for *spec* when present."""
+ raw = (metadata or {}).get(spec.trigger_meta_key)
+ return raw if isinstance(raw, dict) else None
+
+
+def automation_history_overrides_for_spec(
+ metadata: Mapping[str, Any] | None,
+ spec: AutomationTurnSpec,
+) -> tuple[str | None, dict[str, Any]]:
+ """Return hidden session-history text/metadata overrides for *spec*."""
+ trigger = automation_trigger(metadata, spec)
+ if not trigger:
+ return None, {}
+
+ details: dict[str, Any] = {"kind": spec.kind}
+ extra: dict[str, Any] = {AUTOMATION_HISTORY_META: details}
+ if spec.legacy_history_meta_key:
+ extra[spec.legacy_history_meta_key] = True
+ for history_key, trigger_key in spec.history_fields.items():
+ value = trigger.get(trigger_key)
+ extra[history_key] = value
+ details[history_key] = value
+
+ text = spec.text_builder(trigger) if spec.text_builder else None
+ return text, extra
+
+
+@lru_cache(maxsize=1)
+def _automation_specs() -> tuple[AutomationTurnSpec, ...]:
+ # Source modules import the generic helpers above, so keep spec loading lazy.
+ from nanobot.cron.session_turns import CRON_AUTOMATION_SPEC
+ from nanobot.triggers.session_turns import EXTERNAL_TRIGGER_AUTOMATION_SPEC
+
+ return (CRON_AUTOMATION_SPEC, EXTERNAL_TRIGGER_AUTOMATION_SPEC)
+
+
+def automation_history_overrides(
+ metadata: Mapping[str, Any] | None,
+) -> tuple[str | None, dict[str, Any]]:
+ """Return session-history text/metadata overrides for supported automation turns."""
+ for spec in _automation_specs():
+ text, extra = automation_history_overrides_for_spec(metadata, spec)
+ if extra:
+ return text, extra
+ return None, {}
+
+
+def is_automation_history_message(message: Mapping[str, Any] | None) -> bool:
+ """True for hidden automation trigger records in session history."""
+ if not message:
+ return False
+ marker = message.get(AUTOMATION_HISTORY_META)
+ if marker is True or isinstance(marker, Mapping):
+ return True
+ return any(
+ spec.legacy_history_meta_key
+ and message.get(spec.legacy_history_meta_key) is True
+ for spec in _automation_specs()
+ )
+
+
+def is_automation_kind(value: Any) -> bool:
+ return isinstance(value, str) and any(spec.kind == value for spec in _automation_specs())
diff --git a/nanobot/session/webui_turns.py b/nanobot/session/webui_turns.py
index b97125e7..7a049b38 100644
--- a/nanobot/session/webui_turns.py
+++ b/nanobot/session/webui_turns.py
@@ -30,8 +30,8 @@ from nanobot.bus.runtime_events import (
TurnCompleted,
TurnRunStatusChanged,
)
-from nanobot.cron.session_turns import CRON_HISTORY_META
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.manager import Session, SessionManager
from nanobot.utils.helpers import strip_think, truncate_text
@@ -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 message.get(CRON_HISTORY_META) is True:
+ if is_automation_history_message(message):
continue
role = message.get("role")
content = message.get("content")
diff --git a/nanobot/triggers/runner.py b/nanobot/triggers/runner.py
index b7dc8c99..ef75dccc 100644
--- a/nanobot/triggers/runner.py
+++ b/nanobot/triggers/runner.py
@@ -10,12 +10,11 @@ from loguru import logger
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
+from nanobot.triggers.session_turns import EXTERNAL_TRIGGER_META
from nanobot.triggers.store import ExternalTriggerStore
from nanobot.triggers.types import ExternalTrigger, TriggerDelivery
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
-EXTERNAL_TRIGGER_META = "_external_trigger"
-
async def run_external_trigger_queue(
*,
diff --git a/nanobot/triggers/session_turns.py b/nanobot/triggers/session_turns.py
new file mode 100644
index 00000000..eeb36759
--- /dev/null
+++ b/nanobot/triggers/session_turns.py
@@ -0,0 +1,51 @@
+"""Shared metadata helpers for local external trigger session turns."""
+
+from __future__ import annotations
+
+from typing import Any, Mapping
+
+from nanobot.session.automation_turns import (
+ AutomationTurnSpec,
+ automation_history_overrides_for_spec,
+ automation_trigger,
+)
+
+EXTERNAL_TRIGGER_META = "_external_trigger"
+
+
+def _external_trigger_history_text(trigger: Mapping[str, Any]) -> str:
+ name = trigger.get("trigger_name")
+ trigger_id = trigger.get("trigger_id")
+ label = name if isinstance(name, str) and name.strip() else trigger_id
+ return (
+ f"External trigger received: {label}"
+ if isinstance(label, str) and label.strip()
+ else "External trigger received"
+ )
+
+
+EXTERNAL_TRIGGER_AUTOMATION_SPEC = AutomationTurnSpec(
+ kind="trigger",
+ trigger_meta_key=EXTERNAL_TRIGGER_META,
+ history_fields={
+ "trigger_id": "trigger_id",
+ "trigger_name": "trigger_name",
+ "trigger_delivery_id": "delivery_id",
+ },
+ text_builder=_external_trigger_history_text,
+)
+
+
+def external_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
+ """Return structured external trigger metadata when present."""
+ return automation_trigger(metadata, EXTERNAL_TRIGGER_AUTOMATION_SPEC)
+
+
+def external_trigger_history_overrides(
+ metadata: Mapping[str, Any] | None,
+) -> tuple[str | None, dict[str, Any]]:
+ """Return session-history text/metadata overrides for an external trigger turn."""
+ return automation_history_overrides_for_spec(
+ metadata,
+ EXTERNAL_TRIGGER_AUTOMATION_SPEC,
+ )
diff --git a/nanobot/webui/session_automations.py b/nanobot/webui/session_automations.py
index e22be27e..51de158d 100644
--- a/nanobot/webui/session_automations.py
+++ b/nanobot/webui/session_automations.py
@@ -5,8 +5,8 @@ from __future__ import annotations
from collections.abc import Collection
from typing import Any, Protocol
-from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.cron.types import CronJob
+from nanobot.session.automation_turns import is_automation_history_message
from nanobot.session.manager import _message_preview_text
from nanobot.triggers.types import ExternalTrigger
@@ -326,7 +326,7 @@ def _session_preview(messages: Any) -> str:
for message in messages:
if not isinstance(message, dict):
continue
- if message.get(CRON_HISTORY_META) is True:
+ if is_automation_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 ccadcd07..b8bf0478 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.cron.session_turns import CRON_HISTORY_META
+from nanobot.session.automation_turns import is_automation_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 item.get(CRON_HISTORY_META) is True:
+ if is_automation_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 item.get(CRON_HISTORY_META) is True:
+ if is_automation_history_message(item):
return None
if item.get("role") not in _VISIBLE_TRANSCRIPT_ROLES:
return None
@@ -296,7 +296,9 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
):
preview_done = True
continue
- if item.get(CRON_HISTORY_META) is True:
+ if item.get("_type") == "metadata":
+ continue
+ if is_automation_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 67be9669..9c412bf7 100644
--- a/nanobot/webui/transcript.py
+++ b/nanobot/webui/transcript.py
@@ -17,7 +17,7 @@ from urllib.parse import unquote, urlparse
from loguru import logger
from nanobot.config.paths import get_webui_dir
-from nanobot.cron.session_turns import CRON_HISTORY_META
+from nanobot.session.automation_turns import is_automation_history_message, is_automation_kind
from nanobot.session.manager import SessionManager
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
@@ -598,9 +598,12 @@ def normalize_webui_turn_id(value: Any) -> str:
def webui_message_source(metadata: dict[str, Any] | None) -> dict[str, str] | None:
raw = (metadata or {}).get(WEBUI_MESSAGE_SOURCE_METADATA_KEY)
- if not isinstance(raw, dict) or raw.get("kind") != "cron":
+ if not isinstance(raw, dict):
return None
- source: dict[str, str] = {"kind": "cron"}
+ kind = raw.get("kind")
+ if not is_automation_kind(kind):
+ return None
+ source: dict[str, str] = {"kind": kind}
label = raw.get("label")
if isinstance(label, str) and label.strip():
source["label"] = label.strip()
@@ -779,6 +782,8 @@ 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):
+ continue
role = msg.get("role")
content = msg.get("content")
text = content if isinstance(content, str) else ""
@@ -855,7 +860,7 @@ def _session_user_event(
) -> dict[str, Any] | None:
if message.get("role") != "user":
return None
- if message.get(CRON_HISTORY_META) is True:
+ if is_automation_history_message(message):
return None
content = message.get("content")
text = content if isinstance(content, str) else ""
@@ -1271,9 +1276,12 @@ def replay_transcript_to_ui_messages(
def _source_fields(rec: dict[str, Any]) -> dict[str, Any]:
source = rec.get("source")
- if not isinstance(source, dict) or source.get("kind") != "cron":
+ if not isinstance(source, dict):
return {}
- out: dict[str, Any] = {"source": {"kind": "cron"}}
+ kind = source.get("kind")
+ if not is_automation_kind(kind):
+ return {}
+ out: dict[str, Any] = {"source": {"kind": kind}}
label = source.get("label")
if isinstance(label, str) and label.strip():
out["source"]["label"] = label.strip()
diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py
index daac07a4..bfb7a38f 100644
--- a/tests/agent/test_loop_save_turn.py
+++ b/tests/agent/test_loop_save_turn.py
@@ -18,6 +18,7 @@ from nanobot.bus.outbound_events import (
from nanobot.bus.queue import MessageBus
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
from nanobot.providers.base import LLMResponse
+from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
from nanobot.session.goal_state import GOAL_STATE_KEY
from nanobot.session.manager import Session, SessionManager
from nanobot.session.turn_continuation import (
@@ -33,6 +34,7 @@ from nanobot.session.webui_turns import (
clean_generated_title,
maybe_generate_webui_title,
)
+from nanobot.triggers.session_turns import EXTERNAL_TRIGGER_META
from nanobot.utils.llm_runtime import LLMRuntime
@@ -101,6 +103,13 @@ def test_persist_cron_turn_uses_distinct_history_marker(tmp_path: Path) -> None:
assert persisted is True
message = session.messages[-1]
assert message["content"] == "Scheduled cron job triggered: Daily check"
+ assert message[AUTOMATION_HISTORY_META] == {
+ "kind": "cron",
+ "cron_job_id": "job-1",
+ "cron_job_name": "Daily check",
+ "cron_run_id": "job-1:1",
+ "cron_prompt_ref": prompt_ref,
+ }
assert message[CRON_HISTORY_META] is True
assert CRON_TRIGGER_META not in message
assert message["cron_job_id"] == "job-1"
@@ -109,6 +118,44 @@ def test_persist_cron_turn_uses_distinct_history_marker(tmp_path: Path) -> None:
assert message["cron_prompt_ref"] == prompt_ref
+def test_persist_external_trigger_turn_uses_hidden_automation_marker(tmp_path: Path) -> None:
+ loop = _make_full_loop(tmp_path)
+ session = loop.sessions.get_or_create("websocket:auto")
+
+ persisted = loop._persist_user_message_early(
+ InboundMessage(
+ channel="websocket",
+ sender_id="trigger",
+ chat_id="auto",
+ content="Review PR #4502",
+ metadata={
+ EXTERNAL_TRIGGER_META: {
+ "trigger_id": "trg_123",
+ "trigger_name": "PR review",
+ "delivery_id": "tdel_456",
+ "created_at_ms": 1_700_000_000_000,
+ }
+ },
+ ),
+ session,
+ )
+
+ assert persisted is True
+ message = session.messages[-1]
+ assert message["content"] == "External trigger received: PR review"
+ assert "Review PR #4502" not in message["content"]
+ assert message[AUTOMATION_HISTORY_META] == {
+ "kind": "trigger",
+ "trigger_id": "trg_123",
+ "trigger_name": "PR review",
+ "trigger_delivery_id": "tdel_456",
+ }
+ assert EXTERNAL_TRIGGER_META not in message
+ assert message["trigger_id"] == "trg_123"
+ assert message["trigger_name"] == "PR review"
+ assert message["trigger_delivery_id"] == "tdel_456"
+
+
def test_clean_generated_title_strips_reasoning_tags() -> None:
assert clean_generated_title("reasoning WebUI polish") == "WebUI polish"
assert clean_generated_title("Title: The user said hello") == ""
diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py
index 2a0562bc..0fe89ddc 100644
--- a/tests/channels/test_websocket_channel.py
+++ b/tests/channels/test_websocket_channel.py
@@ -2975,3 +2975,49 @@ def test_handle_webui_thread_get_does_not_backfill_cron_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"]] == ["提醒已经到期。"]
+
+
+def test_handle_webui_thread_get_does_not_backfill_trigger_internal_prompt(
+ tmp_path,
+ monkeypatch,
+) -> None:
+ from urllib.parse import quote
+
+ from websockets.datastructures import Headers
+ from websockets.http11 import Request
+
+ from nanobot.session.automation_turns import AUTOMATION_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-trigger"
+ session = sessions.get_or_create(key)
+ session.add_message(
+ "user",
+ "External trigger received: PR review",
+ **{AUTOMATION_HISTORY_META: {"kind": "trigger", "trigger_id": "trg_123"}},
+ )
+ session.add_message("assistant", "PR #4502 已经开始 review。")
+ sessions.save(session)
+ append_transcript_object(
+ key,
+ {"event": "message", "chat_id": "c-trigger", "text": "PR #4502 已经开始 review。"},
+ )
+
+ 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"]] == ["PR #4502 已经开始 review。"]
diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py
index f79cb319..6482a0e8 100644
--- a/tests/utils/test_webui_transcript.py
+++ b/tests/utils/test_webui_transcript.py
@@ -473,6 +473,24 @@ def test_replay_reused_turn_id_after_turn_end_starts_new_turn(tmp_path, monkeypa
assert msgs[2]["source"] == {"kind": "cron", "label": "drink water"}
+def test_replay_preserves_trigger_source_metadata(tmp_path, monkeypatch) -> None:
+ monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
+ key = "websocket:t-trigger-source"
+ append_transcript_object(
+ key,
+ {
+ "event": "message",
+ "chat_id": "t-trigger-source",
+ "text": "PR #4502 review started.",
+ "source": {"kind": "trigger", "label": "PR review"},
+ },
+ )
+
+ msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
+
+ assert msgs[0]["source"] == {"kind": "trigger", "label": "PR review"}
+
+
def test_build_response_restores_session_users_for_legacy_transcript(
tmp_path,
monkeypatch,
diff --git a/tests/webui/test_session_list_index.py b/tests/webui/test_session_list_index.py
index e65ca8de..9fe72118 100644
--- a/tests/webui/test_session_list_index.py
+++ b/tests/webui/test_session_list_index.py
@@ -6,6 +6,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.manager import SessionManager
@@ -88,6 +89,20 @@ def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) ->
assert list_webui_sessions(manager)[0]["preview"] == "提醒已经到期。"
+def test_webui_session_list_skips_trigger_internal_user_preview(tmp_path: Path) -> None:
+ manager = SessionManager(tmp_path)
+ session = manager.get_or_create("websocket:trigger-preview")
+ session.add_message(
+ "user",
+ "External trigger received: PR review",
+ **{AUTOMATION_HISTORY_META: {"kind": "trigger", "trigger_id": "trg_123"}},
+ )
+ session.add_message("assistant", "PR #4502 已经开始 review。")
+ manager.save(session)
+
+ assert list_webui_sessions(manager)[0]["preview"] == "PR #4502 已经开始 review。"
+
+
def test_webui_session_list_uses_webui_transcript_activity_for_sort(
tmp_path: Path,
monkeypatch,