refactor: bind cron jobs to origin sessions
This commit is contained in:
@@ -12,7 +12,6 @@ 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
|
||||
from nanobot.session.routing import SESSION_ROUTING_METADATA_KEY
|
||||
from nanobot.session.turn_continuation import (
|
||||
INTERNAL_CONTINUATION_META,
|
||||
INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
|
||||
@@ -864,12 +863,6 @@ async def test_process_message_uses_context_chat_id_for_runtime_prompt(tmp_path:
|
||||
assert result.chat_id == "thread-777"
|
||||
assert loop.context.build_messages.call_args.kwargs["chat_id"] == "parent-456"
|
||||
assert loop._run_agent_loop.call_args.kwargs["chat_id"] == "thread-777"
|
||||
session = loop.sessions.get_or_create("discord:parent-456:thread:thread-777")
|
||||
assert session.metadata[SESSION_ROUTING_METADATA_KEY] == {
|
||||
"channel": "discord",
|
||||
"chat_id": "thread-777",
|
||||
"metadata": {"context_chat_id": "parent-456"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.session.routing import SESSION_ROUTING_METADATA_KEY
|
||||
|
||||
|
||||
def _assert_no_orphans(history: list[dict]) -> None:
|
||||
@@ -433,11 +432,6 @@ def test_fork_session_before_user_index_copies_only_prefix(tmp_path):
|
||||
source.metadata["webui"] = True
|
||||
source.metadata["title"] = "Old title"
|
||||
source.metadata["goal_state"] = {"status": "active", "objective": "do not inherit"}
|
||||
source.metadata[SESSION_ROUTING_METADATA_KEY] = {
|
||||
"channel": "websocket",
|
||||
"chat_id": "source",
|
||||
"metadata": {},
|
||||
}
|
||||
source.add_message("user", "round1")
|
||||
source.add_message("assistant", "answer1")
|
||||
source.add_message("user", "round2 fork me")
|
||||
@@ -456,7 +450,6 @@ def test_fork_session_before_user_index_copies_only_prefix(tmp_path):
|
||||
assert forked.metadata["webui"] is True
|
||||
assert "title" not in forked.metadata
|
||||
assert "goal_state" not in forked.metadata
|
||||
assert SESSION_ROUTING_METADATA_KEY not in forked.metadata
|
||||
saved = manager.read_session_file("websocket:fork")
|
||||
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ async def test_session_automations_route_filters_by_webui_session(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_automations_route_uses_unified_owner_when_enabled(
|
||||
async def test_session_automations_route_uses_origin_owner_when_unified_enabled(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
cron = CronService(tmp_path / "cron" / "jobs.json")
|
||||
@@ -255,9 +255,9 @@ async def test_session_automations_route_uses_unified_owner_when_enabled(
|
||||
session_key=UNIFIED_SESSION_KEY,
|
||||
)
|
||||
cron.add_job(
|
||||
name="Visible thread only",
|
||||
name="Visible chat job",
|
||||
schedule=hourly,
|
||||
message="Do not show in unified mode",
|
||||
message="Show for this chat",
|
||||
session_key="websocket:abc",
|
||||
)
|
||||
channel = _ch(
|
||||
@@ -274,14 +274,19 @@ async def test_session_automations_route_uses_unified_owner_when_enabled(
|
||||
token = boot.json()["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
for key in ("websocket%3Aabc", "websocket%3Aother"):
|
||||
resp = await _http_get(
|
||||
f"http://127.0.0.1:29917/api/sessions/{key}/automations",
|
||||
headers=auth,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert [job["name"] for job in body["jobs"]] == ["Unified check"]
|
||||
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
|
||||
@@ -802,17 +807,17 @@ async def test_session_delete_can_cascade_bound_automations(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_does_not_cascade_unified_automations(
|
||||
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="Shared daily check",
|
||||
name="Chat daily check",
|
||||
schedule=CronSchedule(kind="every", every_ms=86_400_000),
|
||||
message="Check the shared session",
|
||||
session_key=UNIFIED_SESSION_KEY,
|
||||
message="Check this chat",
|
||||
session_key="websocket:doomed",
|
||||
)
|
||||
channel = _ch(
|
||||
bus,
|
||||
@@ -835,10 +840,13 @@ 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_cron_jobs_for_session(UNIFIED_SESSION_KEY)] == [
|
||||
"Shared daily check"
|
||||
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()
|
||||
|
||||
@@ -16,7 +16,6 @@ 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
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.session.routing import SESSION_ROUTING_METADATA_KEY
|
||||
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
|
||||
|
||||
runner = CliRunner()
|
||||
@@ -1548,38 +1547,10 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
||||
)
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus)
|
||||
|
||||
route_metadata = {
|
||||
"websocket:chat-1": {
|
||||
"workspace_scope": {
|
||||
"project_path": str(tmp_path),
|
||||
"access_mode": "restricted",
|
||||
},
|
||||
SESSION_ROUTING_METADATA_KEY: {
|
||||
"channel": "websocket",
|
||||
"chat_id": "chat-1",
|
||||
"metadata": {},
|
||||
},
|
||||
},
|
||||
"discord:456:thread:777": {
|
||||
SESSION_ROUTING_METADATA_KEY: {
|
||||
"channel": "discord",
|
||||
"chat_id": "777",
|
||||
"metadata": {
|
||||
"context_chat_id": "456",
|
||||
"parent_channel_id": "456",
|
||||
"thread_id": "777",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
class _FakeSessionManager:
|
||||
def __init__(self, _workspace: Path) -> None:
|
||||
pass
|
||||
|
||||
def read_session_file(self, key: str) -> dict[str, object] | None:
|
||||
return {"metadata": route_metadata.get(key, {})}
|
||||
|
||||
monkeypatch.setattr("nanobot.session.manager.SessionManager", _FakeSessionManager)
|
||||
|
||||
class _FakeCron:
|
||||
@@ -1651,10 +1622,9 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
||||
assert msg.channel == "websocket"
|
||||
assert msg.chat_id == "chat-1"
|
||||
assert msg.sender_id == "cron"
|
||||
assert msg.session_key_override == "websocket:chat-1"
|
||||
assert msg.session_key_override is None
|
||||
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",
|
||||
@@ -1675,7 +1645,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
||||
name="Thread check",
|
||||
payload=CronPayload(
|
||||
message="Check the Discord thread.",
|
||||
session_key="discord:456:thread:777",
|
||||
session_key="discord:777",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1686,10 +1656,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
||||
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"
|
||||
assert msg.session_key_override is None
|
||||
|
||||
|
||||
def test_gateway_cron_job_suppresses_intermediate_progress(
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.session.routing import routing_context_for_message
|
||||
|
||||
|
||||
def test_routing_context_keeps_telegram_topic_without_stale_message_id() -> None:
|
||||
context = routing_context_for_message(
|
||||
InboundMessage(
|
||||
channel="telegram",
|
||||
sender_id="user-1",
|
||||
chat_id="-100123",
|
||||
content="set a reminder",
|
||||
metadata={
|
||||
"message_id": 100,
|
||||
"message_thread_id": 42,
|
||||
"_progress": True,
|
||||
},
|
||||
session_key_override="telegram:-100123:topic:42",
|
||||
)
|
||||
)
|
||||
|
||||
assert context == {
|
||||
"channel": "telegram",
|
||||
"chat_id": "-100123",
|
||||
"metadata": {"message_thread_id": 42},
|
||||
}
|
||||
|
||||
|
||||
def test_routing_context_keeps_feishu_topic_anchor() -> None:
|
||||
context = routing_context_for_message(
|
||||
InboundMessage(
|
||||
channel="feishu",
|
||||
sender_id="ou_user",
|
||||
chat_id="oc_chat",
|
||||
content="set a reminder",
|
||||
metadata={
|
||||
"chat_type": "group",
|
||||
"message_id": "om_msg",
|
||||
"thread_id": "omt_thread",
|
||||
"_progress": True,
|
||||
},
|
||||
session_key_override="feishu:oc_chat:om_root",
|
||||
)
|
||||
)
|
||||
|
||||
assert context == {
|
||||
"channel": "feishu",
|
||||
"chat_id": "oc_chat",
|
||||
"metadata": {
|
||||
"chat_type": "group",
|
||||
"message_id": "om_msg",
|
||||
"thread_id": "omt_thread",
|
||||
},
|
||||
}
|
||||
@@ -246,8 +246,8 @@ async def test_cron_tool_basic_set_context_and_execute(tmp_path) -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_cron_tool_uses_unified_session_when_enabled(tmp_path) -> None:
|
||||
"""WebUI-created automations should follow unified session ownership."""
|
||||
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:
|
||||
@@ -271,7 +271,7 @@ async def test_webui_cron_tool_uses_unified_session_when_enabled(tmp_path) -> No
|
||||
|
||||
jobs = tool._cron.list_jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0].payload.session_key == UNIFIED_SESSION_KEY
|
||||
assert jobs[0].payload.session_key == "websocket:chat-123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -280,4 +280,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: scheduled automations must be created from a chat session"
|
||||
assert result == "Error: scheduled cron jobs must be created from a chat session"
|
||||
|
||||
Reference in New Issue
Block a user