Merge PR #4299: feat(cron): bind scheduled automations to sessions
feat(cron): bind scheduled automations to sessions
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]] == ["提醒已经到期。"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user