refactor(trigger): name CLI trigger source as local
maintainer edit: cron is also a trigger source, so keep the new CLI-delivered source explicitly named as local trigger across backend, WebUI, docs, and tests.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() == []
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user