feat(trigger): add session-bound local triggers
This commit is contained in:
@@ -867,7 +867,7 @@ async def test_slash_new_is_blocked_for_disallowed_user() -> None:
|
||||
assert handled == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slash_name", ["stop", "restart", "status", "history", "model"])
|
||||
@pytest.mark.parametrize("slash_name", ["stop", "restart", "status", "history", "model", "trigger"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_commands_forward_via_handle_message(slash_name: str) -> None:
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
@@ -918,6 +918,31 @@ async def test_slash_model_forwards_optional_preset() -> None:
|
||||
assert handled[0]["metadata"]["is_slash_command"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_trigger_forwards_optional_name() -> None:
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
handled: list[dict] = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
client = DiscordBotClient(channel, intents=discord.Intents.none())
|
||||
interaction = _make_interaction()
|
||||
interaction.command.qualified_name = "trigger"
|
||||
|
||||
trigger_cmd = client.tree.get_command("trigger")
|
||||
assert trigger_cmd is not None
|
||||
await trigger_cmd.callback(interaction, name="PR review")
|
||||
|
||||
assert interaction.response.messages == [
|
||||
{"content": "Processing /trigger PR review...", "ephemeral": True}
|
||||
]
|
||||
assert len(handled) == 1
|
||||
assert handled[0]["content"] == "/trigger PR review"
|
||||
assert handled[0]["metadata"]["is_slash_command"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_help_returns_ephemeral_help_text() -> None:
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
|
||||
@@ -1577,12 +1577,14 @@ def test_telegram_bus_slash_command_regex_matches_agent_loop_commands() -> None:
|
||||
assert pat.fullmatch("/history")
|
||||
assert pat.fullmatch("/history 5")
|
||||
assert pat.fullmatch("/goal ship the feature")
|
||||
assert pat.fullmatch("/trigger PR review")
|
||||
assert pat.fullmatch("/pairing list")
|
||||
assert pat.fullmatch("/model fast")
|
||||
assert pat.fullmatch("/skill")
|
||||
assert pat.fullmatch("/skill@nanobot_bot")
|
||||
assert pat.fullmatch("/new@nanobot_bot")
|
||||
assert pat.fullmatch("/goal@nanobot_bot refine objective")
|
||||
assert pat.fullmatch("/trigger@nanobot_bot CI summary")
|
||||
assert pat.fullmatch("/dream-log deadbeef") is None
|
||||
assert pat.fullmatch("/dream-restore deadbeef") is None
|
||||
|
||||
@@ -1606,6 +1608,7 @@ async def test_on_help_includes_restart_command() -> None:
|
||||
assert "/dream" in help_text
|
||||
assert "/dream-log" in help_text
|
||||
assert "/goal" in help_text
|
||||
assert "/trigger" in help_text
|
||||
assert "/pairing" in help_text
|
||||
assert "/model" in help_text
|
||||
assert "/dream-restore" in help_text
|
||||
|
||||
@@ -20,6 +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.webui.gateway_services import GatewayServices, build_gateway_services
|
||||
|
||||
_PORT = 29900
|
||||
@@ -46,6 +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,
|
||||
cron_pending_job_ids: Any | None = None,
|
||||
) -> GatewayServices:
|
||||
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
|
||||
@@ -61,6 +63,7 @@ def _make_handler(
|
||||
runtime_surface="browser",
|
||||
runtime_capabilities_overrides=None,
|
||||
cron_service=cron_service,
|
||||
external_trigger_store=external_trigger_store,
|
||||
cron_pending_job_ids=cron_pending_job_ids,
|
||||
)
|
||||
|
||||
@@ -74,6 +77,7 @@ def _ch(
|
||||
port: int = _PORT,
|
||||
runtime_model_name: Any | None = None,
|
||||
cron_service: CronService | None = None,
|
||||
external_trigger_store: ExternalTriggerStore | None = None,
|
||||
cron_pending_job_ids: Any | None = None,
|
||||
**extra: Any,
|
||||
) -> WebSocketChannel:
|
||||
@@ -93,6 +97,7 @@ def _ch(
|
||||
workspace_path=workspace_path,
|
||||
runtime_model_name=runtime_model_name,
|
||||
cron_service=cron_service,
|
||||
external_trigger_store=external_trigger_store,
|
||||
cron_pending_job_ids=cron_pending_job_ids,
|
||||
)
|
||||
return WebSocketChannel(cfg, bus, gateway=gateway)
|
||||
@@ -319,6 +324,50 @@ async def test_session_automations_route_ignores_unified_owner(
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_automations_route_lists_external_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 = trigger_store.create(
|
||||
name="PR review",
|
||||
channel="websocket",
|
||||
chat_id="abc",
|
||||
session_key="websocket:abc",
|
||||
)
|
||||
channel = _ch(
|
||||
bus,
|
||||
session_manager=_seed_session(tmp_path, key="websocket:abc"),
|
||||
external_trigger_store=trigger_store,
|
||||
port=port,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
boot = await _http_get(f"{base_url}/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
resp = await _http_get(
|
||||
f"{base_url}/api/sessions/websocket%3Aabc/automations",
|
||||
headers=auth,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
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["payload"]["command"] == f'nanobot trigger {trigger.id} "message"'
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_skills_route_requires_token_and_hides_paths(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
@@ -1080,6 +1129,86 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_automations_route_manages_external_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 = trigger_store.create(
|
||||
name="PR review",
|
||||
channel="websocket",
|
||||
chat_id="abc",
|
||||
session_key="websocket:abc",
|
||||
)
|
||||
channel = _ch(
|
||||
bus,
|
||||
session_manager=_seed_session(tmp_path, key="websocket:abc"),
|
||||
external_trigger_store=trigger_store,
|
||||
port=port,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
boot = await _http_get(f"{base_url}/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
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]["trigger"]["command"] == f'nanobot trigger {trigger.id} "message"'
|
||||
|
||||
disabled = await _http_get(
|
||||
f"{base_url}/api/webui/automations/disable?id={trigger.id}",
|
||||
headers=auth,
|
||||
)
|
||||
assert disabled.status_code == 200
|
||||
stored = trigger_store.get(trigger.id)
|
||||
assert stored is not None
|
||||
assert stored.enabled is False
|
||||
|
||||
run = await _http_get(
|
||||
f"{base_url}/api/webui/automations/run?id={trigger.id}",
|
||||
headers=auth,
|
||||
)
|
||||
assert run.status_code == 409
|
||||
assert "CLI message" in run.text
|
||||
|
||||
renamed = await _http_get(
|
||||
f"{base_url}/api/webui/automations/update?id={trigger.id}",
|
||||
headers={
|
||||
**auth,
|
||||
"X-Nanobot-Automation-Values": json.dumps({"name": "Release review"}),
|
||||
},
|
||||
)
|
||||
assert renamed.status_code == 200
|
||||
stored = trigger_store.get(trigger.id)
|
||||
assert stored is not None
|
||||
assert stored.name == "Release review"
|
||||
|
||||
bad_update = await _http_get(
|
||||
f"{base_url}/api/webui/automations/update?id={trigger.id}",
|
||||
headers={
|
||||
**auth,
|
||||
"X-Nanobot-Automation-Values": json.dumps({"message": "coupled"}),
|
||||
},
|
||||
)
|
||||
assert bad_update.status_code == 400
|
||||
|
||||
deleted = await _http_get(
|
||||
f"{base_url}/api/webui/automations/delete?id={trigger.id}",
|
||||
headers=auth,
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert trigger_store.get(trigger.id) is None
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_blocks_when_bound_automation_exists(
|
||||
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -1121,6 +1250,54 @@ async def test_session_delete_blocks_when_bound_automation_exists(
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_blocks_and_cascades_external_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 = trigger_store.create(
|
||||
name="PR review",
|
||||
channel="websocket",
|
||||
chat_id="doomed",
|
||||
session_key="websocket:doomed",
|
||||
)
|
||||
channel = _ch(
|
||||
bus,
|
||||
session_manager=sm,
|
||||
external_trigger_store=trigger_store,
|
||||
port=port,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
boot = await _http_get(f"{base_url}/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
blocked = await _http_get(
|
||||
f"{base_url}/api/sessions/websocket:doomed/delete",
|
||||
headers=auth,
|
||||
)
|
||||
assert blocked.status_code == 200
|
||||
assert blocked.json()["blocked_by_automations"] is True
|
||||
assert trigger_store.get(trigger.id) is not None
|
||||
|
||||
deleted = await _http_get(
|
||||
f"{base_url}/api/sessions/websocket:doomed/delete?delete_automations=true",
|
||||
headers=auth,
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json()["deleted"] is True
|
||||
assert trigger_store.get(trigger.id) is None
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_can_cascade_bound_automations(
|
||||
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
@@ -2516,6 +2516,38 @@ def test_serve_uses_api_config_defaults_and_workspace_override(
|
||||
assert seen["api_key"] == ""
|
||||
|
||||
|
||||
def test_trigger_cli_queues_message_in_workspace(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from nanobot.triggers.store import ExternalTriggerStore
|
||||
|
||||
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)
|
||||
trigger = store.create(
|
||||
name="Review hook",
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
session_key="websocket:chat-1",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["trigger", "--config", str(config_file), trigger.id, "Review PR #4502"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert f"Queued {trigger.id}" in result.stdout
|
||||
deliveries = store.claim_deliveries()
|
||||
assert len(deliveries) == 1
|
||||
assert deliveries[0].trigger_id == trigger.id
|
||||
assert deliveries[0].content == "Review PR #4502"
|
||||
|
||||
|
||||
def test_serve_cli_options_override_api_config(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = _write_instance_config(tmp_path)
|
||||
config = Config()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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)
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="chat-1",
|
||||
content="/trigger@nanobot_bot PR review",
|
||||
metadata={"webui": True},
|
||||
)
|
||||
ctx = CommandContext(
|
||||
msg=msg,
|
||||
session=None,
|
||||
key="websocket:chat-1",
|
||||
raw="/trigger@nanobot_bot PR review",
|
||||
loop=loop,
|
||||
)
|
||||
|
||||
assert router.is_dispatchable_command("/trigger@nanobot_bot PR review") is True
|
||||
response = await router.dispatch(ctx)
|
||||
|
||||
assert response is not None
|
||||
assert "Trigger created: PR review" in response.content
|
||||
trigger = store.list_for_session("websocket:chat-1")[0]
|
||||
assert trigger.name == "PR review"
|
||||
assert trigger.channel == "websocket"
|
||||
assert trigger.chat_id == "chat-1"
|
||||
assert trigger.session_key == "websocket:chat-1"
|
||||
assert f"nanobot trigger {trigger.id} \"message\"" in response.content
|
||||
|
||||
|
||||
def test_trigger_command_is_in_help_text() -> None:
|
||||
assert "/trigger [name]" in build_help_text()
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
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.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)
|
||||
|
||||
first = store.create(
|
||||
name="PR review",
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
session_key="websocket:chat-1",
|
||||
)
|
||||
second = store.create(
|
||||
name="CI summary",
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
session_key="websocket:chat-1",
|
||||
)
|
||||
|
||||
triggers = store.list_for_session("websocket:chat-1")
|
||||
assert {trigger.id for trigger in triggers} == {first.id, second.id}
|
||||
assert first.id.startswith("trg_")
|
||||
assert second.id.startswith("trg_")
|
||||
assert first.id != second.id
|
||||
|
||||
|
||||
def test_enqueue_rejects_disabled_trigger(tmp_path: Path) -> None:
|
||||
store = ExternalTriggerStore(tmp_path)
|
||||
trigger = store.create(
|
||||
name="Disabled",
|
||||
channel="telegram",
|
||||
chat_id="123",
|
||||
session_key="telegram:123",
|
||||
)
|
||||
store.enable(trigger.id, enabled=False)
|
||||
|
||||
with pytest.raises(TriggerDisabledError):
|
||||
store.enqueue(trigger.id, "Review PR #4502")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_trigger_queue_publishes_bound_inbound_message(tmp_path: Path) -> None:
|
||||
store = ExternalTriggerStore(tmp_path)
|
||||
trigger = store.create(
|
||||
name="PR review",
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
session_key="websocket:chat-1",
|
||||
origin_metadata={"webui": True, WEBUI_TURN_METADATA_KEY: "old-turn"},
|
||||
)
|
||||
store.enqueue(trigger.id, "Review PR #4502")
|
||||
published: list[InboundMessage] = []
|
||||
|
||||
class _Bus:
|
||||
async def publish_inbound(self, msg: InboundMessage) -> None:
|
||||
published.append(msg)
|
||||
|
||||
task = asyncio.create_task(
|
||||
run_external_trigger_queue(store=store, bus=_Bus(), poll_interval_s=0.01)
|
||||
)
|
||||
try:
|
||||
for _ in range(100):
|
||||
if published:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
finally:
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert len(published) == 1
|
||||
msg = published[0]
|
||||
assert msg.channel == "websocket"
|
||||
assert msg.chat_id == "chat-1"
|
||||
assert msg.sender_id == "trigger"
|
||||
assert msg.content == "Review PR #4502"
|
||||
assert msg.session_key_override == "websocket:chat-1"
|
||||
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",
|
||||
"label": "PR review",
|
||||
}
|
||||
assert msg.metadata["_external_trigger"]["trigger_id"] == trigger.id
|
||||
|
||||
stored = store.get(trigger.id)
|
||||
assert stored is not None
|
||||
assert stored.last_status == "ok"
|
||||
assert stored.last_run_at_ms is not None
|
||||
assert store.claim_deliveries() == []
|
||||
Reference in New Issue
Block a user