Merge PR #4299: feat(cron): bind scheduled automations to sessions

feat(cron): bind scheduled automations to sessions
This commit is contained in:
Xubin Ren
2026-06-13 00:07:55 +08:00
committed by GitHub
58 changed files with 2451 additions and 412 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
import pytest
from nanobot.utils.evaluator import evaluate_response
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.utils.evaluator import evaluate_response
class DummyProvider(LLMProvider):
+61
View File
@@ -8,6 +8,7 @@ from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
from nanobot.providers.base import LLMResponse
from nanobot.session.goal_state import GOAL_STATE_KEY
from nanobot.session.manager import Session, SessionManager
@@ -64,6 +65,41 @@ def test_agent_loop_llm_runtime_reflects_current_provider_and_model(tmp_path: Pa
assert runtime.model == "next-model"
def test_persist_cron_turn_uses_distinct_history_marker(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
session = loop.sessions.get_or_create("websocket:auto")
prompt_ref = {"id": "cron.agent_turn.reminder", "version": 1, "sha256": "abc"}
persisted = loop._persist_user_message_early(
InboundMessage(
channel="websocket",
sender_id="cron",
chat_id="auto",
content="Cron job: internal prompt",
metadata={
CRON_TRIGGER_META: {
"job_id": "job-1",
"job_name": "Daily check",
"run_id": "job-1:1",
"prompt_ref": prompt_ref,
"persist_content": "Scheduled cron job triggered: Daily check",
}
},
),
session,
)
assert persisted is True
message = session.messages[-1]
assert message["content"] == "Scheduled cron job triggered: Daily check"
assert message[CRON_HISTORY_META] is True
assert CRON_TRIGGER_META not in message
assert message["cron_job_id"] == "job-1"
assert message["cron_job_name"] == "Daily check"
assert message["cron_run_id"] == "job-1:1"
assert message["cron_prompt_ref"] == prompt_ref
def test_clean_generated_title_strips_reasoning_tags() -> None:
assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish"
assert clean_generated_title("Title: <think> The user said hello") == ""
@@ -145,6 +181,31 @@ async def test_generate_webui_title_ignores_command_only_sessions(tmp_path: Path
loop.provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_generate_webui_title_ignores_cron_internal_turns(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
session = loop.sessions.get_or_create("websocket:cron-title")
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
session.add_message(
"user",
"Scheduled cron job triggered: 30s-test\n\nInternal reminder prompt",
**{CRON_HISTORY_META: True},
)
session.add_message("assistant", "提醒已经到期。")
loop.sessions.save(session)
generated = await maybe_generate_webui_title(
sessions=loop.sessions,
session_key="websocket:cron-title",
provider=loop.provider,
model=loop.model,
)
assert generated is False
assert WEBUI_TITLE_METADATA_KEY not in session.metadata
loop.provider.chat_with_retry.assert_not_awaited()
def test_webui_title_update_uses_captured_llm_runtime(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
+87 -1
View File
@@ -592,8 +592,8 @@ async def test_waiting_dispatch_does_not_replace_active_pending_queue(tmp_path):
@pytest.mark.asyncio
async def test_followup_routed_to_pending_queue(tmp_path):
"""Unified-session follow-ups should route into the active pending queue."""
from nanobot.agent.loop import UNIFIED_SESSION_KEY
from nanobot.bus.events import InboundMessage
from nanobot.session.keys import UNIFIED_SESSION_KEY
loop = _make_loop(tmp_path)
loop._unified_session = True
@@ -616,6 +616,92 @@ async def test_followup_routed_to_pending_queue(tmp_path):
assert queued_msg.session_key == UNIFIED_SESSION_KEY
@pytest.mark.asyncio
async def test_cron_turn_deferred_while_session_active(tmp_path):
"""Cron turns wait for the active session instead of becoming injections."""
from nanobot.bus.events import InboundMessage
from nanobot.cron.session_turns import (
CRON_DEFER_UNTIL_IDLE_META,
CRON_TRIGGER_META,
)
loop = _make_loop(tmp_path)
loop._dispatch = AsyncMock() # type: ignore[method-assign]
session_key = "websocket:chat-1"
pending = asyncio.Queue(maxsize=20)
loop._pending_queues[session_key] = pending
run_task = asyncio.create_task(loop.run())
msg = InboundMessage(
channel="websocket",
sender_id="cron",
chat_id="chat-1",
content="scheduled work",
metadata={
CRON_TRIGGER_META: {"job_id": "job-1", "run_id": "run-1"},
CRON_DEFER_UNTIL_IDLE_META: True,
},
session_key_override=session_key,
)
await loop.bus.publish_inbound(msg)
for _ in range(20):
if loop._cron_turns.deferred_queues.get(session_key):
break
await asyncio.sleep(0.05)
loop.stop()
await asyncio.wait_for(run_task, timeout=2)
assert pending.empty()
assert loop._dispatch.await_count == 0
assert loop._cron_turns.deferred_queues[session_key] == [msg]
assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"}
await loop._cron_turns.publish_next_deferred(session_key)
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
assert queued is msg
assert session_key not in loop._cron_turns.deferred_queues
assert loop.pending_cron_job_ids_for_session(session_key) == set()
@pytest.mark.asyncio
async def test_submitted_cron_turn_reports_pending_until_completed(tmp_path):
"""Bound cron jobs remain marked pending while their session turn is in flight."""
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.cron.session_turns import CRON_TRIGGER_META
loop = _make_loop(tmp_path)
loop._running = True
session_key = "websocket:chat-1"
msg = InboundMessage(
channel="websocket",
sender_id="cron",
chat_id="chat-1",
content="scheduled work",
metadata={CRON_TRIGGER_META: {"job_id": "job-1", "run_id": "run-1"}},
session_key_override=session_key,
)
submit_task = asyncio.create_task(loop.submit_cron_turn(msg))
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
assert queued is msg
assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"}
response = OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="done",
)
loop._cron_turns.complete(msg, response=response)
assert await asyncio.wait_for(submit_task, timeout=0.5) is response
assert loop.pending_cron_job_ids_for_session(session_key) == set()
@pytest.mark.asyncio
async def test_pending_queue_preserves_overflow_for_next_injection_cycle(tmp_path):
"""Pending queue should leave overflow messages queued for later drains."""
+6 -5
View File
@@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.config.schema import AgentDefaults
from nanobot.session.keys import UNIFIED_SESSION_KEY
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@@ -450,12 +451,12 @@ class TestSubagentAnnounceSessionKey:
so the result matches the pending queue key."""
mgr, bus = self._make_mgr()
origin = {"channel": "telegram", "chat_id": "111", "session_key": "unified:default"}
origin = {"channel": "telegram", "chat_id": "111", "session_key": UNIFIED_SESSION_KEY}
await mgr._announce_result("sub-1", "label", "task", "result", origin, "ok")
msg = await bus.consume_inbound()
assert msg.session_key_override == "unified:default"
assert msg.session_key == "unified:default"
assert msg.session_key_override == UNIFIED_SESSION_KEY
assert msg.session_key == UNIFIED_SESSION_KEY
@pytest.mark.asyncio
async def test_announce_uses_raw_key_in_normal_mode(self):
@@ -505,9 +506,9 @@ class TestSubagentAnnounceSessionKey:
)
await mgr._run_subagent(
"sub-4", "task", "label",
{"channel": "telegram", "chat_id": "444", "session_key": "unified:default"},
{"channel": "telegram", "chat_id": "444", "session_key": UNIFIED_SESSION_KEY},
status,
)
msg = await bus.consume_inbound()
assert msg.session_key_override == "unified:default"
assert msg.session_key_override == UNIFIED_SESSION_KEY
+4 -9
View File
@@ -25,9 +25,9 @@ from nanobot.bus.queue import MessageBus
from nanobot.command.builtin import cmd_new, register_builtin_commands
from nanobot.command.router import CommandContext, CommandRouter
from nanobot.config.schema import AgentDefaults, Config
from nanobot.session.keys import UNIFIED_SESSION_KEY
from nanobot.session.manager import Session, SessionManager
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -39,8 +39,8 @@ def _make_loop(tmp_path: Path, unified_session: bool = False) -> AgentLoop:
provider.get_default_model.return_value = "test-model"
with patch("nanobot.agent.loop.SessionManager"), \
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
loop = AgentLoop(
bus=bus,
provider=provider,
@@ -415,10 +415,8 @@ class TestStopCommandWithUnifiedSession:
@pytest.mark.asyncio
async def test_active_tasks_use_effective_key_in_unified_mode(self, tmp_path: Path):
"""When unified_session=True, tasks are stored under UNIFIED_SESSION_KEY."""
from nanobot.agent.loop import UNIFIED_SESSION_KEY
loop = _make_loop(tmp_path, unified_session=True)
# Create a message from telegram channel
msg = _make_msg(channel="telegram", chat_id="123456")
@@ -443,7 +441,6 @@ class TestStopCommandWithUnifiedSession:
@pytest.mark.asyncio
async def test_stop_command_finds_task_in_unified_mode(self, tmp_path: Path):
"""cmd_stop can cancel tasks when unified_session=True."""
from nanobot.agent.loop import UNIFIED_SESSION_KEY
from nanobot.command.builtin import cmd_stop
loop = _make_loop(tmp_path, unified_session=True)
@@ -476,7 +473,6 @@ class TestStopCommandWithUnifiedSession:
@pytest.mark.asyncio
async def test_stop_command_uses_effective_key_without_session_override(self, tmp_path: Path):
"""Priority /stop must cancel the unified session even before dispatch rewrites the message."""
from nanobot.agent.loop import UNIFIED_SESSION_KEY
from nanobot.command.builtin import cmd_stop
loop = _make_loop(tmp_path, unified_session=True)
@@ -502,7 +498,6 @@ class TestStopCommandWithUnifiedSession:
@pytest.mark.asyncio
async def test_stop_command_cross_channel_in_unified_mode(self, tmp_path: Path):
"""In unified mode, /stop from one channel cancels tasks from another channel."""
from nanobot.agent.loop import UNIFIED_SESSION_KEY
from nanobot.command.builtin import cmd_stop
loop = _make_loop(tmp_path, unified_session=True)
+46
View File
@@ -2866,3 +2866,49 @@ def test_handle_webui_thread_get_backfills_legacy_missing_user_rows(
"legacy question",
"legacy answer",
]
def test_handle_webui_thread_get_does_not_backfill_cron_internal_prompt(
tmp_path,
monkeypatch,
) -> None:
from urllib.parse import quote
from websockets.datastructures import Headers
from websockets.http11 import Request
from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.webui.transcript import append_transcript_object
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
workspace = tmp_path / "workspace"
sessions = SessionManager(workspace)
key = "websocket:c-cron"
session = sessions.get_or_create(key)
session.add_message(
"user",
"Scheduled cron job triggered: 30s-test\n\nInternal reminder prompt",
**{CRON_HISTORY_META: True},
)
session.add_message("assistant", "提醒已经到期。")
sessions.save(session)
append_transcript_object(
key,
{"event": "message", "chat_id": "c-cron", "text": "提醒已经到期。"},
)
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=workspace),
)
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
enc = quote(key, safe="")
req = Request(f"/api/sessions/{enc}/webui-thread", Headers([("Authorization", "Bearer tok")]))
resp = channel.gateway.http._handle_webui_thread_get(req, enc)
assert resp.status_code == 200
body = json.loads(resp.body.decode())
assert [message["role"] for message in body["messages"]] == ["assistant"]
assert [message["content"] for message in body["messages"]] == ["提醒已经到期。"]
+212 -4
View File
@@ -14,6 +14,7 @@ import pytest
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
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.webui.gateway_services import GatewayServices, build_gateway_services
@@ -29,6 +30,7 @@ def _make_handler(
workspace_path: Path | None = None,
runtime_model_name: Any | None = None,
cron_service: CronService | None = None,
cron_pending_job_ids: Any | None = None,
) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
workspace = workspace_path or Path.cwd()
@@ -43,6 +45,7 @@ def _make_handler(
runtime_surface="browser",
runtime_capabilities_overrides=None,
cron_service=cron_service,
cron_pending_job_ids=cron_pending_job_ids,
)
@@ -55,6 +58,7 @@ def _ch(
port: int = _PORT,
runtime_model_name: Any | None = None,
cron_service: CronService | None = None,
cron_pending_job_ids: Any | None = None,
**extra: Any,
) -> WebSocketChannel:
cfg: dict[str, Any] = {
@@ -73,6 +77,7 @@ def _ch(
workspace_path=workspace_path,
runtime_model_name=runtime_model_name,
cron_service=cron_service,
cron_pending_job_ids=cron_pending_job_ids,
)
return WebSocketChannel(cfg, bus, gateway=gateway)
@@ -176,18 +181,30 @@ async def test_session_automations_route_filters_by_webui_session(
) -> None:
cron = CronService(tmp_path / "cron" / "jobs.json")
hourly = CronSchedule(kind="every", every_ms=3_600_000)
pending_job_id = ""
for name, message, to in (
("Morning check", "Check the project status", "abc"),
("Other session", "Do not show", "other"),
):
cron.add_job(
job = cron.add_job(
name=name,
schedule=hourly,
message=message,
channel="websocket",
to=to,
session_key=f"websocket:{to}",
origin_channel="websocket",
origin_chat_id=to,
)
if name == "Morning check":
pending_job_id = job.id
cron.add_job(
name="Legacy same target",
schedule=hourly,
message="Legacy job should be migrated",
deliver=True,
channel="websocket",
to="abc",
session_key="websocket:abc",
)
cron.register_system_job(
CronJob(
id="heartbeat",
@@ -200,6 +217,7 @@ async def test_session_automations_route_filters_by_webui_session(
bus,
session_manager=_seed_session(tmp_path, key="websocket:abc"),
cron_service=cron,
cron_pending_job_ids=lambda key: {pending_job_id} if key == "websocket:abc" else set(),
port=29914,
)
server_task = asyncio.create_task(channel.start())
@@ -220,11 +238,66 @@ async def test_session_automations_route_filters_by_webui_session(
assert resp.status_code == 200
body = resp.json()
assert [job["name"] for job in body["jobs"]] == ["Morning check"]
assert [job["name"] for job in body["jobs"]] == ["Morning check", "Legacy same target"]
job = body["jobs"][0]
assert job["schedule"]["kind"] == "every"
assert job["schedule"]["every_ms"] == 3_600_000
assert job["payload"]["message"] == "Check the project status"
assert job["state"]["pending"] is True
assert body["jobs"][1]["state"]["pending"] is False
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_session_automations_route_ignores_unified_owner(
bus: MagicMock, tmp_path: Path
) -> None:
cron = CronService(tmp_path / "cron" / "jobs.json")
hourly = CronSchedule(kind="every", every_ms=3_600_000)
cron.add_job(
name="Unified check",
schedule=hourly,
message="Check the shared session",
session_key=UNIFIED_SESSION_KEY,
origin_channel="websocket",
origin_chat_id="abc",
)
cron.add_job(
name="Visible chat job",
schedule=hourly,
message="Show for this chat",
session_key="websocket:abc",
origin_channel="websocket",
origin_chat_id="abc",
)
channel = _ch(
bus,
session_manager=_seed_session(tmp_path, key="websocket:abc"),
cron_service=cron,
port=29917,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
boot = await _http_get("http://127.0.0.1:29917/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
resp = await _http_get(
"http://127.0.0.1:29917/api/sessions/websocket%3Aabc/automations",
headers=auth,
)
assert resp.status_code == 200
assert [job["name"] for job in resp.json()["jobs"]] == ["Visible chat job"]
resp = await _http_get(
"http://127.0.0.1:29917/api/sessions/websocket%3Aother/automations",
headers=auth,
)
assert resp.status_code == 200
assert resp.json()["jobs"] == []
finally:
await channel.stop()
await server_task
@@ -659,6 +732,141 @@ async def test_session_delete_removes_file(
await server_task
@pytest.mark.asyncio
async def test_session_delete_blocks_when_bound_automation_exists(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sm = _seed_session(tmp_path, key="websocket:doomed")
cron = CronService(tmp_path / "cron" / "jobs.json")
cron.add_job(
name="Daily check",
schedule=CronSchedule(kind="every", every_ms=86_400_000),
message="Check the repo",
session_key="websocket:doomed",
origin_channel="websocket",
origin_chat_id="doomed",
)
channel = _ch(bus, session_manager=sm, cron_service=cron, port=29915)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
boot = await _http_get("http://127.0.0.1:29915/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
path = sm._get_session_path("websocket:doomed")
resp = await _http_get(
"http://127.0.0.1:29915/api/sessions/websocket:doomed/delete",
headers=auth,
)
assert resp.status_code == 200
body = resp.json()
assert body["deleted"] is False
assert body["blocked_by_automations"] is True
assert [job["name"] for job in body["automations"]] == ["Daily check"]
assert path.exists()
assert cron.list_bound_cron_jobs_for_session("websocket:doomed")
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
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sm = _seed_session(tmp_path, key="websocket:doomed")
cron = CronService(tmp_path / "cron" / "jobs.json")
cron.add_job(
name="Daily check",
schedule=CronSchedule(kind="every", every_ms=86_400_000),
message="Check the repo",
session_key="websocket:doomed",
origin_channel="websocket",
origin_chat_id="doomed",
)
cron.add_job(
name="Legacy same target",
schedule=CronSchedule(kind="every", every_ms=86_400_000),
message="Legacy job remains",
channel="websocket",
to="doomed",
)
channel = _ch(bus, session_manager=sm, cron_service=cron, port=29916)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
boot = await _http_get("http://127.0.0.1:29916/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
path = sm._get_session_path("websocket:doomed")
resp = await _http_get(
"http://127.0.0.1:29916/api/sessions/websocket:doomed/delete?delete_automations=true",
headers=auth,
)
assert resp.status_code == 200
assert resp.json()["deleted"] is True
assert not path.exists()
assert cron.list_bound_cron_jobs_for_session("websocket:doomed") == []
assert cron.list_jobs(include_disabled=True) == []
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_session_delete_blocks_origin_automation_when_unified_enabled(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sm = _seed_session(tmp_path, key="websocket:doomed")
cron = CronService(tmp_path / "cron" / "jobs.json")
cron.add_job(
name="Chat daily check",
schedule=CronSchedule(kind="every", every_ms=86_400_000),
message="Check this chat",
session_key="websocket:doomed",
origin_channel="websocket",
origin_chat_id="doomed",
)
channel = _ch(
bus,
session_manager=sm,
cron_service=cron,
port=29918,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
boot = await _http_get("http://127.0.0.1:29918/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
path = sm._get_session_path("websocket:doomed")
resp = await _http_get(
"http://127.0.0.1:29918/api/sessions/websocket:doomed/delete",
headers=auth,
)
assert resp.status_code == 200
body = resp.json()
assert body["deleted"] is False
assert body["blocked_by_automations"] is True
assert [job["name"] for job in body["automations"]] == ["Chat daily check"]
assert path.exists()
assert [job.name for job in cron.list_bound_cron_jobs_for_session("websocket:doomed")] == [
"Chat daily check"
]
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_session_routes_accept_percent_encoded_websocket_keys(
bus: MagicMock, tmp_path: Path
+158 -121
View File
@@ -8,13 +8,20 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from typer.testing import CliRunner
from nanobot.bus.events import OutboundMessage
from nanobot.cli.commands import _proactive_delivery_metadata, app
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.cli.commands import app
from nanobot.config.schema import Config
from nanobot.cron.service import CronJobSkippedError
from nanobot.cron.session_turns import CRON_DEFER_UNTIL_IDLE_META, CRON_TRIGGER_META
from nanobot.cron.types import CronJob, CronPayload
from nanobot.cron.webui_metadata import cron_proactive_delivery_metadata
from nanobot.providers.factory import ProviderSnapshot, make_provider
from nanobot.providers.openai_codex_provider import _strip_model_prefix
from nanobot.providers.registry import find_by_name
from nanobot.webui.metadata import (
WEBUI_MESSAGE_SOURCE_METADATA_KEY,
WEBUI_TURN_METADATA_KEY,
)
runner = CliRunner()
@@ -22,11 +29,11 @@ runner = CliRunner()
def test_proactive_websocket_delivery_gets_fresh_turn_id() -> None:
metadata = {
"webui": True,
"webui_turn_id": "turn-that-created-the-reminder",
WEBUI_TURN_METADATA_KEY: "turn-that-created-the-reminder",
"workspace_scope": {"mode": "default"},
}
out = _proactive_delivery_metadata(
out = cron_proactive_delivery_metadata(
"websocket",
metadata,
turn_seed="cron:drink-water",
@@ -35,9 +42,9 @@ def test_proactive_websocket_delivery_gets_fresh_turn_id() -> None:
assert out["webui"] is True
assert out["workspace_scope"] == {"mode": "default"}
assert out["webui_turn_id"].startswith("cron:drink-water:")
assert out["webui_turn_id"] != metadata["webui_turn_id"]
assert out["_webui_message_source"] == {"kind": "cron", "label": "drink water"}
assert out[WEBUI_TURN_METADATA_KEY].startswith("cron:drink-water:")
assert out[WEBUI_TURN_METADATA_KEY] != metadata[WEBUI_TURN_METADATA_KEY]
assert out[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {"kind": "cron", "label": "drink water"}
def _fake_provider():
@@ -1338,7 +1345,7 @@ def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path:
assert seen["cron_store"] == config.workspace_path / "cron" / "jobs.json"
def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
def test_gateway_unbound_agent_cron_is_skipped(
monkeypatch, tmp_path: Path
) -> None:
config_file = tmp_path / "instance" / "config.json"
@@ -1403,11 +1410,10 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
seen["agent"] = self
async def process_direct(self, *_args, **_kwargs):
return OutboundMessage(
channel="telegram",
chat_id="user-1",
content="Time to stretch.",
)
raise AssertionError("unbound cron job must not use process_direct")
async def submit_cron_turn(self, _msg: InboundMessage):
raise AssertionError("unbound cron job must not run as a bound cron turn")
async def close_mcp(self) -> None:
return None
@@ -1423,16 +1429,10 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
raise _StopGatewayError("stop")
async def _capture_evaluate_response(
response: str,
task_context: str,
provider_arg: object,
model: str,
*_args,
**_kwargs,
) -> bool:
seen["response"] = response
seen["task_context"] = task_context
seen["provider"] = provider_arg
seen["model"] = model
return True
raise AssertionError("unbound cron job must not be evaluated for delivery")
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
@@ -1465,124 +1465,71 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
),
)
response = asyncio.run(cron.on_job(job))
with pytest.raises(CronJobSkippedError, match="unbound agent cron job"):
asyncio.run(cron.on_job(job))
assert response == "Time to stretch."
assert seen["response"] == "Time to stretch."
assert seen["provider"] is runtime_provider
assert seen["model"] == "runtime-model"
assert seen["task_context"] == (
"The scheduled time has arrived. Deliver this reminder to the user now, "
"as a brief and natural message in their language. Speak directly to them — "
"do not narrate progress, summarize, include user IDs, or add status reports "
"like 'Done' or 'Reminded'.\n\n"
"Reminder: Remind me to stretch."
)
bus.publish_outbound.assert_awaited_once_with(
OutboundMessage(
channel="telegram",
chat_id="user-1",
content="Time to stretch.",
)
)
assert seen["session_key"] == "telegram:user-1"
saved_session = seen["saved_session"]
assert isinstance(saved_session, _FakeSession)
assert saved_session.messages == [
{
"role": "assistant",
"content": "Time to stretch.",
"_channel_delivery": True,
}
]
bus.publish_outbound.reset_mock()
old_turn_id = "turn-that-created-the-reminder"
websocket_job = CronJob(
id="drink-water",
name="drink water",
payload=CronPayload(
message="Remind me to drink water.",
deliver=True,
channel="websocket",
to="chat-1",
channel_meta={
"webui": True,
"webui_turn_id": old_turn_id,
"workspace_scope": {"mode": "default"},
},
session_key="websocket:chat-1",
),
)
response = asyncio.run(cron.on_job(websocket_job))
assert response == "Time to stretch."
bus.publish_outbound.assert_awaited_once()
delivered = bus.publish_outbound.await_args.args[0]
assert delivered.channel == "websocket"
assert delivered.chat_id == "chat-1"
assert delivered.metadata["webui"] is True
assert delivered.metadata["workspace_scope"] == {"mode": "default"}
assert delivered.metadata["webui_turn_id"].startswith("cron:drink-water:")
assert delivered.metadata["webui_turn_id"] != old_turn_id
assert delivered.metadata["_webui_message_source"] == {
"kind": "cron",
"label": "drink water",
}
bus.publish_outbound.assert_not_awaited()
def test_gateway_cron_job_suppresses_intermediate_progress(
def test_gateway_bound_cron_runs_as_session_turn(
monkeypatch, tmp_path: Path
) -> None:
"""Cron jobs must pass on_progress=_silent to process_direct so that
tool hints and streaming deltas are never leaked to the user channel
before evaluate_response decides whether to deliver."""
config_file = tmp_path / "instance" / "config.json"
config_file.parent.mkdir(parents=True)
config_file.write_text("{}")
config = Config()
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
provider = _fake_provider()
bus = MagicMock()
bus.publish_outbound = AsyncMock()
seen: dict[str, object] = {}
seen: dict[str, object] = {"run_records": []}
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
monkeypatch.setattr(
"nanobot.providers.factory.build_provider_snapshot",
lambda _config: _test_provider_snapshot(object(), _config),
lambda _config: _test_provider_snapshot(provider, _config),
)
monkeypatch.setattr(
"nanobot.providers.factory.load_provider_snapshot",
lambda _config_path=None: _test_provider_snapshot(object(), config),
lambda _config_path=None: _test_provider_snapshot(provider, config),
)
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus)
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
class _FakeSessionManager:
def __init__(self, _workspace: Path) -> None:
pass
monkeypatch.setattr("nanobot.session.manager.SessionManager", _FakeSessionManager)
class _FakeCron:
def __init__(self, _store_path: Path) -> None:
self.on_job = None
seen["cron"] = self
def write_run_record(self, run_id: str, record: dict[str, object]) -> None:
seen["run_records"].append((run_id, record))
class _FakeAgentLoop:
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(**extra)
def __init__(self, *args, **kwargs) -> None:
self.model = "test-model"
self.provider = object()
self.provider = kwargs.get("provider", object())
self.tools = {}
seen["agent"] = self
async def process_direct(self, *_args, on_progress=None, **_kwargs):
seen["on_progress"] = on_progress
async def submit_cron_turn(self, msg: InboundMessage):
seen["cron_msg"] = msg
return OutboundMessage(
channel="telegram",
chat_id="user-1",
content="Done.",
channel=msg.channel,
chat_id=msg.chat_id,
content="Checked the repo.",
)
async def close_mcp(self) -> None:
@@ -1598,41 +1545,131 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
def __init__(self, *_args, **_kwargs) -> None:
raise _StopGatewayError("stop")
async def _always_reject(*_args, **_kwargs) -> bool:
return False
async def _unexpected_evaluator(*_args, **_kwargs) -> bool:
raise AssertionError("bound cron must not use legacy response evaluator")
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
monkeypatch.setattr(
"nanobot.cli.commands.evaluate_response",
_always_reject,
)
monkeypatch.setattr("nanobot.cli.commands.evaluate_response", _unexpected_evaluator)
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
assert isinstance(result.exception, _StopGatewayError)
cron = seen["cron"]
job = CronJob(
id="cron-silent-test",
name="test-silent",
id="repo-check",
name="Repo check",
payload=CronPayload(
message="Run something.",
deliver=True,
channel="telegram",
to="user-1",
message="Check repository health.",
session_key="websocket:chat-1",
origin_channel="websocket",
origin_chat_id="chat-1",
),
)
response = asyncio.run(cron.on_job(job))
assert response == "Done."
# on_progress must be a callable (the _silent noop), not None and not bus_progress
assert seen["on_progress"] is not None
assert callable(seen["on_progress"])
# Verify it actually swallows calls (no side effects)
asyncio.run(seen["on_progress"]("tool_hint", "🔧 $ echo test"))
# Nothing published to bus since evaluator rejected
bus.publish_outbound.assert_not_awaited()
assert response == "Checked the repo."
msg = seen["cron_msg"]
assert isinstance(msg, InboundMessage)
assert msg.channel == "websocket"
assert msg.chat_id == "chat-1"
assert msg.sender_id == "cron"
assert msg.session_key_override == "websocket:chat-1"
assert "Cron job: Check repository health." in msg.content
assert msg.metadata["webui"] is True
assert msg.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {
"kind": "cron",
"label": "Repo check",
}
trigger = msg.metadata[CRON_TRIGGER_META]
assert trigger["job_id"] == "repo-check"
assert trigger["job_name"] == "Repo check"
assert trigger["persist_content"] == (
"Scheduled cron job triggered: Repo check\n\nCheck repository health."
)
assert msg.metadata[CRON_DEFER_UNTIL_IDLE_META] is True
statuses = [record["status"] for _run_id, record in seen["run_records"]]
assert statuses == ["queued", "ok"]
assert seen["run_records"][0][0] == seen["run_records"][1][0]
discord_job = CronJob(
id="thread-check",
name="Thread check",
payload=CronPayload(
message="Check the Discord thread.",
session_key="discord:456:thread:777",
origin_channel="discord",
origin_chat_id="777",
origin_metadata={
"context_chat_id": "456",
"parent_channel_id": "456",
"thread_id": "777",
},
),
)
response = asyncio.run(cron.on_job(discord_job))
assert response == "Checked the repo."
msg = seen["cron_msg"]
assert isinstance(msg, InboundMessage)
assert msg.channel == "discord"
assert msg.chat_id == "777"
assert msg.session_key_override == "discord:456:thread:777"
assert msg.metadata["context_chat_id"] == "456"
assert msg.metadata["parent_channel_id"] == "456"
assert msg.metadata["thread_id"] == "777"
telegram_job = CronJob(
id="telegram-topic",
name="Telegram topic",
payload=CronPayload(
message="Check the Telegram topic.",
session_key="telegram:-100123:topic:42",
origin_channel="telegram",
origin_chat_id="-100123",
origin_metadata={"message_thread_id": 42},
),
)
response = asyncio.run(cron.on_job(telegram_job))
assert response == "Checked the repo."
msg = seen["cron_msg"]
assert isinstance(msg, InboundMessage)
assert msg.channel == "telegram"
assert msg.chat_id == "-100123"
assert msg.session_key_override == "telegram:-100123:topic:42"
assert msg.metadata["message_thread_id"] == 42
feishu_job = CronJob(
id="feishu-topic",
name="Feishu topic",
payload=CronPayload(
message="Check the Feishu topic.",
session_key="feishu:oc_abc:om_root123",
origin_channel="feishu",
origin_chat_id="oc_abc",
origin_metadata={
"chat_type": "group",
"message_id": "om_root123",
"thread_id": "om_root123",
},
),
)
response = asyncio.run(cron.on_job(feishu_job))
assert response == "Checked the repo."
msg = seen["cron_msg"]
assert isinstance(msg, InboundMessage)
assert msg.channel == "feishu"
assert msg.chat_id == "oc_abc"
assert msg.session_key_override == "feishu:oc_abc:om_root123"
assert msg.metadata["message_id"] == "om_root123"
assert msg.metadata["thread_id"] == "om_root123"
def test_gateway_workspace_override_does_not_migrate_legacy_cron(
+222 -18
View File
@@ -4,7 +4,7 @@ import time
import pytest
from nanobot.cron.service import CronService
from nanobot.cron.service import CronJobSkippedError, CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
@@ -43,7 +43,7 @@ def test_add_job_accepts_valid_timezone(tmp_path) -> None:
assert job.state.next_run_at_ms is not None
def test_add_job_preserves_channel_meta_and_session_key(tmp_path) -> None:
def test_add_job_migrates_legacy_delivery_context(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
meta = {"slack": {"thread_ts": "1234567890.123456", "channel_type": "channel"}}
job = service.add_job(
@@ -56,13 +56,160 @@ def test_add_job_preserves_channel_meta_and_session_key(tmp_path) -> None:
channel_meta=meta,
session_key="slack:C123:1234567890.123456",
)
assert job.payload.channel_meta == meta
assert job.payload.deliver is False
assert job.payload.channel is None
assert job.payload.to is None
assert job.payload.channel_meta == {}
assert job.payload.session_key == "slack:C123:1234567890.123456"
assert job.payload.origin_channel == "slack"
assert job.payload.origin_chat_id == "C123"
assert job.payload.origin_metadata == meta
reloaded = service.get_job(job.id)
assert reloaded is not None
assert reloaded.payload.channel_meta == meta
assert reloaded.payload.channel_meta == {}
assert reloaded.payload.session_key == "slack:C123:1234567890.123456"
assert reloaded.payload.origin_channel == "slack"
assert reloaded.payload.origin_chat_id == "C123"
assert reloaded.payload.origin_metadata == meta
def test_load_store_migrates_legacy_delivery_context(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json"
store_path.parent.mkdir(parents=True)
store_path.write_text(
json.dumps(
{
"version": 1,
"jobs": [
{
"id": "legacy-1",
"name": "Legacy reminder",
"enabled": True,
"schedule": {"kind": "every", "everyMs": 60_000},
"payload": {
"kind": "agent_turn",
"message": "check status",
"deliver": True,
"channel": "telegram",
"to": "user-1",
"channelMeta": {"message_thread_id": 42},
"sessionKey": "telegram:user-1:topic:42",
},
"state": {},
"createdAtMs": 1,
"updatedAtMs": 1,
}
],
}
),
encoding="utf-8",
)
job = CronService(store_path).get_job("legacy-1")
assert job is not None
assert job.payload.session_key == "telegram:user-1:topic:42"
assert job.payload.origin_channel == "telegram"
assert job.payload.origin_chat_id == "user-1"
assert job.payload.origin_metadata == {"message_thread_id": 42}
assert job.payload.deliver is False
assert job.payload.channel is None
assert job.payload.to is None
assert job.payload.channel_meta == {}
def test_load_store_disables_malformed_legacy_payload(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json"
store_path.parent.mkdir(parents=True)
store_path.write_text(
json.dumps(
{
"version": 1,
"jobs": [
{
"id": "legacy-bad",
"name": "Broken legacy",
"enabled": True,
"schedule": {"kind": "every", "everyMs": 60_000},
"payload": {
"kind": "agent_turn",
"message": "check status",
"deliver": True,
},
"state": {"nextRunAtMs": 123},
"createdAtMs": 1,
"updatedAtMs": 1,
}
],
}
),
encoding="utf-8",
)
job = CronService(store_path).get_job("legacy-bad")
assert job is not None
assert job.enabled is False
assert job.state.next_run_at_ms is None
assert job.state.last_status == "error"
assert "missing channel/to" in (job.state.last_error or "")
assert job.payload.deliver is False
def test_list_bound_agent_jobs_includes_migrated_legacy_delivery_payloads(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
schedule = CronSchedule(kind="every", every_ms=60_000)
bound = service.add_job(
name="Bound",
schedule=schedule,
message="new bound job",
session_key="websocket:chat-1",
origin_channel="websocket",
origin_chat_id="chat-1",
)
migrated = service.add_job(
name="Legacy same session",
schedule=schedule,
message="legacy job",
deliver=True,
channel="websocket",
to="chat-1",
session_key="websocket:chat-1",
)
assert service.list_bound_cron_jobs_for_session("websocket:chat-1") == [bound, migrated]
def test_add_job_preserves_origin_delivery_context(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
metadata = {"slack": {"thread_ts": "1234567890.123456", "channel_type": "channel"}}
job = service.add_job(
name="bound thread",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
session_key="slack:C123:1234567890.123456",
origin_channel="slack",
origin_chat_id="C123",
origin_metadata=metadata,
)
assert job.payload.origin_channel == "slack"
assert job.payload.origin_chat_id == "C123"
assert job.payload.origin_metadata == metadata
raw = json.loads((tmp_path / "cron" / "action.jsonl").read_text(encoding="utf-8"))
payload = raw["params"]["payload"]
assert payload["origin_channel"] == "slack"
assert payload["origin_chat_id"] == "C123"
assert payload["origin_metadata"] == metadata
reloaded = service.get_job(job.id)
assert reloaded is not None
assert reloaded.payload.origin_channel == "slack"
assert reloaded.payload.origin_chat_id == "C123"
assert reloaded.payload.origin_metadata == metadata
@pytest.mark.asyncio
@@ -81,19 +228,31 @@ async def test_channel_meta_and_session_key_survive_store_reload(tmp_path) -> No
to="C123",
channel_meta=meta,
session_key="slack:C123:1234567890.123456",
origin_channel="slack",
origin_chat_id="C123",
origin_metadata=meta,
)
finally:
service.stop()
raw = json.loads(store_path.read_text(encoding="utf-8"))
payload = raw["jobs"][0]["payload"]
assert payload["channelMeta"] == meta
assert payload["deliver"] is False
assert payload["channel"] is None
assert payload["to"] is None
assert payload["channelMeta"] == {}
assert payload["sessionKey"] == "slack:C123:1234567890.123456"
assert payload["originChannel"] == "slack"
assert payload["originChatId"] == "C123"
assert payload["originMetadata"] == meta
reloaded = CronService(store_path).get_job(job.id)
assert reloaded is not None
assert reloaded.payload.channel_meta == meta
assert reloaded.payload.channel_meta == {}
assert reloaded.payload.session_key == "slack:C123:1234567890.123456"
assert reloaded.payload.origin_channel == "slack"
assert reloaded.payload.origin_chat_id == "C123"
assert reloaded.payload.origin_metadata == meta
@pytest.mark.asyncio
@@ -137,6 +296,57 @@ async def test_run_history_records_errors(tmp_path) -> None:
assert loaded.state.run_history[0].error == "boom"
@pytest.mark.asyncio
async def test_run_history_records_skipped_jobs(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json"
async def skip(_):
raise CronJobSkippedError("missing session binding")
service = CronService(store_path, on_job=skip)
job = service.add_job(
name="skip",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
)
await service.run_job(job.id)
loaded = service.get_job(job.id)
assert loaded is not None
assert loaded.state.last_status == "skipped"
assert loaded.state.last_error == "missing session binding"
assert len(loaded.state.run_history) == 1
assert loaded.state.run_history[0].status == "skipped"
assert loaded.state.run_history[0].error == "missing session binding"
@pytest.mark.asyncio
async def test_run_history_records_job_cancellation(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json"
async def cancel(_):
raise asyncio.CancelledError("turn cancelled")
service = CronService(store_path, on_job=cancel)
job = service.add_job(
name="cancel",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
session_key="websocket:chat-1",
)
assert await service.run_job(job.id) is True
loaded = service.get_job(job.id)
assert loaded is not None
assert loaded.state.last_status == "error"
assert loaded.state.last_error == "turn cancelled"
assert len(loaded.state.run_history) == 1
assert loaded.state.run_history[0].status == "error"
assert loaded.state.run_history[0].error == "turn cancelled"
assert loaded.state.next_run_at_ms is not None
@pytest.mark.asyncio
async def test_run_history_trimmed_to_max(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json"
@@ -559,28 +769,22 @@ def test_update_job_offline_writes_action(tmp_path) -> None:
assert last["params"]["name"] == "updated-offline"
def test_update_job_sentinel_channel_and_to(tmp_path) -> None:
"""Passing None clears channel/to; omitting leaves them unchanged."""
def test_update_job_migrates_legacy_delivery_target(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
job = service.add_job(
name="sentinel",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
channel="telegram",
to="user123",
)
assert job.payload.channel == "telegram"
assert job.payload.to == "user123"
result = service.update_job(job.id, name="renamed")
assert isinstance(result, CronJob)
assert result.payload.channel == "telegram"
assert result.payload.to == "user123"
result = service.update_job(job.id, channel=None, to=None)
result = service.update_job(job.id, channel="telegram", to="user123")
assert isinstance(result, CronJob)
assert result.payload.session_key == "telegram:user123"
assert result.payload.origin_channel == "telegram"
assert result.payload.origin_chat_id == "user123"
assert result.payload.channel is None
assert result.payload.to is None
assert result.payload.channel_meta == {}
@pytest.mark.asyncio
+31 -14
View File
@@ -303,7 +303,9 @@ def test_remove_protected_dream_job_returns_clear_feedback(tmp_path) -> None:
def test_add_cron_job_defaults_to_tool_timezone(tmp_path) -> None:
tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai")
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
tool.set_context(
RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1")
)
result = tool._add_job(None, "Morning standup", None, "0 8 * * *", None, None)
@@ -314,7 +316,9 @@ def test_add_cron_job_defaults_to_tool_timezone(tmp_path) -> None:
def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None:
tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai")
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
tool.set_context(
RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1")
)
result = tool._add_job(None, "Morning reminder", None, None, None, "2026-03-25T08:00:00")
@@ -324,26 +328,32 @@ def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None:
assert job.schedule.at_ms == expected
def test_add_job_delivers_by_default(tmp_path) -> None:
def test_add_job_binds_current_session_key(tmp_path) -> None:
tool = _make_tool(tmp_path)
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
tool.set_context(
RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1")
)
result = tool._add_job(None, "Morning standup", 60, None, None, None)
assert result.startswith("Created job")
job = tool._cron.list_jobs()[0]
assert job.payload.deliver is True
assert job.payload.session_key == "telegram:chat-1"
assert job.payload.origin_channel == "telegram"
assert job.payload.origin_chat_id == "chat-1"
assert job.payload.origin_metadata == {}
assert job.payload.channel is None
assert job.payload.to is None
def test_add_job_can_disable_delivery(tmp_path) -> None:
def test_add_job_requires_session_key(tmp_path) -> None:
tool = _make_tool(tmp_path)
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
result = tool._add_job(None, "Background refresh", 60, None, None, None, deliver=False)
result = tool._add_job(None, "Background refresh", 60, None, None, None)
assert result.startswith("Created job")
job = tool._cron.list_jobs()[0]
assert job.payload.deliver is False
assert result == "Error: scheduled cron jobs must be created from a chat session"
assert tool._cron.list_jobs() == []
def test_cron_schema_advertises_action_specific_requirements(tmp_path) -> None:
@@ -375,7 +385,9 @@ def test_validate_params_requires_message_only_for_add(tmp_path) -> None:
def test_add_job_empty_message_returns_actionable_error(tmp_path) -> None:
tool = _make_tool(tmp_path)
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
tool.set_context(
RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1")
)
result = tool._add_job(None, "", 60, None, None, None)
@@ -383,8 +395,8 @@ def test_add_job_empty_message_returns_actionable_error(tmp_path) -> None:
assert "Retry including message=" in result
def test_add_job_captures_metadata_and_session_key(tmp_path) -> None:
"""CronTool stores channel metadata and session_key when adding a job."""
def test_add_job_captures_owner_and_origin_without_legacy_delivery_fields(tmp_path) -> None:
"""CronTool stores owner/session identity separately from origin delivery context."""
tool = _make_tool(tmp_path)
meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}}
tool.set_context(RequestContext(
@@ -396,8 +408,13 @@ def test_add_job_captures_metadata_and_session_key(tmp_path) -> None:
jobs = tool._cron.list_jobs()
assert len(jobs) == 1
assert jobs[0].payload.channel_meta == meta
assert jobs[0].payload.session_key == "slack:C99:111.222"
assert jobs[0].payload.origin_channel == "slack"
assert jobs[0].payload.origin_chat_id == "C99"
assert jobs[0].payload.origin_metadata == meta
assert jobs[0].payload.channel is None
assert jobs[0].payload.to is None
assert jobs[0].payload.channel_meta == {}
def test_list_excludes_disabled_jobs(tmp_path) -> None:
+3 -1
View File
@@ -41,7 +41,9 @@ class _SvcStub:
@pytest.fixture
def registry() -> ToolRegistry:
tool = CronTool(_SvcStub(), default_timezone="UTC")
tool.set_context(RequestContext(channel="channel", chat_id="chat-id"))
tool.set_context(
RequestContext(channel="channel", chat_id="chat-id", session_key="channel:chat-id")
)
reg = ToolRegistry()
reg.register(tool)
return reg
+44
View File
@@ -0,0 +1,44 @@
import pytest
from nanobot.cron.session_delivery import origin_delivery_context
from nanobot.cron.types import CronJob, CronPayload
def test_origin_delivery_context_uses_explicit_origin_fields() -> None:
metadata = {
"context_chat_id": "456",
"parent_channel_id": "456",
"thread_id": "777",
}
job = CronJob(
id="thread-check",
name="Thread check",
payload=CronPayload(
message="check",
session_key="discord:456:thread:777",
origin_channel="discord",
origin_chat_id="777",
origin_metadata=metadata,
),
)
channel, chat_id, returned_metadata = origin_delivery_context(job)
assert channel == "discord"
assert chat_id == "777"
assert returned_metadata == metadata
assert returned_metadata is not metadata
def test_origin_delivery_context_rejects_missing_origin_fields() -> None:
job = CronJob(
id="old-bound",
name="Old bound job",
payload=CronPayload(
message="check",
session_key="websocket:chat-1",
),
)
with pytest.raises(ValueError, match="missing origin delivery context"):
origin_delivery_context(job)
+76 -8
View File
@@ -4,11 +4,13 @@ import asyncio
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.context import RequestContext
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.cron.service import CronService
from nanobot.session.keys import UNIFIED_SESSION_KEY
@pytest.mark.asyncio
@@ -99,14 +101,18 @@ async def test_cron_tool_keeps_task_local_context(tmp_path) -> None:
release = asyncio.Event()
async def task_one() -> str:
tool.set_context(RequestContext(channel="feishu", chat_id="chat-a"))
tool.set_context(
RequestContext(channel="feishu", chat_id="chat-a", session_key="feishu:chat-a")
)
entered.set()
await release.wait()
return await tool.execute(action="add", message="first", every_seconds=60)
async def task_two() -> str:
await entered.wait()
tool.set_context(RequestContext(channel="email", chat_id="chat-b"))
tool.set_context(
RequestContext(channel="email", chat_id="chat-b", session_key="email:chat-b")
)
release.set()
return await tool.execute(action="add", message="second", every_seconds=60)
@@ -116,8 +122,11 @@ async def test_cron_tool_keeps_task_local_context(tmp_path) -> None:
assert result_two.startswith("Created job")
jobs = tool._cron.list_jobs()
assert {job.payload.channel for job in jobs} == {"feishu", "email"}
assert {job.payload.to for job in jobs} == {"chat-a", "chat-b"}
assert {job.payload.session_key for job in jobs} == {"feishu:chat-a", "email:chat-b"}
assert {(job.payload.origin_channel, job.payload.origin_chat_id) for job in jobs} == {
("feishu", "chat-a"),
("email", "chat-b"),
}
# --- Basic single-task regression tests ---
@@ -228,15 +237,74 @@ async def test_spawn_tool_default_values_without_set_context() -> None:
async def test_cron_tool_basic_set_context_and_execute(tmp_path) -> None:
"""Single task: set_context then add job should use correct target."""
tool = CronTool(CronService(tmp_path / "jobs.json"))
tool.set_context(RequestContext(channel="wechat", chat_id="user-789"))
tool.set_context(
RequestContext(channel="wechat", chat_id="user-789", session_key="wechat:user-789")
)
result = await tool.execute(action="add", message="standup", every_seconds=300)
assert result.startswith("Created job")
jobs = tool._cron.list_jobs()
assert len(jobs) == 1
assert jobs[0].payload.channel == "wechat"
assert jobs[0].payload.to == "user-789"
assert jobs[0].payload.session_key == "wechat:user-789"
assert jobs[0].payload.origin_channel == "wechat"
assert jobs[0].payload.origin_chat_id == "user-789"
@pytest.mark.asyncio
async def test_webui_cron_tool_uses_origin_session_when_unified_enabled(tmp_path) -> None:
"""WebUI-created cron jobs stay attached to the creating chat."""
tool = CronTool(CronService(tmp_path / "jobs.json"))
class _Tools:
tool_names = ["cron"]
def get(self, name: str):
return tool if name == "cron" else None
loop = object.__new__(AgentLoop)
loop._unified_session = True
loop.tools = _Tools()
loop._set_tool_context(
"websocket",
"chat-123",
metadata={"webui": True},
session_key=UNIFIED_SESSION_KEY,
)
result = await tool.execute(action="add", message="standup", every_seconds=300)
assert result.startswith("Created job")
jobs = tool._cron.list_jobs()
assert len(jobs) == 1
assert jobs[0].payload.session_key == "websocket:chat-123"
assert jobs[0].payload.origin_channel == "websocket"
assert jobs[0].payload.origin_chat_id == "chat-123"
assert jobs[0].payload.origin_metadata == {"webui": True}
@pytest.mark.asyncio
async def test_cron_tool_preserves_thread_scoped_session_key(tmp_path) -> None:
"""Channel-provided thread session keys should remain the cron owner."""
tool = CronTool(CronService(tmp_path / "jobs.json"))
tool.set_context(
RequestContext(
channel="slack",
chat_id="C123",
metadata={"slack": {"thread_ts": "1700.42"}},
session_key="slack:C123:1700.42",
)
)
result = await tool.execute(action="add", message="check thread", every_seconds=300)
assert result.startswith("Created job")
jobs = tool._cron.list_jobs()
assert len(jobs) == 1
assert jobs[0].payload.session_key == "slack:C123:1700.42"
assert jobs[0].payload.origin_channel == "slack"
assert jobs[0].payload.origin_chat_id == "C123"
assert jobs[0].payload.origin_metadata == {"slack": {"thread_ts": "1700.42"}}
@pytest.mark.asyncio
@@ -245,4 +313,4 @@ async def test_cron_tool_no_context_returns_error(tmp_path) -> None:
tool = CronTool(CronService(tmp_path / "jobs.json"))
result = await tool.execute(action="add", message="test", every_seconds=60)
assert result == "Error: no session context (channel/chat_id)"
assert result == "Error: scheduled cron jobs must be created from a chat session"
+85
View File
@@ -290,6 +290,91 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
assert msgs[1]["latencyMs"] == 42
def test_thread_response_does_not_mark_completed_message_tool_tail_pending(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:cron-tail"
turn_id = "cron:job:run"
for ev in (
{
"event": "message",
"chat_id": "cron-tail",
"text": 'message({"content":"Cron test"})',
"kind": "tool_hint",
"tool_events": [{
"phase": "start",
"call_id": "call-message",
"name": "message",
"arguments": {"content": "Cron test"},
}],
"turn_id": turn_id,
"turn_phase": "activity",
"turn_seq": 5,
},
{
"event": "message",
"chat_id": "cron-tail",
"text": "Cron test",
"source": {"kind": "cron", "label": "one-min-test"},
"turn_id": turn_id,
"turn_phase": "answer",
"turn_seq": 6,
},
{
"event": "message",
"chat_id": "cron-tail",
"text": "",
"kind": "progress",
"tool_events": [{
"phase": "end",
"call_id": "call-message",
"name": "message",
"arguments": {"content": "Cron test"},
"result": "ok",
}],
"turn_id": turn_id,
"turn_phase": "activity",
"turn_seq": 7,
},
{
"event": "turn_end",
"chat_id": "cron-tail",
"turn_id": turn_id,
"turn_phase": "complete",
"turn_seq": 8,
},
):
append_transcript_object(key, ev)
out = build_webui_thread_response(key)
assert out is not None
assert out["has_pending_tool_calls"] is False
assert out["messages"][-1]["kind"] == "trace"
assert out["messages"][-2]["content"] == "Cron test"
def test_thread_response_marks_unfinished_tool_tail_pending(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:active-tail"
append_transcript_object(
key,
{
"event": "message",
"chat_id": "active-tail",
"text": 'exec({"command":"date"})',
"kind": "tool_hint",
},
)
out = build_webui_thread_response(key)
assert out is not None
assert out["has_pending_tool_calls"] is True
def test_replay_preserves_turn_metadata(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-turn"
+15
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from pathlib import Path
import nanobot.webui.session_list_index as session_list_index
from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.session.manager import SessionManager
@@ -71,5 +72,19 @@ def test_webui_session_list_drops_deleted_index_rows(tmp_path: Path) -> None:
assert list_webui_sessions(manager) == []
def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) -> None:
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:cron-preview")
session.add_message(
"user",
"Scheduled cron job triggered: 30s-test\n\nInternal reminder prompt",
**{CRON_HISTORY_META: True},
)
session.add_message("assistant", "提醒已经到期。")
manager.save(session)
assert list_webui_sessions(manager)[0]["preview"] == "提醒已经到期。"
def list_webui_sessions(manager: SessionManager) -> list[dict]:
return session_list_index.list_webui_sessions(manager)