refactor: use cron turn naming internally

This commit is contained in:
chengyongru
2026-06-12 11:57:35 +08:00
parent d9d481bc15
commit 271b3651d7
19 changed files with 283 additions and 243 deletions
+12 -12
View File
@@ -8,7 +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.automation import AUTOMATION_HISTORY_META, AUTOMATION_TRIGGER_META
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
@@ -66,7 +66,7 @@ def test_agent_loop_llm_runtime_reflects_current_provider_and_model(tmp_path: Pa
assert runtime.model == "next-model"
def test_persist_automation_turn_uses_distinct_history_marker(tmp_path: Path) -> None:
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"}
@@ -76,14 +76,14 @@ def test_persist_automation_turn_uses_distinct_history_marker(tmp_path: Path) ->
channel="websocket",
sender_id="cron",
chat_id="auto",
content="Automation: internal prompt",
content="Cron job: internal prompt",
metadata={
AUTOMATION_TRIGGER_META: {
CRON_TRIGGER_META: {
"job_id": "job-1",
"job_name": "Daily check",
"run_id": "job-1:1",
"prompt_ref": prompt_ref,
"persist_content": "Scheduled automation triggered: Daily check",
"persist_content": "Scheduled cron job triggered: Daily check",
}
},
),
@@ -92,13 +92,13 @@ def test_persist_automation_turn_uses_distinct_history_marker(tmp_path: Path) ->
assert persisted is True
message = session.messages[-1]
assert message["content"] == "Scheduled automation triggered: Daily check"
assert message[AUTOMATION_HISTORY_META] is True
assert AUTOMATION_TRIGGER_META not in message
assert message["automation_id"] == "job-1"
assert message["automation_name"] == "Daily check"
assert message["automation_run_id"] == "job-1:1"
assert message["automation_prompt_ref"] == prompt_ref
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:
+11 -11
View File
@@ -617,12 +617,12 @@ async def test_followup_routed_to_pending_queue(tmp_path):
@pytest.mark.asyncio
async def test_automation_turn_deferred_while_session_active(tmp_path):
"""Automation turns wait for the active session instead of becoming injections."""
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.automation import (
AUTOMATION_DEFER_UNTIL_IDLE_META,
AUTOMATION_TRIGGER_META,
from nanobot.cron.session_turns import (
CRON_DEFER_UNTIL_IDLE_META,
CRON_TRIGGER_META,
)
loop = _make_loop(tmp_path)
@@ -639,15 +639,15 @@ async def test_automation_turn_deferred_while_session_active(tmp_path):
chat_id="chat-1",
content="scheduled work",
metadata={
AUTOMATION_TRIGGER_META: {"run_id": "run-1"},
AUTOMATION_DEFER_UNTIL_IDLE_META: True,
CRON_TRIGGER_META: {"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._deferred_automation_queues.get(session_key):
if loop._cron_turns.deferred_queues.get(session_key):
break
await asyncio.sleep(0.05)
@@ -656,12 +656,12 @@ async def test_automation_turn_deferred_while_session_active(tmp_path):
assert pending.empty()
assert loop._dispatch.await_count == 0
assert loop._deferred_automation_queues[session_key] == [msg]
assert loop._cron_turns.deferred_queues[session_key] == [msg]
await loop._publish_next_deferred_automation(session_key)
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._deferred_automation_queues
assert session_key not in loop._cron_turns.deferred_queues
@pytest.mark.asyncio
+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)
+3 -3
View File
@@ -749,7 +749,7 @@ async def test_session_delete_blocks_when_bound_automation_exists(
assert body["blocked_by_automations"] is True
assert [job["name"] for job in body["automations"]] == ["Daily check"]
assert path.exists()
assert cron.list_bound_agent_jobs_for_session("websocket:doomed")
assert cron.list_bound_cron_jobs_for_session("websocket:doomed")
finally:
await channel.stop()
await server_task
@@ -792,7 +792,7 @@ async def test_session_delete_can_cascade_bound_automations(
assert resp.status_code == 200
assert resp.json()["deleted"] is True
assert not path.exists()
assert cron.list_bound_agent_jobs_for_session("websocket:doomed") == []
assert cron.list_bound_cron_jobs_for_session("websocket:doomed") == []
assert [job.name for job in cron.list_jobs(include_disabled=True)] == [
"Legacy same target"
]
@@ -837,7 +837,7 @@ async def test_session_delete_does_not_cascade_unified_automations(
assert resp.status_code == 200
assert resp.json()["deleted"] is True
assert not path.exists()
assert [job.name for job in cron.list_bound_agent_jobs_for_session(UNIFIED_SESSION_KEY)] == [
assert [job.name for job in cron.list_bound_cron_jobs_for_session(UNIFIED_SESSION_KEY)] == [
"Shared daily check"
]
finally:
+11 -11
View File
@@ -11,7 +11,7 @@ from typer.testing import CliRunner
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.cli.commands import _proactive_delivery_metadata, app
from nanobot.config.schema import Config
from nanobot.cron.automation import AUTOMATION_DEFER_UNTIL_IDLE_META, AUTOMATION_TRIGGER_META
from nanobot.cron.session_turns import CRON_DEFER_UNTIL_IDLE_META, CRON_TRIGGER_META
from nanobot.cron.types import CronJob, CronPayload
from nanobot.providers.factory import ProviderSnapshot, make_provider
from nanobot.providers.openai_codex_provider import _strip_model_prefix
@@ -1430,8 +1430,8 @@ def test_gateway_legacy_cron_payloads_with_session_key_stay_legacy(
content="Legacy response.",
)
async def submit_automation_turn(self, _msg: InboundMessage):
raise AssertionError("legacy cron payload must not run as bound automation")
async def submit_cron_turn(self, _msg: InboundMessage):
raise AssertionError("legacy cron payload must not run as bound cron turn")
async def close_mcp(self) -> None:
return None
@@ -1601,8 +1601,8 @@ def test_gateway_bound_cron_runs_as_session_turn(
self.tools = {}
seen["agent"] = self
async def submit_automation_turn(self, msg: InboundMessage):
seen["automation_msg"] = msg
async def submit_cron_turn(self, msg: InboundMessage):
seen["cron_msg"] = msg
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
@@ -1646,26 +1646,26 @@ def test_gateway_bound_cron_runs_as_session_turn(
response = asyncio.run(cron.on_job(job))
assert response == "Checked the repo."
msg = seen["automation_msg"]
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 "Automation: Check repository health." in msg.content
assert "Cron job: Check repository health." in msg.content
assert msg.metadata["webui"] is True
assert msg.metadata["workspace_scope"]["project_path"] == str(tmp_path)
assert msg.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {
"kind": "cron",
"label": "Repo check",
}
trigger = msg.metadata[AUTOMATION_TRIGGER_META]
trigger = msg.metadata[CRON_TRIGGER_META]
assert trigger["job_id"] == "repo-check"
assert trigger["job_name"] == "Repo check"
assert trigger["persist_content"] == (
"Scheduled automation triggered: Repo check\n\nCheck repository health."
"Scheduled cron job triggered: Repo check\n\nCheck repository health."
)
assert msg.metadata[AUTOMATION_DEFER_UNTIL_IDLE_META] is True
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]
@@ -1682,7 +1682,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
response = asyncio.run(cron.on_job(discord_job))
assert response == "Checked the repo."
msg = seen["automation_msg"]
msg = seen["cron_msg"]
assert isinstance(msg, InboundMessage)
assert msg.channel == "discord"
assert msg.chat_id == "777"
+1 -1
View File
@@ -84,7 +84,7 @@ def test_list_bound_agent_jobs_excludes_legacy_delivery_payloads(tmp_path) -> No
session_key="websocket:chat-1",
)
assert service.list_bound_agent_jobs_for_session("websocket:chat-1") == [bound]
assert service.list_bound_cron_jobs_for_session("websocket:chat-1") == [bound]
@pytest.mark.asyncio
+1 -1
View File
@@ -349,7 +349,7 @@ def test_add_job_requires_session_key(tmp_path) -> None:
result = tool._add_job(None, "Background refresh", 60, None, None, None)
assert result == "Error: scheduled automations must be created from a chat session"
assert result == "Error: scheduled cron jobs must be created from a chat session"
assert tool._cron.list_jobs() == []