mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-16 07:44:06 +00:00
refactor: bind cron jobs to origin sessions
This commit is contained in:
parent
271b3651d7
commit
80524e9e88
@ -213,20 +213,23 @@ Rules:
|
|||||||
session being deleted.
|
session being deleted.
|
||||||
- Do not block on system jobs.
|
- Do not block on system jobs.
|
||||||
- Do not block on legacy unbound jobs.
|
- Do not block on legacy unbound jobs.
|
||||||
- In unified-session mode, WebUI chats display cron jobs owned by
|
- In unified-session mode, WebUI-created cron jobs still belong to the concrete
|
||||||
`unified:default`, but deleting an individual `websocket:*` thread should not
|
`websocket:*` chat that created them, so deleting that chat should block on or
|
||||||
block on or delete those unified cron jobs.
|
delete those jobs.
|
||||||
- If the user manually deletes files outside the WebUI/API, do not try to
|
- If the user manually deletes files outside the WebUI/API, do not try to
|
||||||
compensate.
|
compensate.
|
||||||
|
|
||||||
## Unified Session Mode
|
## Unified Session Mode
|
||||||
|
|
||||||
When `unified_session` is enabled, WebUI-created cron jobs should bind to the
|
When `unified_session` is enabled, WebUI-created cron jobs should still bind to
|
||||||
same unified session as normal WebUI chat turns: `unified:default`.
|
the concrete WebUI chat that created them, for example `websocket:<chat_id>`.
|
||||||
|
The cron trigger is delivered through that original chat. `AgentLoop` then
|
||||||
|
applies `unified_session` normally, so the turn's memory/session context may be
|
||||||
|
`unified:default` even though the cron job's ownership key is concrete.
|
||||||
|
|
||||||
- All WebUI chats should display cron jobs owned by `unified:default`.
|
- Each WebUI chat should display cron jobs owned by that concrete chat.
|
||||||
- Individual WebUI thread deletion should remain scoped to the concrete
|
- Individual WebUI thread deletion should block on cron jobs owned by that
|
||||||
`websocket:*` thread being deleted.
|
concrete `websocket:*` thread.
|
||||||
- Toggling `unified_session` does not migrate existing cron jobs. Existing jobs
|
- Toggling `unified_session` does not migrate existing cron jobs. Existing jobs
|
||||||
keep their stored `payload.session_key` and continue to execute against that
|
keep their stored `payload.session_key` and continue to execute against that
|
||||||
owner until explicitly removed or recreated.
|
owner until explicitly removed or recreated.
|
||||||
|
|||||||
@ -59,7 +59,6 @@ from nanobot.session.goal_state import (
|
|||||||
)
|
)
|
||||||
from nanobot.session.keys import UNIFIED_SESSION_KEY, session_key_for_channel
|
from nanobot.session.keys import UNIFIED_SESSION_KEY, session_key_for_channel
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.session.routing import persist_routing_context
|
|
||||||
from nanobot.utils.document import extract_documents, reference_non_image_attachments
|
from nanobot.utils.document import extract_documents, reference_non_image_attachments
|
||||||
from nanobot.utils.helpers import image_placeholder_text
|
from nanobot.utils.helpers import image_placeholder_text
|
||||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
||||||
@ -1389,8 +1388,6 @@ class AgentLoop:
|
|||||||
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
||||||
await self._runtime_events().session_turn_started(msg, ctx.session_key)
|
await self._runtime_events().session_turn_started(msg, ctx.session_key)
|
||||||
self.workspace_scopes.persist_message_scope(ctx.session, msg)
|
self.workspace_scopes.persist_message_scope(ctx.session, msg)
|
||||||
if persist_routing_context(ctx.session, msg):
|
|
||||||
self.sessions.save(ctx.session)
|
|
||||||
|
|
||||||
if self._restore_runtime_checkpoint(ctx.session):
|
if self._restore_runtime_checkpoint(ctx.session):
|
||||||
self.sessions.save(ctx.session)
|
self.sessions.save(ctx.session)
|
||||||
|
|||||||
@ -75,7 +75,7 @@ class CronTool(Tool, ContextAware):
|
|||||||
self._channel.set(ctx.channel)
|
self._channel.set(ctx.channel)
|
||||||
self._chat_id.set(ctx.chat_id)
|
self._chat_id.set(ctx.chat_id)
|
||||||
self._metadata.set(ctx.metadata)
|
self._metadata.set(ctx.metadata)
|
||||||
self._session_key.set(ctx.session_key or "")
|
self._session_key.set(f"{ctx.channel}:{ctx.chat_id}" if ctx.channel and ctx.chat_id else "")
|
||||||
|
|
||||||
def set_cron_context(self, active: bool):
|
def set_cron_context(self, active: bool):
|
||||||
"""Mark whether the tool is executing inside a cron job callback."""
|
"""Mark whether the tool is executing inside a cron job callback."""
|
||||||
|
|||||||
@ -988,9 +988,7 @@ def _run_gateway(
|
|||||||
from nanobot.cron.types import CronJob
|
from nanobot.cron.types import CronJob
|
||||||
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
|
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
|
||||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.session.routing import read_routing_context
|
|
||||||
from nanobot.session.webui_turns import WebuiTurnCoordinator
|
from nanobot.session.webui_turns import WebuiTurnCoordinator
|
||||||
from nanobot.utils.prompt_templates import render_template
|
from nanobot.utils.prompt_templates import render_template
|
||||||
from nanobot.webui.token_usage import TokenUsageHook
|
from nanobot.webui.token_usage import TokenUsageHook
|
||||||
@ -1046,11 +1044,6 @@ def _run_gateway(
|
|||||||
unified_session=config.agents.defaults.unified_session,
|
unified_session=config.agents.defaults.unified_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _session_metadata(session_key: str) -> dict[str, Any]:
|
|
||||||
data = session_manager.read_session_file(session_key)
|
|
||||||
metadata = data.get("metadata", {}) if isinstance(data, dict) else {}
|
|
||||||
return dict(metadata) if isinstance(metadata, dict) else {}
|
|
||||||
|
|
||||||
def _bound_session_delivery_context(
|
def _bound_session_delivery_context(
|
||||||
session_key: str,
|
session_key: str,
|
||||||
*,
|
*,
|
||||||
@ -1063,18 +1056,10 @@ def _run_gateway(
|
|||||||
if not channel or not rest:
|
if not channel or not rest:
|
||||||
raise ValueError(f"bound cron session_key is invalid: {session_key!r}")
|
raise ValueError(f"bound cron session_key is invalid: {session_key!r}")
|
||||||
|
|
||||||
session_metadata = _session_metadata(session_key)
|
metadata: dict[str, Any] = {}
|
||||||
routed = read_routing_context(session_metadata)
|
|
||||||
if routed is not None:
|
|
||||||
channel, rest, metadata = routed
|
|
||||||
else:
|
|
||||||
metadata: dict[str, Any] = {}
|
|
||||||
|
|
||||||
if channel == "websocket":
|
if channel == "websocket":
|
||||||
metadata["webui"] = True
|
metadata["webui"] = True
|
||||||
scope = session_metadata.get(WORKSPACE_SCOPE_METADATA_KEY)
|
|
||||||
if isinstance(scope, dict):
|
|
||||||
metadata[WORKSPACE_SCOPE_METADATA_KEY] = dict(scope)
|
|
||||||
metadata.update(
|
metadata.update(
|
||||||
_proactive_delivery_metadata(
|
_proactive_delivery_metadata(
|
||||||
"websocket",
|
"websocket",
|
||||||
@ -1156,7 +1141,6 @@ def _run_gateway(
|
|||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
content=prompt,
|
content=prompt,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
session_key_override=session_key,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except (Exception, asyncio.CancelledError) as exc:
|
except (Exception, asyncio.CancelledError) as exc:
|
||||||
|
|||||||
@ -14,7 +14,6 @@ from typing import Any
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.config.paths import get_legacy_sessions_dir
|
from nanobot.config.paths import get_legacy_sessions_dir
|
||||||
from nanobot.session.metadata import SESSION_ROUTING_METADATA_KEY
|
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
ensure_dir,
|
ensure_dir,
|
||||||
estimate_message_tokens,
|
estimate_message_tokens,
|
||||||
@ -37,7 +36,6 @@ _FORK_VOLATILE_METADATA_KEYS = {
|
|||||||
"pending_user_turn",
|
"pending_user_turn",
|
||||||
"runtime_checkpoint",
|
"runtime_checkpoint",
|
||||||
"thread_goal",
|
"thread_goal",
|
||||||
SESSION_ROUTING_METADATA_KEY,
|
|
||||||
"title",
|
"title",
|
||||||
"title_user_edited",
|
"title_user_edited",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
"""Shared session metadata keys."""
|
|
||||||
|
|
||||||
SESSION_ROUTING_METADATA_KEY = "_routing_context"
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
"""Persisted session routing context for proactive turns."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
from nanobot.bus.events import InboundMessage
|
|
||||||
from nanobot.cron.session_turns import is_cron_turn
|
|
||||||
from nanobot.session.manager import Session
|
|
||||||
from nanobot.session.metadata import SESSION_ROUTING_METADATA_KEY
|
|
||||||
|
|
||||||
_ROUTING_METADATA_KEYS = {
|
|
||||||
"chat_type",
|
|
||||||
"context_chat_id",
|
|
||||||
"conversation_type",
|
|
||||||
"event_id",
|
|
||||||
"message_thread_id",
|
|
||||||
"msg_type",
|
|
||||||
"parent_channel_id",
|
|
||||||
"parent_id",
|
|
||||||
"platform",
|
|
||||||
"root_id",
|
|
||||||
"thread_id",
|
|
||||||
"thread_reply_to_event_id",
|
|
||||||
"thread_root_event_id",
|
|
||||||
}
|
|
||||||
_CHANNEL_ROUTING_METADATA_KEYS = {
|
|
||||||
# Feishu needs a message anchor to reply into an existing topic. Other
|
|
||||||
# channels should avoid stale reply anchors for scheduled cron turns.
|
|
||||||
"feishu": {"message_id"},
|
|
||||||
}
|
|
||||||
_SLACK_ROUTING_KEYS = {"channel_type", "thread_ts"}
|
|
||||||
|
|
||||||
|
|
||||||
def _scalar(value: Any) -> str | int | float | bool | None:
|
|
||||||
if value is None or isinstance(value, (str, int, float, bool)):
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _routing_metadata(channel: str, metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
||||||
if not isinstance(metadata, Mapping):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
out: dict[str, Any] = {}
|
|
||||||
keys = _ROUTING_METADATA_KEYS | _CHANNEL_ROUTING_METADATA_KEYS.get(channel, set())
|
|
||||||
for key in keys:
|
|
||||||
if key not in metadata:
|
|
||||||
continue
|
|
||||||
value = _scalar(metadata.get(key))
|
|
||||||
if value is not None:
|
|
||||||
out[key] = value
|
|
||||||
|
|
||||||
slack = metadata.get("slack")
|
|
||||||
if isinstance(slack, Mapping):
|
|
||||||
slack_out = {
|
|
||||||
key: value
|
|
||||||
for key in _SLACK_ROUTING_KEYS
|
|
||||||
if (value := _scalar(slack.get(key))) is not None
|
|
||||||
}
|
|
||||||
if slack_out:
|
|
||||||
out["slack"] = slack_out
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def routing_context_for_message(msg: InboundMessage) -> dict[str, Any]:
|
|
||||||
"""Return the stable routing context needed to deliver future session turns."""
|
|
||||||
return {
|
|
||||||
"channel": msg.channel,
|
|
||||||
"chat_id": msg.chat_id,
|
|
||||||
"metadata": _routing_metadata(msg.channel, msg.metadata),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def persist_routing_context(session: Session, msg: InboundMessage) -> bool:
|
|
||||||
"""Persist the latest non-cron delivery context for a session."""
|
|
||||||
if is_cron_turn(msg.metadata):
|
|
||||||
return False
|
|
||||||
context = routing_context_for_message(msg)
|
|
||||||
if session.metadata.get(SESSION_ROUTING_METADATA_KEY) == context:
|
|
||||||
return False
|
|
||||||
session.metadata[SESSION_ROUTING_METADATA_KEY] = context
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def read_routing_context(metadata: Mapping[str, Any] | None) -> tuple[str, str, dict[str, Any]] | None:
|
|
||||||
"""Decode a persisted routing context from session metadata."""
|
|
||||||
if not isinstance(metadata, Mapping):
|
|
||||||
return None
|
|
||||||
raw = metadata.get(SESSION_ROUTING_METADATA_KEY)
|
|
||||||
if not isinstance(raw, Mapping):
|
|
||||||
return None
|
|
||||||
|
|
||||||
channel = raw.get("channel")
|
|
||||||
chat_id = raw.get("chat_id")
|
|
||||||
if not isinstance(channel, str) or not channel:
|
|
||||||
return None
|
|
||||||
if not isinstance(chat_id, str) or not chat_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
route_meta = raw.get("metadata")
|
|
||||||
metadata_out = dict(route_meta) if isinstance(route_meta, Mapping) else {}
|
|
||||||
return channel, chat_id, metadata_out
|
|
||||||
@ -21,7 +21,6 @@ from websockets.http11 import Request as WsRequest
|
|||||||
from websockets.http11 import Response
|
from websockets.http11 import Response
|
||||||
|
|
||||||
from nanobot.command.builtin import builtin_command_palette
|
from nanobot.command.builtin import builtin_command_palette
|
||||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
|
||||||
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
||||||
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
|
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
|
||||||
from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload
|
from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload
|
||||||
@ -473,8 +472,6 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
def _automation_display_key(self, session_key: str) -> str:
|
def _automation_display_key(self, session_key: str) -> str:
|
||||||
"""Return the cron ownership key shown for this WebUI thread."""
|
"""Return the cron ownership key shown for this WebUI thread."""
|
||||||
if self._unified_session:
|
|
||||||
return UNIFIED_SESSION_KEY
|
|
||||||
return session_key
|
return session_key
|
||||||
|
|
||||||
# -- Media routes -------------------------------------------------------
|
# -- Media routes -------------------------------------------------------
|
||||||
|
|||||||
@ -12,7 +12,6 @@ from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
|
|||||||
from nanobot.providers.base import LLMResponse
|
from nanobot.providers.base import LLMResponse
|
||||||
from nanobot.session.goal_state import GOAL_STATE_KEY
|
from nanobot.session.goal_state import GOAL_STATE_KEY
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.session.routing import SESSION_ROUTING_METADATA_KEY
|
|
||||||
from nanobot.session.turn_continuation import (
|
from nanobot.session.turn_continuation import (
|
||||||
INTERNAL_CONTINUATION_META,
|
INTERNAL_CONTINUATION_META,
|
||||||
INTERNAL_CONTINUATION_RUN_STARTED_AT_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 result.chat_id == "thread-777"
|
||||||
assert loop.context.build_messages.call_args.kwargs["chat_id"] == "parent-456"
|
assert loop.context.build_messages.call_args.kwargs["chat_id"] == "parent-456"
|
||||||
assert loop._run_agent_loop.call_args.kwargs["chat_id"] == "thread-777"
|
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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.session.routing import SESSION_ROUTING_METADATA_KEY
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_no_orphans(history: list[dict]) -> None:
|
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["webui"] = True
|
||||||
source.metadata["title"] = "Old title"
|
source.metadata["title"] = "Old title"
|
||||||
source.metadata["goal_state"] = {"status": "active", "objective": "do not inherit"}
|
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("user", "round1")
|
||||||
source.add_message("assistant", "answer1")
|
source.add_message("assistant", "answer1")
|
||||||
source.add_message("user", "round2 fork me")
|
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 forked.metadata["webui"] is True
|
||||||
assert "title" not in forked.metadata
|
assert "title" not in forked.metadata
|
||||||
assert "goal_state" 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")
|
saved = manager.read_session_file("websocket:fork")
|
||||||
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
|
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
|
@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
|
bus: MagicMock, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
cron = CronService(tmp_path / "cron" / "jobs.json")
|
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,
|
session_key=UNIFIED_SESSION_KEY,
|
||||||
)
|
)
|
||||||
cron.add_job(
|
cron.add_job(
|
||||||
name="Visible thread only",
|
name="Visible chat job",
|
||||||
schedule=hourly,
|
schedule=hourly,
|
||||||
message="Do not show in unified mode",
|
message="Show for this chat",
|
||||||
session_key="websocket:abc",
|
session_key="websocket:abc",
|
||||||
)
|
)
|
||||||
channel = _ch(
|
channel = _ch(
|
||||||
@ -274,14 +274,19 @@ async def test_session_automations_route_uses_unified_owner_when_enabled(
|
|||||||
token = boot.json()["token"]
|
token = boot.json()["token"]
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
for key in ("websocket%3Aabc", "websocket%3Aother"):
|
resp = await _http_get(
|
||||||
resp = await _http_get(
|
"http://127.0.0.1:29917/api/sessions/websocket%3Aabc/automations",
|
||||||
f"http://127.0.0.1:29917/api/sessions/{key}/automations",
|
headers=auth,
|
||||||
headers=auth,
|
)
|
||||||
)
|
assert resp.status_code == 200
|
||||||
assert resp.status_code == 200
|
assert [job["name"] for job in resp.json()["jobs"]] == ["Visible chat job"]
|
||||||
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%3Aother/automations",
|
||||||
|
headers=auth,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["jobs"] == []
|
||||||
finally:
|
finally:
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
@ -802,17 +807,17 @@ async def test_session_delete_can_cascade_bound_automations(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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
|
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
sm = _seed_session(tmp_path, key="websocket:doomed")
|
sm = _seed_session(tmp_path, key="websocket:doomed")
|
||||||
cron = CronService(tmp_path / "cron" / "jobs.json")
|
cron = CronService(tmp_path / "cron" / "jobs.json")
|
||||||
cron.add_job(
|
cron.add_job(
|
||||||
name="Shared daily check",
|
name="Chat daily check",
|
||||||
schedule=CronSchedule(kind="every", every_ms=86_400_000),
|
schedule=CronSchedule(kind="every", every_ms=86_400_000),
|
||||||
message="Check the shared session",
|
message="Check this chat",
|
||||||
session_key=UNIFIED_SESSION_KEY,
|
session_key="websocket:doomed",
|
||||||
)
|
)
|
||||||
channel = _ch(
|
channel = _ch(
|
||||||
bus,
|
bus,
|
||||||
@ -835,10 +840,13 @@ async def test_session_delete_does_not_cascade_unified_automations(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["deleted"] is True
|
body = resp.json()
|
||||||
assert not path.exists()
|
assert body["deleted"] is False
|
||||||
assert [job.name for job in cron.list_bound_cron_jobs_for_session(UNIFIED_SESSION_KEY)] == [
|
assert body["blocked_by_automations"] is True
|
||||||
"Shared daily check"
|
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:
|
finally:
|
||||||
await channel.stop()
|
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.factory import ProviderSnapshot, make_provider
|
||||||
from nanobot.providers.openai_codex_provider import _strip_model_prefix
|
from nanobot.providers.openai_codex_provider import _strip_model_prefix
|
||||||
from nanobot.providers.registry import find_by_name
|
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
|
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
@ -1548,38 +1547,10 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus)
|
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:
|
class _FakeSessionManager:
|
||||||
def __init__(self, _workspace: Path) -> None:
|
def __init__(self, _workspace: Path) -> None:
|
||||||
pass
|
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)
|
monkeypatch.setattr("nanobot.session.manager.SessionManager", _FakeSessionManager)
|
||||||
|
|
||||||
class _FakeCron:
|
class _FakeCron:
|
||||||
@ -1651,10 +1622,9 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
|||||||
assert msg.channel == "websocket"
|
assert msg.channel == "websocket"
|
||||||
assert msg.chat_id == "chat-1"
|
assert msg.chat_id == "chat-1"
|
||||||
assert msg.sender_id == "cron"
|
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 "Cron job: Check repository health." in msg.content
|
||||||
assert msg.metadata["webui"] is True
|
assert msg.metadata["webui"] is True
|
||||||
assert msg.metadata["workspace_scope"]["project_path"] == str(tmp_path)
|
|
||||||
assert msg.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {
|
assert msg.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {
|
||||||
"kind": "cron",
|
"kind": "cron",
|
||||||
"label": "Repo check",
|
"label": "Repo check",
|
||||||
@ -1675,7 +1645,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
|||||||
name="Thread check",
|
name="Thread check",
|
||||||
payload=CronPayload(
|
payload=CronPayload(
|
||||||
message="Check the Discord thread.",
|
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 isinstance(msg, InboundMessage)
|
||||||
assert msg.channel == "discord"
|
assert msg.channel == "discord"
|
||||||
assert msg.chat_id == "777"
|
assert msg.chat_id == "777"
|
||||||
assert msg.session_key_override == "discord:456:thread:777"
|
assert msg.session_key_override is None
|
||||||
assert msg.metadata["context_chat_id"] == "456"
|
|
||||||
assert msg.metadata["parent_channel_id"] == "456"
|
|
||||||
assert msg.metadata["thread_id"] == "777"
|
|
||||||
|
|
||||||
|
|
||||||
def test_gateway_cron_job_suppresses_intermediate_progress(
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_webui_cron_tool_uses_unified_session_when_enabled(tmp_path) -> None:
|
async def test_webui_cron_tool_uses_origin_session_when_unified_enabled(tmp_path) -> None:
|
||||||
"""WebUI-created automations should follow unified session ownership."""
|
"""WebUI-created cron jobs stay attached to the creating chat."""
|
||||||
tool = CronTool(CronService(tmp_path / "jobs.json"))
|
tool = CronTool(CronService(tmp_path / "jobs.json"))
|
||||||
|
|
||||||
class _Tools:
|
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()
|
jobs = tool._cron.list_jobs()
|
||||||
assert len(jobs) == 1
|
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
|
@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"))
|
tool = CronTool(CronService(tmp_path / "jobs.json"))
|
||||||
|
|
||||||
result = await tool.execute(action="add", message="test", every_seconds=60)
|
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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user