diff --git a/docs/README.md b/docs/README.md index 289bba1b..e551f41b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,7 +51,7 @@ If a local `nanobot agent` session can already answer normally, you can also ask |---|---|---| | Open the bundled browser UI | [`webui.md`](./webui.md) | WebUI on port `8765`, chat workspace, Apps, Skills, Automations, and settings | | Connect Telegram, Discord, WeChat, Slack, and other apps | [`chat-apps.md`](./chat-apps.md) | A gateway-backed chat channel with access control | -| Use slash commands and automations | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, external triggers, heartbeat tasks, and chat-side controls | +| Use slash commands and automations | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, local triggers, heartbeat tasks, and chat-side controls | | Generate images | [`image-generation.md`](./image-generation.md) | Image provider config, WebUI image mode, and artifact behavior | | Run several isolated bots | [`multiple-instances.md`](./multiple-instances.md) | Separate configs, workspaces, ports, and sessions | | Deploy outside a terminal | [`deployment.md`](./deployment.md) | Docker, systemd user services, and macOS LaunchAgent setup | diff --git a/docs/chat-commands.md b/docs/chat-commands.md index e93c1f8b..02801c0e 100644 --- a/docs/chat-commands.md +++ b/docs/chat-commands.md @@ -16,8 +16,8 @@ These commands work inside chat channels and interactive agent sessions: | `/dream-restore` | List recent Dream memory versions | | `/dream-restore ` | Restore memory to the state before a specific change | | `/skill` | List enabled skills and their descriptions | -| `/trigger` | Show external trigger usage | -| `/trigger ` | Create a named local external trigger for the current chat/session | +| `/trigger` | Show local trigger usage | +| `/trigger ` | Create a named local trigger for the current chat/session | | `/pairing` | List pending pairing requests | | `/pairing approve ` | Approve a pairing code | | `/pairing deny ` | Deny a pending pairing request | @@ -57,7 +57,7 @@ To switch presets for future turns: Preset names come from the top-level `modelPresets` config. Switching is runtime-only: it does not rewrite `config.json`, and an in-progress turn keeps using the model it started with. See [Configuration: Model presets](./configuration.md#model-presets) for setup details. -## External Triggers +## Local triggers Use `/trigger ` when a local script or another service should be able to send a message into the current chat/session later. A name is required; plain diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2f8da0db..02137786 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -13,7 +13,7 @@ Use this page when you know what you want to run and need the command shape. For | Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work | | Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` | | Use WebUI or chat apps | `nanobot gateway` | Keep this terminal running, or use `nanobot gateway --background` | -| Deliver a local external trigger | `nanobot trigger "message"` | Created first with `/trigger ` in the target chat/session | +| Deliver a local trigger | `nanobot trigger "message"` | Created first with `/trigger ` in the target chat/session | | Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` | | Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` | | Log in to QR/OAuth-style channels | `nanobot channels login ` | Used by channels such as WhatsApp and WeChat | diff --git a/docs/concepts.md b/docs/concepts.md index c782cdd0..6cc8a597 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -123,7 +123,7 @@ Tools are discovered automatically from built-in modules and plugin entry points - shell execution with configurable sandboxing; - web search and web fetch with SSRF checks; - MCP servers; -- cron reminders, external triggers, and heartbeat tasks; +- cron reminders, local triggers, and heartbeat tasks; - image generation; - subagents and runtime self-inspection. @@ -143,7 +143,7 @@ User-created reminders use the same cron service but are not the same as the protected heartbeat system job. They run as scheduled turns in their origin chat/session and normally deliver the result back to that channel. -External triggers are also session-bound, but they do not have their own +Local triggers are also session-bound, but they do not have their own schedule. Create one from the target chat with `/trigger `, then call `nanobot trigger ""` when a local script or external service wants nanobot to respond in that session. Webhook servers, third-party auth, and diff --git a/docs/webui.md b/docs/webui.md index 7a36bcba..8f3fedfa 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -56,7 +56,7 @@ Enter `tokenIssueSecret` when the WebUI asks for a password. | Composer | Send text, images, voice input, slash commands, and `@` mentions for Apps or MCP presets | | Apps | Install, test, update, and use local CLI App adapters and MCP presets | | Skills | Inspect available built-in and workspace skills before relying on them | -| Automations | Review, search, run, pause, edit, and delete scheduled and external-trigger agent turns | +| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns | | Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options | ## Chat Workspace @@ -125,7 +125,7 @@ There are two user-facing automation types: - Scheduled automations, created by the agent's cron tool, run at a time, interval, or cron expression. -- External triggers, created with `/trigger `, run when you call a local +- Local triggers, created with `/trigger `, run when you call a local command such as `nanobot trigger trg_8K4P2Q9X "Review PR #4502"`. If a GitHub webhook, CI system, or another service should wake nanobot up, keep @@ -148,7 +148,7 @@ Use the Automations view to: - Sort by next run, last run, updated time, or name. - Run scheduled automations now. - Pause or resume, rename, or delete user-created automations. -- Copy the CLI command for external triggers. +- Copy the CLI command for local triggers. - Inspect protected system automations without changing them. Search accepts plain text and field filters such as `name:backup`, @@ -159,7 +159,7 @@ An automation without a linked chat cannot be enabled or run from the WebUI, because nanobot would not know where to deliver the scheduled turn. Recreate it from the target chat or channel so the automation has complete context. -External triggers do not have a WebUI "Run now" action because each run needs a +Local triggers do not have a WebUI "Run now" action because each run needs a message. Use the copied `nanobot trigger ...` command and replace `"message"` with the content that should be delivered. diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 499a0c4f..7f1b0fe0 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -68,7 +68,7 @@ class ChannelManager: *, session_manager: "SessionManager | None" = None, cron_service: Any | None = None, - external_trigger_store: Any | None = None, + local_trigger_store: Any | None = None, webui_runtime_model_name: Callable[[], str | None] | None = None, webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None, webui_static_dist: bool = True, @@ -79,7 +79,7 @@ class ChannelManager: self.bus = bus self._session_manager = session_manager self._cron_service = cron_service - self._external_trigger_store = external_trigger_store + self._local_trigger_store = local_trigger_store self._webui_runtime_model_name = webui_runtime_model_name self._webui_cron_pending_job_ids = webui_cron_pending_job_ids self._webui_static_dist = webui_static_dist @@ -141,7 +141,7 @@ class ChannelManager: runtime_surface=self._webui_runtime_surface, runtime_capabilities_overrides=self._webui_runtime_capabilities, cron_service=self._cron_service, - external_trigger_store=self._external_trigger_store, + local_trigger_store=self._local_trigger_store, cron_pending_job_ids=self._webui_cron_pending_job_ids, logger=logger, ) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 7e424a0a..7dcb5b21 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -772,8 +772,8 @@ def trigger( config: str | None = typer.Option(None, "--config", "-c", help="Config file path"), ): """Deliver a local trigger message to its bound chat session.""" - from nanobot.triggers.store import ( - ExternalTriggerStore, + from nanobot.triggers.local_store import ( + LocalTriggerStore, TriggerDisabledError, TriggerNotFoundError, TriggerStoreError, @@ -781,7 +781,7 @@ def trigger( runtime_config = _load_runtime_config(config, workspace) content = _read_trigger_cli_message(message) - store = ExternalTriggerStore(runtime_config.workspace_path) + store = LocalTriggerStore(runtime_config.workspace_path) try: delivery = store.enqueue(trigger_id, content) except (TriggerNotFoundError, TriggerDisabledError) as exc: @@ -909,8 +909,8 @@ def _run_gateway( from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.session.manager import SessionManager from nanobot.session.webui_turns import WebuiTurnCoordinator - from nanobot.triggers.runner import run_external_trigger_queue - from nanobot.triggers.store import ExternalTriggerStore + from nanobot.triggers.local_runner import run_local_trigger_queue + from nanobot.triggers.local_store import LocalTriggerStore from nanobot.webui.token_usage import TokenUsageHook port = port if port is not None else config.gateway.port @@ -933,7 +933,7 @@ def _run_gateway( # Create cron service with workspace-scoped store cron_store_path = config.workspace_path / "cron" / "jobs.json" cron = CronService(cron_store_path) - trigger_store = ExternalTriggerStore(config.workspace_path) + trigger_store = LocalTriggerStore(config.workspace_path) # Create agent with cron service agent = AgentLoop.from_config( @@ -954,7 +954,7 @@ def _run_gateway( sessions=session_manager, schedule_background=lambda coro: agent._schedule_background(coro), ).subscribe(runtime_events) - agent.external_trigger_store = trigger_store + agent.local_trigger_store = trigger_store from nanobot.bus.events import OutboundMessage from nanobot.session.keys import session_key_for_channel @@ -1151,7 +1151,7 @@ def _run_gateway( bus, session_manager=session_manager, cron_service=cron, - external_trigger_store=trigger_store, + local_trigger_store=trigger_store, webui_runtime_model_name=_webui_runtime_model_name, webui_cron_pending_job_ids=getattr(agent, "pending_cron_job_ids_for_session", None), webui_static_dist=webui_static_dist, @@ -1295,8 +1295,8 @@ def _run_gateway( asyncio.create_task(agent.run(), name="nanobot-agent-loop"), asyncio.create_task(channels.start_all(), name="nanobot-channels"), asyncio.create_task( - run_external_trigger_queue(store=trigger_store, bus=bus), - name="nanobot-external-triggers", + run_local_trigger_queue(store=trigger_store, bus=bus), + name="nanobot-local-triggers", ), ] if health_server_enabled: diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index c2dd0db1..015099c7 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -740,7 +740,7 @@ async def cmd_trigger(ctx: CommandContext) -> OutboundMessage: metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, ) - from nanobot.triggers.store import ExternalTriggerStore + from nanobot.triggers.local_store import LocalTriggerStore loop = ctx.loop workspace = getattr(loop, "workspace", None) @@ -749,9 +749,9 @@ async def cmd_trigger(ctx: CommandContext) -> OutboundMessage: if workspace is None: raise RuntimeError("workspace unavailable for trigger creation") - store = getattr(loop, "external_trigger_store", None) + store = getattr(loop, "local_trigger_store", None) if store is None: - store = ExternalTriggerStore(workspace) + store = LocalTriggerStore(workspace) trigger = store.create( name=name, diff --git a/nanobot/session/automation_turns.py b/nanobot/session/automation_turns.py index 116ac9c3..ebd73c57 100644 --- a/nanobot/session/automation_turns.py +++ b/nanobot/session/automation_turns.py @@ -56,9 +56,9 @@ def automation_history_overrides_for_spec( 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 + from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_AUTOMATION_SPEC - return (CRON_AUTOMATION_SPEC, EXTERNAL_TRIGGER_AUTOMATION_SPEC) + return (CRON_AUTOMATION_SPEC, LOCAL_TRIGGER_AUTOMATION_SPEC) def automation_history_overrides( @@ -87,4 +87,6 @@ def is_automation_history_message(message: Mapping[str, Any] | None) -> bool: def is_automation_kind(value: Any) -> bool: - return isinstance(value, str) and any(spec.kind == value for spec in _automation_specs()) + return isinstance(value, str) and ( + value == "trigger" or any(spec.kind == value for spec in _automation_specs()) + ) diff --git a/nanobot/triggers/__init__.py b/nanobot/triggers/__init__.py index 7a47510f..4dca3847 100644 --- a/nanobot/triggers/__init__.py +++ b/nanobot/triggers/__init__.py @@ -1,16 +1,16 @@ -"""Local external trigger support.""" +"""Local trigger support.""" -from nanobot.triggers.store import ( - ExternalTriggerStore, +from nanobot.triggers.local_store import ( + LocalTriggerStore, TriggerDisabledError, TriggerNotFoundError, TriggerStoreError, ) -from nanobot.triggers.types import ExternalTrigger, TriggerDelivery, TriggerRunRecord +from nanobot.triggers.local_types import LocalTrigger, TriggerDelivery, TriggerRunRecord __all__ = [ - "ExternalTrigger", - "ExternalTriggerStore", + "LocalTrigger", + "LocalTriggerStore", "TriggerDelivery", "TriggerDisabledError", "TriggerNotFoundError", diff --git a/nanobot/triggers/runner.py b/nanobot/triggers/local_runner.py similarity index 86% rename from nanobot/triggers/runner.py rename to nanobot/triggers/local_runner.py index b1eea405..5e213b68 100644 --- a/nanobot/triggers/runner.py +++ b/nanobot/triggers/local_runner.py @@ -1,4 +1,4 @@ -"""Gateway delivery loop for local external triggers.""" +"""Gateway delivery loop for local triggers.""" from __future__ import annotations @@ -10,21 +10,21 @@ 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.triggers.local_session_turns import LOCAL_TRIGGER_META +from nanobot.triggers.local_store import LocalTriggerStore +from nanobot.triggers.local_types import LocalTrigger, TriggerDelivery from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY -async def run_external_trigger_queue( +async def run_local_trigger_queue( *, - store: ExternalTriggerStore, + store: LocalTriggerStore, bus: MessageBus, poll_interval_s: float = 0.5, batch_size: int = 20, ) -> None: """Poll local trigger deliveries and publish them as normal inbound messages.""" - logger.info("External trigger queue started") + logger.info("Local trigger queue started") recovered = store.recover_processing_deliveries() if recovered: logger.warning( @@ -80,7 +80,7 @@ class _TerminalDeliveryError(RuntimeError): async def _publish_delivery( - store: ExternalTriggerStore, + store: LocalTriggerStore, bus: MessageBus, delivery: TriggerDelivery, ) -> None: @@ -107,9 +107,9 @@ async def _publish_delivery( ) -def _delivery_metadata(trigger: ExternalTrigger, delivery: TriggerDelivery) -> dict[str, Any]: +def _delivery_metadata(trigger: LocalTrigger, delivery: TriggerDelivery) -> dict[str, Any]: metadata = dict(trigger.origin_metadata or {}) - metadata[EXTERNAL_TRIGGER_META] = { + metadata[LOCAL_TRIGGER_META] = { "trigger_id": trigger.id, "trigger_name": trigger.name, "delivery_id": delivery.id, @@ -118,7 +118,7 @@ def _delivery_metadata(trigger: ExternalTrigger, delivery: TriggerDelivery) -> d if trigger.channel == "websocket": metadata.pop(WEBUI_TURN_METADATA_KEY, None) metadata[WEBUI_TURN_METADATA_KEY] = f"trigger:{trigger.id}:{uuid.uuid4().hex}" - source: dict[str, str] = {"kind": "trigger"} + source: dict[str, str] = {"kind": "local_trigger"} if trigger.name: source["label"] = trigger.name metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] = source diff --git a/nanobot/triggers/local_session_turns.py b/nanobot/triggers/local_session_turns.py new file mode 100644 index 00000000..771e3745 --- /dev/null +++ b/nanobot/triggers/local_session_turns.py @@ -0,0 +1,51 @@ +"""Shared metadata helpers for local 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, +) + +LOCAL_TRIGGER_META = "_local_trigger" + + +def _local_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"Local trigger received: {label}" + if isinstance(label, str) and label.strip() + else "Local trigger received" + ) + + +LOCAL_TRIGGER_AUTOMATION_SPEC = AutomationTurnSpec( + kind="local_trigger", + trigger_meta_key=LOCAL_TRIGGER_META, + history_fields={ + "trigger_id": "trigger_id", + "trigger_name": "trigger_name", + "trigger_delivery_id": "delivery_id", + }, + text_builder=_local_trigger_history_text, +) + + +def local_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None: + """Return structured local trigger metadata when present.""" + return automation_trigger(metadata, LOCAL_TRIGGER_AUTOMATION_SPEC) + + +def local_trigger_history_overrides( + metadata: Mapping[str, Any] | None, +) -> tuple[str | None, dict[str, Any]]: + """Return session-history text/metadata overrides for a local trigger turn.""" + return automation_history_overrides_for_spec( + metadata, + LOCAL_TRIGGER_AUTOMATION_SPEC, + ) diff --git a/nanobot/triggers/store.py b/nanobot/triggers/local_store.py similarity index 94% rename from nanobot/triggers/store.py rename to nanobot/triggers/local_store.py index 5f081ed5..41b5e5d0 100644 --- a/nanobot/triggers/store.py +++ b/nanobot/triggers/local_store.py @@ -14,7 +14,7 @@ from typing import Any from filelock import FileLock from loguru import logger -from nanobot.triggers.types import ExternalTrigger, TriggerDelivery, TriggerRunRecord +from nanobot.triggers.local_types import LocalTrigger, TriggerDelivery, TriggerRunRecord _TRIGGER_ID_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" _MAX_RUN_HISTORY = 20 @@ -34,7 +34,7 @@ class TriggerDisabledError(TriggerStoreError): """Raised when a trigger is disabled.""" -class ExternalTriggerStore: +class LocalTriggerStore: """Persistent local triggers for one workspace.""" def __init__(self, workspace_path: Path): @@ -55,8 +55,8 @@ class ExternalTriggerStore: session_key: str, sender_id: str = "trigger", origin_metadata: dict[str, Any] | None = None, - ) -> ExternalTrigger: - """Create a new session-bound external trigger.""" + ) -> LocalTrigger: + """Create a new session-bound local trigger.""" clean_name = _clean_name(name) channel = channel.strip() chat_id = chat_id.strip() @@ -70,7 +70,7 @@ class ExternalTriggerStore: triggers = self._load_triggers_unlocked() existing_ids = {trigger.id for trigger in triggers} trigger_id = _new_trigger_id(existing_ids) - trigger = ExternalTrigger( + trigger = LocalTrigger( id=trigger_id, name=clean_name, enabled=True, @@ -86,7 +86,7 @@ class ExternalTriggerStore: self._save_triggers_unlocked(triggers) return trigger - def list_triggers(self, *, include_disabled: bool = False) -> list[ExternalTrigger]: + def list_triggers(self, *, include_disabled: bool = False) -> list[LocalTrigger]: """List triggers in this workspace.""" self._ensure_dirs() with self._lock: @@ -100,7 +100,7 @@ class ExternalTriggerStore: session_key: str, *, include_disabled: bool = True, - ) -> list[ExternalTrigger]: + ) -> list[LocalTrigger]: """List triggers bound to one session key.""" return [ trigger @@ -108,13 +108,13 @@ class ExternalTriggerStore: if trigger.session_key == session_key ] - def get(self, trigger_id: str) -> ExternalTrigger | None: + def get(self, trigger_id: str) -> LocalTrigger | None: """Return one trigger by ID.""" self._ensure_dirs() with self._lock: return self._find_unlocked(self._load_triggers_unlocked(), trigger_id) - def enable(self, trigger_id: str, *, enabled: bool) -> ExternalTrigger | None: + def enable(self, trigger_id: str, *, enabled: bool) -> LocalTrigger | None: """Enable or disable a trigger.""" self._ensure_dirs() with self._lock: @@ -127,7 +127,7 @@ class ExternalTriggerStore: self._save_triggers_unlocked(triggers) return trigger - def update(self, trigger_id: str, *, name: str | None = None) -> ExternalTrigger | None: + def update(self, trigger_id: str, *, name: str | None = None) -> LocalTrigger | None: """Update mutable trigger fields.""" self._ensure_dirs() with self._lock: @@ -267,13 +267,13 @@ class ExternalTriggerStore: self.processing_dir.mkdir(parents=True, exist_ok=True) self.failed_dir.mkdir(parents=True, exist_ok=True) - def _load_triggers_unlocked(self) -> list[ExternalTrigger]: + def _load_triggers_unlocked(self) -> list[LocalTrigger]: if not self.store_path.exists(): return [] try: data = json.loads(self.store_path.read_text(encoding="utf-8")) return [ - ExternalTrigger.from_dict(raw) + LocalTrigger.from_dict(raw) for raw in data.get("triggers", []) if isinstance(raw, dict) ] @@ -288,7 +288,7 @@ class ExternalTriggerStore: "as a .corrupt- backup" ) from exc - def _save_triggers_unlocked(self, triggers: list[ExternalTrigger]) -> None: + def _save_triggers_unlocked(self, triggers: list[LocalTrigger]) -> None: payload = { "version": 1, "triggers": [trigger.to_dict() for trigger in triggers], @@ -297,9 +297,9 @@ class ExternalTriggerStore: @staticmethod def _find_unlocked( - triggers: list[ExternalTrigger], + triggers: list[LocalTrigger], trigger_id: str, - ) -> ExternalTrigger | None: + ) -> LocalTrigger | None: return next((trigger for trigger in triggers if trigger.id == trigger_id), None) def _move_bad_delivery_unlocked(self, path: Path) -> None: @@ -356,7 +356,7 @@ def _new_trigger_id(existing_ids: set[str]) -> str: def _clean_name(name: str) -> str: stripped = " ".join(name.strip().split()) - return (stripped or "External trigger")[:120] + return (stripped or "Local trigger")[:120] def _now_ms() -> int: diff --git a/nanobot/triggers/types.py b/nanobot/triggers/local_types.py similarity index 96% rename from nanobot/triggers/types.py rename to nanobot/triggers/local_types.py index 93f3d14b..ca16bc78 100644 --- a/nanobot/triggers/types.py +++ b/nanobot/triggers/local_types.py @@ -1,4 +1,4 @@ -"""Persistent types for local external triggers.""" +"""Persistent types for local triggers.""" from __future__ import annotations @@ -40,7 +40,7 @@ class TriggerRunRecord: @dataclass -class ExternalTrigger: +class LocalTrigger: """A session-bound local trigger.""" id: str @@ -59,7 +59,7 @@ class ExternalTrigger: run_history: list[TriggerRunRecord] = field(default_factory=list) @classmethod - def from_dict(cls, data: dict[str, Any]) -> "ExternalTrigger": + def from_dict(cls, data: dict[str, Any]) -> "LocalTrigger": history = [ record if isinstance(record, TriggerRunRecord) else TriggerRunRecord.from_dict(record) for record in data.get("runHistory", data.get("run_history", [])) diff --git a/nanobot/triggers/session_turns.py b/nanobot/triggers/session_turns.py deleted file mode 100644 index eeb36759..00000000 --- a/nanobot/triggers/session_turns.py +++ /dev/null @@ -1,51 +0,0 @@ -"""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/gateway_services.py b/nanobot/webui/gateway_services.py index b6ae3a2b..4fdcc8e0 100644 --- a/nanobot/webui/gateway_services.py +++ b/nanobot/webui/gateway_services.py @@ -26,7 +26,7 @@ class GatewayServices: workspaces: WebUIWorkspaceController session_manager: Any | None cron_service: Any | None - external_trigger_store: Any | None + local_trigger_store: Any | None cron_pending_job_ids: Callable[[str], set[str]] | None @@ -43,7 +43,7 @@ def build_gateway_services( runtime_capabilities_overrides: dict[str, Any] | None, disabled_skills: set[str] | None = None, cron_service: Any | None = None, - external_trigger_store: Any | None = None, + local_trigger_store: Any | None = None, cron_pending_job_ids: Callable[[str], set[str]] | None = None, logger: Any = default_logger, ) -> GatewayServices: @@ -72,7 +72,7 @@ def build_gateway_services( skills_workspace_path=workspace_path, disabled_skills=disabled_skills, cron_service=cron_service, - external_trigger_store=external_trigger_store, + local_trigger_store=local_trigger_store, cron_pending_job_ids=cron_pending_job_ids, log=logger, ) @@ -84,6 +84,6 @@ def build_gateway_services( workspaces=workspaces, session_manager=session_manager, cron_service=cron_service, - external_trigger_store=external_trigger_store, + local_trigger_store=local_trigger_store, cron_pending_job_ids=cron_pending_job_ids, ) diff --git a/nanobot/webui/session_automations.py b/nanobot/webui/session_automations.py index 51de158d..3ca5d718 100644 --- a/nanobot/webui/session_automations.py +++ b/nanobot/webui/session_automations.py @@ -8,9 +8,9 @@ from typing import Any, Protocol 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 +from nanobot.triggers.local_types import LocalTrigger -AutomationJob = CronJob | ExternalTrigger +AutomationJob = CronJob | LocalTrigger class _CronServiceLike(Protocol): @@ -24,15 +24,15 @@ class _CronServiceLike(Protocol): ) -> list[CronJob]: ... -class _ExternalTriggerStoreLike(Protocol): - def list_triggers(self, *, include_disabled: bool = False) -> list[ExternalTrigger]: ... +class _LocalTriggerStoreLike(Protocol): + def list_triggers(self, *, include_disabled: bool = False) -> list[LocalTrigger]: ... def list_for_session( self, session_key: str, *, include_disabled: bool = True, - ) -> list[ExternalTrigger]: ... + ) -> list[LocalTrigger]: ... class _SessionManagerLike(Protocol): @@ -43,7 +43,7 @@ def session_automation_jobs( cron_service: _CronServiceLike | None, session_key: str, *, - external_trigger_store: _ExternalTriggerStoreLike | None = None, + local_trigger_store: _LocalTriggerStoreLike | None = None, ) -> list[AutomationJob]: """Return user automations attached to the WebUI session.""" jobs: list[AutomationJob] = [] @@ -54,9 +54,9 @@ def session_automation_jobs( include_disabled=True, ) ) - if external_trigger_store is not None: + if local_trigger_store is not None: jobs.extend( - external_trigger_store.list_for_session( + local_trigger_store.list_for_session( session_key, include_disabled=True, ) @@ -68,7 +68,7 @@ def session_automations_payload( cron_service: _CronServiceLike | None, session_key: str, *, - external_trigger_store: _ExternalTriggerStoreLike | None = None, + local_trigger_store: _LocalTriggerStoreLike | None = None, pending_job_ids: Collection[str] | None = None, ) -> dict[str, Any]: """Return user-created automation jobs attached to a WebUI session.""" @@ -77,7 +77,7 @@ def session_automations_payload( session_automation_jobs( cron_service, session_key, - external_trigger_store=external_trigger_store, + local_trigger_store=local_trigger_store, ), pending_job_ids=pending_job_ids, ) @@ -87,7 +87,7 @@ def session_automations_payload( def all_automations_payload( cron_service: _CronServiceLike | None, *, - external_trigger_store: _ExternalTriggerStoreLike | None = None, + local_trigger_store: _LocalTriggerStoreLike | None = None, session_manager: _SessionManagerLike | None = None, pending_job_ids: Collection[str] | None = None, ) -> dict[str, Any]: @@ -95,8 +95,8 @@ def all_automations_payload( jobs: list[AutomationJob] = [] if cron_service is not None: jobs.extend(cron_service.list_jobs(include_disabled=True)) - if external_trigger_store is not None: - jobs.extend(external_trigger_store.list_triggers(include_disabled=True)) + if local_trigger_store is not None: + jobs.extend(local_trigger_store.list_triggers(include_disabled=True)) return { "jobs": serialize_automation_jobs( jobs, @@ -132,7 +132,7 @@ def _serialize_job( include_details: bool = False, session_manager: _SessionManagerLike | None = None, ) -> dict[str, Any]: - if isinstance(job, ExternalTrigger): + if isinstance(job, LocalTrigger): return _serialize_trigger( job, include_details=include_details, @@ -187,7 +187,7 @@ def _serialize_job( def _serialize_trigger( - trigger: ExternalTrigger, + trigger: LocalTrigger, *, include_details: bool = False, session_manager: _SessionManagerLike | None = None, @@ -197,16 +197,16 @@ def _serialize_trigger( "id": trigger.id, "name": trigger.name, "enabled": trigger.enabled, - "kind": "external_trigger", + "kind": "local_trigger", "schedule": { - "kind": "external", + "kind": "local", "at_ms": None, "every_ms": None, "expr": None, "tz": None, }, "payload": { - "kind": "external_trigger", + "kind": "local_trigger", "message": command, "command": command, }, @@ -273,7 +273,7 @@ def _origin_payload( def _trigger_origin_payload( - trigger: ExternalTrigger, + trigger: LocalTrigger, session_manager: _SessionManagerLike | None, ) -> dict[str, Any] | None: channel = trigger.channel diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 83a58e97..ee1a3133 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -26,7 +26,7 @@ from websockets.http11 import Response from nanobot.command.builtin import builtin_command_palette from nanobot.cron.session_turns import is_bound_cron_job from nanobot.cron.types import CronJob, CronSchedule -from nanobot.triggers.types import ExternalTrigger +from nanobot.triggers.local_types import LocalTrigger from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload @@ -90,7 +90,7 @@ if TYPE_CHECKING: from nanobot.bus.queue import MessageBus from nanobot.cron.service import CronService from nanobot.session.manager import SessionManager - from nanobot.triggers.store import ExternalTriggerStore + from nanobot.triggers.local_store import LocalTriggerStore def _decode_api_key(raw_key: str) -> str | None: @@ -155,7 +155,7 @@ class GatewayHTTPHandler: skills_workspace_path: Path, disabled_skills: set[str] | None = None, cron_service: CronService | None = None, - external_trigger_store: ExternalTriggerStore | None = None, + local_trigger_store: LocalTriggerStore | None = None, cron_pending_job_ids: Callable[[str], set[str]] | None = None, log: Any = logger, ) -> None: @@ -170,7 +170,7 @@ class GatewayHTTPHandler: self.skills_workspace_path = skills_workspace_path self.disabled_skills = disabled_skills or set() self.cron_service = cron_service - self.external_trigger_store = external_trigger_store + self.local_trigger_store = local_trigger_store self.cron_pending_job_ids = cron_pending_job_ids self._log = log self._runtime_surface = runtime_surface @@ -494,7 +494,7 @@ class GatewayHTTPHandler: session_automations_payload( self.cron_service, decoded_key, - external_trigger_store=self.external_trigger_store, + local_trigger_store=self.local_trigger_store, pending_job_ids=pending_job_ids, ) ) @@ -514,7 +514,7 @@ class GatewayHTTPHandler: automation_jobs = session_automation_jobs( self.cron_service, decoded_key, - external_trigger_store=self.external_trigger_store, + local_trigger_store=self.local_trigger_store, ) if automation_jobs and delete_automations not in {"1", "true", "yes"}: return _http_json_response( @@ -526,9 +526,9 @@ class GatewayHTTPHandler: ) if automation_jobs: for job in automation_jobs: - if isinstance(job, ExternalTrigger): - if self.external_trigger_store is not None: - self.external_trigger_store.delete(job.id) + if isinstance(job, LocalTrigger): + if self.local_trigger_store is not None: + self.local_trigger_store.delete(job.id) elif self.cron_service is not None: self.cron_service.remove_job(job.id) deleted = self.session_manager.delete_session(decoded_key) @@ -567,7 +567,7 @@ class GatewayHTTPHandler: return _http_json_response( all_automations_payload( self.cron_service, - external_trigger_store=self.external_trigger_store, + local_trigger_store=self.local_trigger_store, session_manager=self.session_manager, pending_job_ids=self._pending_cron_job_ids_for_all(), ) @@ -580,16 +580,16 @@ class GatewayHTTPHandler: ) -> Response: if not self.check_api_token(request): return _http_error(401, "Unauthorized") - if self.cron_service is None and self.external_trigger_store is None: + if self.cron_service is None and self.local_trigger_store is None: return _http_error(503, "automation service unavailable") query = _parse_query(request.path) job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip() if not job_id: return _http_error(400, "missing automation id") - trigger = self.external_trigger_store.get(job_id) if self.external_trigger_store else None + trigger = self.local_trigger_store.get(job_id) if self.local_trigger_store else None if trigger is not None: - return self._handle_external_trigger_action(request, action, trigger) + return self._handle_local_trigger_action(request, action, trigger) if self.cron_service is None: return _http_error(404, "automation not found") @@ -638,34 +638,34 @@ class GatewayHTTPHandler: return self._handle_webui_automations(request) - def _handle_external_trigger_action( + def _handle_local_trigger_action( self, request: WsRequest, action: str, - trigger: ExternalTrigger, + trigger: LocalTrigger, ) -> Response: - if self.external_trigger_store is None: + if self.local_trigger_store is None: return _http_error(503, "trigger service unavailable") if action == "enable": - if self.external_trigger_store.enable(trigger.id, enabled=True) is None: + if self.local_trigger_store.enable(trigger.id, enabled=True) is None: return _http_error(404, "automation not found") elif action == "disable": - if self.external_trigger_store.enable(trigger.id, enabled=False) is None: + if self.local_trigger_store.enable(trigger.id, enabled=False) is None: return _http_error(404, "automation not found") elif action == "delete": - if not self.external_trigger_store.delete(trigger.id): + if not self.local_trigger_store.delete(trigger.id): return _http_error(404, "automation not found") elif action == "run": - return _http_error(409, "external trigger requires a CLI message") + return _http_error(409, "local trigger requires a CLI message") elif action == "update": values = _automation_values_from_request(request) if values is None: return _http_error(400, "invalid automation update payload") - parsed = _parse_external_trigger_update(values) + parsed = _parse_local_trigger_update(values) if isinstance(parsed, str): return _http_error(400, parsed) if parsed: - if self.external_trigger_store.update(trigger.id, **parsed) is None: + if self.local_trigger_store.update(trigger.id, **parsed) is None: return _http_error(404, "automation not found") else: return _http_error(404, "unknown automation action") @@ -884,7 +884,7 @@ def _parse_automation_update( return update -def _parse_external_trigger_update(values: dict[str, Any]) -> dict[str, Any] | str: +def _parse_local_trigger_update(values: dict[str, Any]) -> dict[str, Any] | str: update: dict[str, Any] = {} if "name" in values: raw_name = values.get("name") @@ -896,7 +896,7 @@ def _parse_external_trigger_update(values: dict[str, Any]) -> dict[str, Any] | s update["name"] = name forbidden = [key for key in ("message", "schedule") if key in values] if forbidden: - return "external trigger updates only support name" + return "local trigger updates only support name" return update diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index bfb7a38f..cb46d27f 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -34,7 +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.triggers.local_session_turns import LOCAL_TRIGGER_META from nanobot.utils.llm_runtime import LLMRuntime @@ -118,7 +118,7 @@ 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: +def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path) -> None: loop = _make_full_loop(tmp_path) session = loop.sessions.get_or_create("websocket:auto") @@ -129,7 +129,7 @@ def test_persist_external_trigger_turn_uses_hidden_automation_marker(tmp_path: P chat_id="auto", content="Review PR #4502", metadata={ - EXTERNAL_TRIGGER_META: { + LOCAL_TRIGGER_META: { "trigger_id": "trg_123", "trigger_name": "PR review", "delivery_id": "tdel_456", @@ -142,15 +142,15 @@ def test_persist_external_trigger_turn_uses_hidden_automation_marker(tmp_path: P assert persisted is True message = session.messages[-1] - assert message["content"] == "External trigger received: PR review" + assert message["content"] == "Local trigger received: PR review" assert "Review PR #4502" not in message["content"] assert message[AUTOMATION_HISTORY_META] == { - "kind": "trigger", + "kind": "local_trigger", "trigger_id": "trg_123", "trigger_name": "PR review", "trigger_delivery_id": "tdel_456", } - assert EXTERNAL_TRIGGER_META not in message + assert LOCAL_TRIGGER_META not in message assert message["trigger_id"] == "trg_123" assert message["trigger_name"] == "PR review" assert message["trigger_delivery_id"] == "tdel_456" diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 0fe89ddc..0b26dd61 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -2996,8 +2996,8 @@ def test_handle_webui_thread_get_does_not_backfill_trigger_internal_prompt( session = sessions.get_or_create(key) session.add_message( "user", - "External trigger received: PR review", - **{AUTOMATION_HISTORY_META: {"kind": "trigger", "trigger_id": "trg_123"}}, + "Local trigger received: PR review", + **{AUTOMATION_HISTORY_META: {"kind": "local_trigger", "trigger_id": "trg_123"}}, ) session.add_message("assistant", "PR #4502 已经开始 review。") sessions.save(session) diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 9e5dc0f6..144669b3 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -20,7 +20,7 @@ from nanobot.cron.service import CronService from nanobot.cron.types import CronJob, CronPayload, CronSchedule from nanobot.session.keys import UNIFIED_SESSION_KEY from nanobot.session.manager import Session, SessionManager -from nanobot.triggers.store import ExternalTriggerStore +from nanobot.triggers.local_store import LocalTriggerStore from nanobot.webui.gateway_services import GatewayServices, build_gateway_services _PORT = 29900 @@ -47,7 +47,7 @@ def _make_handler( workspace_path: Path | None = None, runtime_model_name: Any | None = None, cron_service: CronService | None = None, - external_trigger_store: ExternalTriggerStore | None = None, + local_trigger_store: LocalTriggerStore | None = None, cron_pending_job_ids: Any | None = None, ) -> GatewayServices: config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg @@ -63,7 +63,7 @@ def _make_handler( runtime_surface="browser", runtime_capabilities_overrides=None, cron_service=cron_service, - external_trigger_store=external_trigger_store, + local_trigger_store=local_trigger_store, cron_pending_job_ids=cron_pending_job_ids, ) @@ -77,7 +77,7 @@ def _ch( port: int = _PORT, runtime_model_name: Any | None = None, cron_service: CronService | None = None, - external_trigger_store: ExternalTriggerStore | None = None, + local_trigger_store: LocalTriggerStore | None = None, cron_pending_job_ids: Any | None = None, **extra: Any, ) -> WebSocketChannel: @@ -97,7 +97,7 @@ def _ch( workspace_path=workspace_path, runtime_model_name=runtime_model_name, cron_service=cron_service, - external_trigger_store=external_trigger_store, + local_trigger_store=local_trigger_store, cron_pending_job_ids=cron_pending_job_ids, ) return WebSocketChannel(cfg, bus, gateway=gateway) @@ -325,12 +325,12 @@ async def test_session_automations_route_ignores_unified_owner( @pytest.mark.asyncio -async def test_session_automations_route_lists_external_triggers( +async def test_session_automations_route_lists_local_triggers( bus: MagicMock, tmp_path: Path ) -> None: port = _free_port() base_url = f"http://127.0.0.1:{port}" - trigger_store = ExternalTriggerStore(tmp_path) + trigger_store = LocalTriggerStore(tmp_path) trigger = trigger_store.create( name="PR review", channel="websocket", @@ -340,7 +340,7 @@ async def test_session_automations_route_lists_external_triggers( channel = _ch( bus, session_manager=_seed_session(tmp_path, key="websocket:abc"), - external_trigger_store=trigger_store, + local_trigger_store=trigger_store, port=port, ) server_task = asyncio.create_task(channel.start()) @@ -359,9 +359,9 @@ async def test_session_automations_route_lists_external_triggers( body = resp.json() assert [job["id"] for job in body["jobs"]] == [trigger.id] job = body["jobs"][0] - assert job["kind"] == "external_trigger" - assert job["schedule"]["kind"] == "external" - assert job["payload"]["kind"] == "external_trigger" + assert job["kind"] == "local_trigger" + assert job["schedule"]["kind"] == "local" + assert job["payload"]["kind"] == "local_trigger" assert job["payload"]["command"] == f'nanobot trigger {trigger.id} "message"' finally: await channel.stop() @@ -1130,12 +1130,12 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( @pytest.mark.asyncio -async def test_webui_automations_route_manages_external_triggers( +async def test_webui_automations_route_manages_local_triggers( bus: MagicMock, tmp_path: Path ) -> None: port = _free_port() base_url = f"http://127.0.0.1:{port}" - trigger_store = ExternalTriggerStore(tmp_path) + trigger_store = LocalTriggerStore(tmp_path) trigger = trigger_store.create( name="PR review", channel="websocket", @@ -1145,7 +1145,7 @@ async def test_webui_automations_route_manages_external_triggers( channel = _ch( bus, session_manager=_seed_session(tmp_path, key="websocket:abc"), - external_trigger_store=trigger_store, + local_trigger_store=trigger_store, port=port, ) server_task = asyncio.create_task(channel.start()) @@ -1158,7 +1158,7 @@ async def test_webui_automations_route_manages_external_triggers( listed = await _http_get(f"{base_url}/api/webui/automations", headers=auth) assert listed.status_code == 200 by_id = {job["id"]: job for job in listed.json()["jobs"]} - assert by_id[trigger.id]["kind"] == "external_trigger" + assert by_id[trigger.id]["kind"] == "local_trigger" assert by_id[trigger.id]["trigger"]["command"] == f'nanobot trigger {trigger.id} "message"' disabled = await _http_get( @@ -1251,14 +1251,14 @@ async def test_session_delete_blocks_when_bound_automation_exists( @pytest.mark.asyncio -async def test_session_delete_blocks_and_cascades_external_triggers( +async def test_session_delete_blocks_and_cascades_local_triggers( bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) port = _free_port() base_url = f"http://127.0.0.1:{port}" sm = _seed_session(tmp_path, key="websocket:doomed") - trigger_store = ExternalTriggerStore(tmp_path) + trigger_store = LocalTriggerStore(tmp_path) trigger = trigger_store.create( name="PR review", channel="websocket", @@ -1268,7 +1268,7 @@ async def test_session_delete_blocks_and_cascades_external_triggers( channel = _ch( bus, session_manager=sm, - external_trigger_store=trigger_store, + local_trigger_store=trigger_store, port=port, ) server_task = asyncio.create_task(channel.start()) diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index c15a9686..84334e33 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -2520,14 +2520,14 @@ def test_trigger_cli_queues_message_in_workspace( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - from nanobot.triggers.store import ExternalTriggerStore + from nanobot.triggers.local_store import LocalTriggerStore config_file = _write_instance_config(tmp_path) config = Config() config.agents.defaults.workspace = str(tmp_path / "workspace") _patch_cli_command_runtime(monkeypatch, config) - store = ExternalTriggerStore(config.workspace_path) + store = LocalTriggerStore(config.workspace_path) trigger = store.create( name="Review hook", channel="websocket", diff --git a/tests/command/test_trigger_command.py b/tests/command/test_trigger_command.py index f9c6d039..d7320802 100644 --- a/tests/command/test_trigger_command.py +++ b/tests/command/test_trigger_command.py @@ -8,15 +8,15 @@ import pytest from nanobot.bus.events import InboundMessage from nanobot.command.builtin import build_help_text, register_builtin_commands from nanobot.command.router import CommandContext, CommandRouter -from nanobot.triggers.store import ExternalTriggerStore +from nanobot.triggers.local_store import LocalTriggerStore @pytest.mark.asyncio async def test_trigger_command_creates_session_bound_local_trigger(tmp_path: Path) -> None: router = CommandRouter() register_builtin_commands(router) - store = ExternalTriggerStore(tmp_path) - loop = SimpleNamespace(workspace=tmp_path, external_trigger_store=store) + store = LocalTriggerStore(tmp_path) + loop = SimpleNamespace(workspace=tmp_path, local_trigger_store=store) msg = InboundMessage( channel="websocket", sender_id="user", @@ -49,8 +49,8 @@ async def test_trigger_command_creates_session_bound_local_trigger(tmp_path: Pat async def test_trigger_command_without_name_returns_usage_only(tmp_path: Path) -> None: router = CommandRouter() register_builtin_commands(router) - store = ExternalTriggerStore(tmp_path) - loop = SimpleNamespace(workspace=tmp_path, external_trigger_store=store) + store = LocalTriggerStore(tmp_path) + loop = SimpleNamespace(workspace=tmp_path, local_trigger_store=store) msg = InboundMessage( channel="websocket", sender_id="user", diff --git a/tests/triggers/test_external_triggers.py b/tests/triggers/test_local_triggers.py similarity index 80% rename from tests/triggers/test_external_triggers.py rename to tests/triggers/test_local_triggers.py index d4450653..390428e7 100644 --- a/tests/triggers/test_external_triggers.py +++ b/tests/triggers/test_local_triggers.py @@ -7,13 +7,13 @@ from pathlib import Path import pytest from nanobot.bus.events import InboundMessage -from nanobot.triggers.runner import run_external_trigger_queue -from nanobot.triggers.store import ExternalTriggerStore, TriggerDisabledError +from nanobot.triggers.local_runner import run_local_trigger_queue +from nanobot.triggers.local_store import LocalTriggerStore, TriggerDisabledError from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY def test_trigger_store_allows_multiple_triggers_per_session(tmp_path: Path) -> None: - store = ExternalTriggerStore(tmp_path) + store = LocalTriggerStore(tmp_path) first = store.create( name="PR review", @@ -36,7 +36,7 @@ def test_trigger_store_allows_multiple_triggers_per_session(tmp_path: Path) -> N def test_enqueue_rejects_disabled_trigger(tmp_path: Path) -> None: - store = ExternalTriggerStore(tmp_path) + store = LocalTriggerStore(tmp_path) trigger = store.create( name="Disabled", channel="telegram", @@ -50,7 +50,7 @@ def test_enqueue_rejects_disabled_trigger(tmp_path: Path) -> None: def test_recover_processing_deliveries_requeues_claimed_delivery(tmp_path: Path) -> None: - store = ExternalTriggerStore(tmp_path) + store = LocalTriggerStore(tmp_path) trigger = store.create( name="PR review", channel="websocket", @@ -63,9 +63,9 @@ def test_recover_processing_deliveries_requeues_claimed_delivery(tmp_path: Path) assert len(claimed) == 1 assert claimed[0].path is not None assert claimed[0].path.parent.name == "processing" - assert ExternalTriggerStore(tmp_path).claim_deliveries() == [] + assert LocalTriggerStore(tmp_path).claim_deliveries() == [] - restarted = ExternalTriggerStore(tmp_path) + restarted = LocalTriggerStore(tmp_path) assert restarted.recover_processing_deliveries() == 1 reclaimed = restarted.claim_deliveries() @@ -77,8 +77,8 @@ def test_recover_processing_deliveries_requeues_claimed_delivery(tmp_path: Path) @pytest.mark.asyncio -async def test_external_trigger_queue_publishes_bound_inbound_message(tmp_path: Path) -> None: - store = ExternalTriggerStore(tmp_path) +async def test_local_trigger_queue_publishes_bound_inbound_message(tmp_path: Path) -> None: + store = LocalTriggerStore(tmp_path) trigger = store.create( name="PR review", channel="websocket", @@ -94,7 +94,7 @@ async def test_external_trigger_queue_publishes_bound_inbound_message(tmp_path: published.append(msg) task = asyncio.create_task( - run_external_trigger_queue(store=store, bus=_Bus(), poll_interval_s=0.01) + run_local_trigger_queue(store=store, bus=_Bus(), poll_interval_s=0.01) ) try: for _ in range(100): @@ -116,10 +116,10 @@ async def test_external_trigger_queue_publishes_bound_inbound_message(tmp_path: assert msg.metadata[WEBUI_TURN_METADATA_KEY].startswith(f"trigger:{trigger.id}:") assert msg.metadata[WEBUI_TURN_METADATA_KEY] != "old-turn" assert msg.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == { - "kind": "trigger", + "kind": "local_trigger", "label": "PR review", } - assert msg.metadata["_external_trigger"]["trigger_id"] == trigger.id + assert msg.metadata["_local_trigger"]["trigger_id"] == trigger.id stored = store.get(trigger.id) assert stored is not None @@ -129,10 +129,10 @@ async def test_external_trigger_queue_publishes_bound_inbound_message(tmp_path: @pytest.mark.asyncio -async def test_external_trigger_queue_recovers_processing_delivery_on_start( +async def test_local_trigger_queue_recovers_processing_delivery_on_start( tmp_path: Path, ) -> None: - store = ExternalTriggerStore(tmp_path) + store = LocalTriggerStore(tmp_path) trigger = store.create( name="PR review", channel="websocket", @@ -147,9 +147,9 @@ async def test_external_trigger_queue_recovers_processing_delivery_on_start( async def publish_inbound(self, msg: InboundMessage) -> None: published.append(msg) - restarted = ExternalTriggerStore(tmp_path) + restarted = LocalTriggerStore(tmp_path) task = asyncio.create_task( - run_external_trigger_queue(store=restarted, bus=_Bus(), poll_interval_s=0.01) + run_local_trigger_queue(store=restarted, bus=_Bus(), poll_interval_s=0.01) ) try: for _ in range(100): @@ -163,5 +163,5 @@ async def test_external_trigger_queue_recovers_processing_delivery_on_start( assert len(published) == 1 assert published[0].content == "Review PR #4591" - assert published[0].metadata["_external_trigger"]["trigger_id"] == trigger.id + assert published[0].metadata["_local_trigger"]["trigger_id"] == trigger.id assert restarted.claim_deliveries() == [] diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index 6482a0e8..ec59ce03 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -473,7 +473,25 @@ 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: +def test_replay_preserves_local_trigger_source_metadata(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + key = "websocket:t-local-trigger-source" + append_transcript_object( + key, + { + "event": "message", + "chat_id": "t-local-trigger-source", + "text": "PR #4502 review started.", + "source": {"kind": "local_trigger", "label": "PR review"}, + }, + ) + + msgs = replay_transcript_to_ui_messages(read_transcript_lines(key)) + + assert msgs[0]["source"] == {"kind": "local_trigger", "label": "PR review"} + + +def test_replay_preserves_legacy_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( diff --git a/tests/webui/test_session_list_index.py b/tests/webui/test_session_list_index.py index 9fe72118..7920ecbc 100644 --- a/tests/webui/test_session_list_index.py +++ b/tests/webui/test_session_list_index.py @@ -94,8 +94,8 @@ def test_webui_session_list_skips_trigger_internal_user_preview(tmp_path: 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"}}, + "Local trigger received: PR review", + **{AUTOMATION_HISTORY_META: {"kind": "local_trigger", "trigger_id": "trg_123"}}, ) session.add_message("assistant", "PR #4502 已经开始 review。") manager.save(session) diff --git a/webui/src/components/DeleteConfirm.tsx b/webui/src/components/DeleteConfirm.tsx index 1aee2c83..654dcf29 100644 --- a/webui/src/components/DeleteConfirm.tsx +++ b/webui/src/components/DeleteConfirm.tsx @@ -122,8 +122,8 @@ function formatAutomationSchedule( }) : t("deleteConfirm.schedule.cron", { expr: job.schedule.expr }); } - if (job.schedule.kind === "external" || job.payload.kind === "external_trigger") { - return t("deleteConfirm.schedule.external", { defaultValue: "External trigger" }); + if (job.schedule.kind === "local" || job.payload.kind === "local_trigger") { + return t("deleteConfirm.schedule.local", { defaultValue: "Local trigger" }); } return t("deleteConfirm.schedule.unknown"); } @@ -134,8 +134,8 @@ function formatAutomationNextRun( locale: string, ): string { if (!job.enabled) return t("deleteConfirm.next.disabled"); - if (job.schedule.kind === "external" || job.payload.kind === "external_trigger") { - return t("deleteConfirm.next.external", { defaultValue: "Waiting for trigger" }); + if (job.schedule.kind === "local" || job.payload.kind === "local_trigger") { + return t("deleteConfirm.next.local", { defaultValue: "Waiting for trigger" }); } const next = job.state.next_run_at_ms; if (!next) return t("deleteConfirm.next.none"); diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index cb479a54..efcafcef 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -167,7 +167,12 @@ export function MessageBubble({ const reasoning = message.role === "assistant" ? message.reasoning ?? "" : ""; const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming); const hasReasoning = reasoning.length > 0 || reasoningStreaming; - const automationSourceLabel = message.source?.kind === "cron" || message.source?.kind === "trigger" + const automationSourceKind = message.source?.kind; + const automationSourceLabel = ( + automationSourceKind === "cron" + || automationSourceKind === "local_trigger" + || automationSourceKind === "trigger" + ) ? (message.source.label?.trim() || t("message.automationSourceFallback")) : ""; const automationTriggeredLabel = t("message.automationTriggered"); diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 4ed6710b..c76f6164 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -3756,10 +3756,10 @@ function AutomationDetailPanel({ : null; const created = job.created_at_ms ? fmtDateTime(job.created_at_ms, locale) : null; const updated = job.updated_at_ms ? fmtDateTime(job.updated_at_ms, locale) : null; - const externalTrigger = isExternalTriggerAutomation(job); + const localTrigger = isLocalTriggerAutomation(job); const triggerCommand = automationTriggerCommand(job); const message = automationDetailText(job, tx); - const messageLabel = externalTrigger + const messageLabel = localTrigger ? tx("settings.automations.fields.command", "Command") : tx("settings.automations.fields.message", "Message"); const schedule = formatAutomationSchedule(job, locale, tx); @@ -3807,7 +3807,7 @@ function AutomationDetailPanel({
{messageLabel}
- {externalTrigger && triggerCommand ? ( + {localTrigger && triggerCommand ? (