fix(trigger): hide external trigger inputs

This commit is contained in:
chengyongru 2026-06-30 01:03:16 +08:00 committed by Xubin Ren
parent b690a48336
commit 55b550ee01
14 changed files with 327 additions and 40 deletions

View File

@ -78,7 +78,8 @@ nanobot trigger trg_8K4P2Q9X "Review PR #4502"
Replace `"Review PR #4502"` with the message you want nanobot to receive. The 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 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 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: For longer or generated content, omit the message argument and pipe stdin:

View File

@ -47,9 +47,6 @@ from nanobot.bus.runtime_events import (
) )
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.config.schema import AgentDefaults, ModelPresetConfig 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.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot from nanobot.providers.factory import ProviderSnapshot
from nanobot.security.workspace_access import ( from nanobot.security.workspace_access import (
@ -58,6 +55,7 @@ from nanobot.security.workspace_access import (
reset_workspace_scope, reset_workspace_scope,
) )
from nanobot.session import turn_continuation from nanobot.session import turn_continuation
from nanobot.session.automation_turns import automation_history_overrides
from nanobot.session.goal_state import ( from nanobot.session.goal_state import (
goal_state_runtime_lines, goal_state_runtime_lines,
runner_wall_llm_timeout_s, 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: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
extra.update(kwargs) extra.update(kwargs)
text = msg.content if isinstance(msg.content, str) else "" 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: if text_override is not None:
text = text_override text = text_override
extra.update(cron_extra) extra.update(automation_extra)
session.add_message("user", text, **extra) session.add_message("user", text, **extra)
self._mark_pending_user_turn(session) self._mark_pending_user_turn(session)
self.sessions.save(session) self.sessions.save(session)

View File

@ -5,16 +5,43 @@ from __future__ import annotations
from typing import Any, Mapping from typing import Any, Mapping
from nanobot.cron.types import CronJob 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_TRIGGER_META = "_cron_trigger"
CRON_DEFER_UNTIL_IDLE_META = "_cron_defer_until_session_idle" CRON_DEFER_UNTIL_IDLE_META = "_cron_defer_until_session_idle"
CRON_HISTORY_META = "_cron_turn" 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: def cron_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
"""Return structured cron trigger metadata when present.""" """Return structured cron trigger metadata when present."""
raw = (metadata or {}).get(CRON_TRIGGER_META) return automation_trigger(metadata, CRON_AUTOMATION_SPEC)
return raw if isinstance(raw, dict) else None
def is_cron_turn(metadata: Mapping[str, Any] | None) -> bool: 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]]: 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.""" """Return session-history text/metadata overrides for a cron turn."""
trigger = cron_trigger(metadata) return automation_history_overrides_for_spec(metadata, CRON_AUTOMATION_SPEC)
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"),
}
def is_bound_cron_job(job: CronJob) -> bool: def is_bound_cron_job(job: CronJob) -> bool:

View File

@ -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())

View File

@ -30,8 +30,8 @@ from nanobot.bus.runtime_events import (
TurnCompleted, TurnCompleted,
TurnRunStatusChanged, TurnRunStatusChanged,
) )
from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.providers.base import LLMProvider 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.goal_state import goal_state_ws_blob
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
from nanobot.utils.helpers import strip_think, truncate_text 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: for message in session.messages:
if message.get("_command") is True: if message.get("_command") is True:
continue continue
if message.get(CRON_HISTORY_META) is True: if is_automation_history_message(message):
continue continue
role = message.get("role") role = message.get("role")
content = message.get("content") content = message.get("content")

View File

@ -10,12 +10,11 @@ from loguru import logger
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.triggers.session_turns import EXTERNAL_TRIGGER_META
from nanobot.triggers.store import ExternalTriggerStore from nanobot.triggers.store import ExternalTriggerStore
from nanobot.triggers.types import ExternalTrigger, TriggerDelivery from nanobot.triggers.types import ExternalTrigger, TriggerDelivery
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY 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( async def run_external_trigger_queue(
*, *,

View File

@ -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,
)

View File

@ -5,8 +5,8 @@ from __future__ import annotations
from collections.abc import Collection from collections.abc import Collection
from typing import Any, Protocol from typing import Any, Protocol
from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.cron.types import CronJob 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.session.manager import _message_preview_text
from nanobot.triggers.types import ExternalTrigger from nanobot.triggers.types import ExternalTrigger
@ -326,7 +326,7 @@ def _session_preview(messages: Any) -> str:
for message in messages: for message in messages:
if not isinstance(message, dict): if not isinstance(message, dict):
continue continue
if message.get(CRON_HISTORY_META) is True: if is_automation_history_message(message):
continue continue
text = _message_preview_text(message) text = _message_preview_text(message)
if not text: if not text:

View File

@ -16,7 +16,7 @@ from typing import Any
from loguru import logger from loguru import logger
from nanobot.config.paths import get_webui_dir 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 ( from nanobot.session.manager import (
_SESSION_LIST_PREVIEW_MAX_CHARS, _SESSION_LIST_PREVIEW_MAX_CHARS,
_SESSION_LIST_PREVIEW_MAX_RECORDS, _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 or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
): ):
break break
if item.get(CRON_HISTORY_META) is True: if is_automation_history_message(item):
continue continue
text = _message_preview_text(item) text = _message_preview_text(item)
if not text: 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: 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 return None
if item.get("role") not in _VISIBLE_TRANSCRIPT_ROLES: if item.get("role") not in _VISIBLE_TRANSCRIPT_ROLES:
return None return None
@ -296,7 +296,9 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
): ):
preview_done = True preview_done = True
continue continue
if item.get(CRON_HISTORY_META) is True: if item.get("_type") == "metadata":
continue
if is_automation_history_message(item):
continue continue
text = _message_preview_text(item) text = _message_preview_text(item)
if not text: if not text:

View File

@ -17,7 +17,7 @@ from urllib.parse import unquote, urlparse
from loguru import logger from loguru import logger
from nanobot.config.paths import get_webui_dir 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.session.manager import SessionManager
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY 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: def webui_message_source(metadata: dict[str, Any] | None) -> dict[str, str] | None:
raw = (metadata or {}).get(WEBUI_MESSAGE_SOURCE_METADATA_KEY) 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 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") label = raw.get("label")
if isinstance(label, str) and label.strip(): if isinstance(label, str) and label.strip():
source["label"] = 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) target_chat_id = _chat_id_from_session_key(target_key)
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
for msg in messages: for msg in messages:
if is_automation_history_message(msg):
continue
role = msg.get("role") role = msg.get("role")
content = msg.get("content") content = msg.get("content")
text = content if isinstance(content, str) else "" text = content if isinstance(content, str) else ""
@ -855,7 +860,7 @@ def _session_user_event(
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
if message.get("role") != "user": if message.get("role") != "user":
return None return None
if message.get(CRON_HISTORY_META) is True: if is_automation_history_message(message):
return None return None
content = message.get("content") content = message.get("content")
text = content if isinstance(content, str) else "" 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]: def _source_fields(rec: dict[str, Any]) -> dict[str, Any]:
source = rec.get("source") source = rec.get("source")
if not isinstance(source, dict) or source.get("kind") != "cron": if not isinstance(source, dict):
return {} 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") label = source.get("label")
if isinstance(label, str) and label.strip(): if isinstance(label, str) and label.strip():
out["source"]["label"] = label.strip() out["source"]["label"] = label.strip()

View File

@ -18,6 +18,7 @@ from nanobot.bus.outbound_events import (
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
from nanobot.providers.base import LLMResponse 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.goal_state import GOAL_STATE_KEY
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
from nanobot.session.turn_continuation import ( from nanobot.session.turn_continuation import (
@ -33,6 +34,7 @@ from nanobot.session.webui_turns import (
clean_generated_title, clean_generated_title,
maybe_generate_webui_title, maybe_generate_webui_title,
) )
from nanobot.triggers.session_turns import EXTERNAL_TRIGGER_META
from nanobot.utils.llm_runtime import LLMRuntime 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 assert persisted is True
message = session.messages[-1] message = session.messages[-1]
assert message["content"] == "Scheduled cron job triggered: Daily check" 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 message[CRON_HISTORY_META] is True
assert CRON_TRIGGER_META not in message assert CRON_TRIGGER_META not in message
assert message["cron_job_id"] == "job-1" 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 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: def test_clean_generated_title_strips_reasoning_tags() -> None:
assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish" assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish"
assert clean_generated_title("Title: <think> The user said hello") == "" assert clean_generated_title("Title: <think> The user said hello") == ""

View File

@ -2975,3 +2975,49 @@ def test_handle_webui_thread_get_does_not_backfill_cron_internal_prompt(
body = json.loads(resp.body.decode()) body = json.loads(resp.body.decode())
assert [message["role"] for message in body["messages"]] == ["assistant"] assert [message["role"] for message in body["messages"]] == ["assistant"]
assert [message["content"] for message in body["messages"]] == ["提醒已经到期。"] 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。"]

View File

@ -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"} 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( def test_build_response_restores_session_users_for_legacy_transcript(
tmp_path, tmp_path,
monkeypatch, monkeypatch,

View File

@ -6,6 +6,7 @@ from pathlib import Path
import nanobot.webui.session_list_index as session_list_index import nanobot.webui.session_list_index as session_list_index
from nanobot.cron.session_turns import CRON_HISTORY_META from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
from nanobot.session.manager import SessionManager 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"] == "提醒已经到期。" 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( def test_webui_session_list_uses_webui_transcript_activity_for_sort(
tmp_path: Path, tmp_path: Path,
monkeypatch, monkeypatch,