From a326ba40f4e4f00806c74e09d279d62ae221377e Mon Sep 17 00:00:00 2001 From: chengyongru Date: Thu, 11 Jun 2026 19:11:37 +0800 Subject: [PATCH 01/24] feat(cron): bind scheduled automations to sessions --- .agent/cron-session-memory.md | 246 +++++++++++++++++++ nanobot/agent/loop.py | 98 +++++++- nanobot/agent/tools/cron.py | 22 +- nanobot/cli/commands.py | 160 +++++++++++- nanobot/cron/automation.py | 33 +++ nanobot/cron/service.py | 32 +++ nanobot/session/manager.py | 1 + nanobot/session/routing.py | 105 ++++++++ nanobot/templates/agent/cron_reminder.md | 9 + nanobot/webui/session_automations.py | 37 +-- nanobot/webui/ws_http.py | 20 +- tests/agent/test_loop_save_turn.py | 7 + tests/agent/test_runner_injections.py | 48 ++++ tests/agent/test_session_manager_history.py | 7 + tests/channels/test_websocket_http_routes.py | 92 +++++++ tests/cli/test_commands.py | 173 ++++++++++++- tests/cron/test_cron_tool_list.py | 37 ++- tests/cron/test_cron_tool_schema_contract.py | 4 +- tests/session/test_routing.py | 53 ++++ tests/test_tool_contextvars.py | 20 +- webui/src/App.tsx | 30 ++- webui/src/components/DeleteConfirm.tsx | 39 ++- webui/src/hooks/useSessions.ts | 18 +- webui/src/lib/api.ts | 14 +- webui/src/lib/types.ts | 6 + webui/src/tests/api.test.ts | 11 + webui/src/tests/app-layout.test.tsx | 1 + webui/src/tests/useSessions.test.tsx | 36 ++- 28 files changed, 1277 insertions(+), 82 deletions(-) create mode 100644 .agent/cron-session-memory.md create mode 100644 nanobot/cron/automation.py create mode 100644 nanobot/session/routing.py create mode 100644 nanobot/templates/agent/cron_reminder.md create mode 100644 tests/session/test_routing.py diff --git a/.agent/cron-session-memory.md b/.agent/cron-session-memory.md new file mode 100644 index 000000000..d1b5be582 --- /dev/null +++ b/.agent/cron-session-memory.md @@ -0,0 +1,246 @@ +# Cron / Session / Memory Design Decisions + +This note records the agreed design direction for fixing the mismatch between +scheduled automations and chat session memory. + +## Problem + +User-created cron jobs currently run their agent turn under an internal key such +as `cron:{job.id}` and only deliver the final response back to the user channel. +That splits the turn's working memory from the session where the user sees and +continues the conversation. + +The visible failure mode is awkward: a cron job reports something into a chat, +the user discusses it in that chat, and the next cron run behaves as if that +discussion never happened. + +The fix is not to make cron a separate delivery system. A user automation should +be a scheduled input into a session. + +## Core Model + +For new user-created cron jobs, `payload.session_key` is the canonical anchor. + +- The cron job belongs to that session. +- The cron job reads that session's memory/history. +- The cron job produces a normal session turn. +- There is no separate delivery target concept for new jobs. + +Legacy fields remain in the store only for compatibility: + +- `payload.channel` +- `payload.to` +- `payload.channel_meta` +- `payload.deliver` + +These fields are legacy-only. New cron creation should not depend on them. + +## Job Categories + +Use explicit branching: + +- **Bound user automation**: `payload.kind == "agent_turn"` and + `payload.session_key` is present. This uses the new session-turn model. +- **Legacy unbound automation**: user job with no `payload.session_key`. Keep the + existing behavior. Do not migrate, infer, bind, or add UI for these jobs in + this change. +- **System job**: `payload.kind == "system_event"` or known internal jobs such + as `dream` / `heartbeat`. Keep their specialized paths. + +The project should not grow a compatibility subsystem for legacy jobs. Missing +`session_key` means old behavior. + +## New Job Creation + +`CronTool` must create user automations with a `session_key`. + +- If no request/session context exists, `cron action=add` should fail. +- Do not create new unbound jobs. +- Do not infer `session_key` from `channel/to` for new jobs. +- Remove `deliver` from the advertised tool schema. It can remain as a Python + compatibility argument, but it must not affect new bound jobs. +- New bound jobs should persist `message` and `session_key`; legacy delivery + fields should not be populated as part of the new path. + +## Execution Path + +Bound user automations should execute through `AgentLoop` as internal inbound +session events, not as an out-of-band `agent.process_direct()` call. + +The intended flow is: + +```text +cron due -> create automation inbound -> AgentLoop dispatches session turn +``` + +The inbound event should carry metadata identifying the automation, such as: + +- job id +- job name +- run id +- prompt reference +- persisted trigger content + +This keeps locking, runtime status, session persistence, and WebUI behavior on +the same path as normal chat turns. + +`session_key` is the ownership anchor, but an `InboundMessage` still needs an +execution context. Bound cron must resolve `channel`, `chat_id`, and any +channel metadata from the target session/session metadata. It must not fall back +to legacy `payload.channel`, `payload.to`, or `payload.channel_meta` for bound +jobs. Those fields are only for the legacy unbound path. + +The scheduler must not mark a bound job run as complete just because the inbound +event was queued. It should either wait for the automation turn to complete and +record the real outcome, or explicitly model the run as separate states such as +`queued` and `turn_completed`. A failed automation turn must be reflected in the +cron run record/job state, not hidden behind a successful enqueue. + +## Active Session Behavior + +Cron must not interrupt an active session turn. + +- If the target session is idle, run the automation turn immediately. +- If the target session is running, defer the automation until the current turn + completes. +- Do not inject the automation into the active turn's runtime context. +- Do not route automation messages into the existing mid-turn pending injection + queue. +- UI/runtime status may show that an automation is queued, but the current LLM + call should not see the queued automation. + +Automation inbound events need explicit metadata, for example +`_automation_trigger` plus `_defer_until_session_idle`. `AgentLoop.run()` must +recognize that metadata before the existing `_pending_queues` mid-turn injection +branch. If the session is active, the event goes to a deferred automation queue, +not the pending injection queue. + +The user experience goal is: cron can run after the current answer, but it +should not take over an answer already in progress. + +## Session History + +Do not persist the raw internal execution prompt as a normal user message. + +Instead, persist a readable automation trigger event, for example: + +```json +{ + "role": "user", + "content": "Scheduled automation triggered: daily monitor\n\nCheck ...", + "_automation_trigger": true, + "automation_id": "abc123", + "automation_name": "daily monitor", + "automation_run_id": "abc123:1770000000000", + "automation_prompt_ref": { + "id": "cron.agent_turn.reminder", + "version": 1, + "sha256": "..." + } +} +``` + +The assistant result should be saved as the normal assistant response for that +turn, with source metadata suitable for WebUI rendering. + +This gives future turns useful context without leaking internal instruction text +into the transcript. + +## Prompt Traceability + +The rendered execution prompt should remain traceable, but it should not be part +of normal session history. + +Use a named/versioned prompt reference in session history and save the full +rendered prompt in an internal run record. + +Preferred direction: + +- Move the cron execution prompt out of `commands.py` into a named template. +- Use a stable prompt id such as `cron.agent_turn.reminder`. +- Store `prompt_ref` and `automation_run_id` in session history. +- Store the full rendered prompt, prompt variables, and errors in an internal + run record. + +Avoid putting full prompt text into `jobs.json`; run records should not make the +cron store grow without bound. + +## Visibility and Evaluation + +A bound user automation is a real session turn. + +- If it succeeds, save and publish the assistant response. +- Do not pass bound automation responses through `evaluate_response()`. +- Keep `evaluate_response()` only for system/legacy paths where the old behavior + still applies. +- Avoid states where session history contains a response the user never saw. + +If a bound automation starts executing, it must leave a visible closure in the +session: + +- success response +- short failure message +- or an empty-result status message + +Full exceptions and diagnostic details belong in the internal run record, not in +the user-facing transcript. + +## Deleting Sessions + +Deleting a session with bound automations should be a two-step operation. + +Default delete behavior should block and return the associated automations: + +```json +{ + "deleted": false, + "blocked_by_automations": true, + "automations": [ + {"id": "abc123", "name": "daily monitor", "enabled": true} + ] +} +``` + +After explicit confirmation, the API may delete the bound user automations and +then delete the session/thread. + +Rules: + +- Only block on user-created bound jobs whose `payload.session_key` equals the + session being deleted. +- Do not block on system jobs. +- Do not block on legacy unbound jobs. +- If the user manually deletes files outside the WebUI/API, do not try to + compensate. + +## WebUI Scope + +This change should not grow into a full automation manager. + +Keep the scope focused: + +- Fix cron/session/memory semantics for new bound jobs. +- Preserve legacy job behavior. +- Add deletion protection for sessions with bound automations. +- Update the existing session automation panel only as needed for the new + bound-job status. + +Do not add deterministic legacy migration, legacy binding UI, or a global +calendar/task manager in this change. + +## Manual Run + +Do not add a user-visible "run now" feature as part of this design. + +`CronService.run_job()` may remain an internal/test helper. It should not become +a product surface, and the implementation should avoid creating a separate +execution path that behaves differently from scheduled runs. + +## Non-Goals + +- No legacy migration. +- No automatic binding of legacy jobs. +- No runtime-context prompt asking the model to bind jobs. +- No new global automation manager. +- No new delivery-target abstraction. +- No user-visible manual cron run. diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 3431237fa..4a9947e4a 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -39,6 +39,11 @@ from nanobot.bus.runtime_events import ( ) from nanobot.command import CommandContext, CommandRouter, register_builtin_commands from nanobot.config.schema import AgentDefaults, ModelPresetConfig +from nanobot.cron.automation import ( + automation_run_id, + automation_trigger, + defer_until_session_idle, +) from nanobot.providers.base import LLMProvider from nanobot.providers.factory import ProviderSnapshot from nanobot.security.workspace_access import ( @@ -53,6 +58,7 @@ from nanobot.session.goal_state import ( sustained_goal_active, ) 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.helpers import image_placeholder_text from nanobot.utils.helpers import truncate_text as truncate_text_fn @@ -300,6 +306,10 @@ class AgentLoop: # When a session has an active task, new messages for that session # are routed here instead of creating a new task. self._pending_queues: dict[str, asyncio.Queue] = {} + # Scheduled automations wait for the current visible turn to finish. + # They must not be injected into the active model call as follow-up text. + self._deferred_automation_queues: dict[str, list[InboundMessage]] = {} + self._automation_waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {} # NANOBOT_MAX_CONCURRENT_REQUESTS: <=0 means unlimited; default 3. _max = int(os.environ.get("NANOBOT_MAX_CONCURRENT_REQUESTS", "3")) self._concurrency_gate: asyncio.Semaphore | None = ( @@ -565,6 +575,55 @@ class AgentLoop: def _runtime_events(self) -> RuntimeEventPublisher: return ensure_runtime_event_publisher(self) + async def submit_automation_turn(self, msg: InboundMessage) -> OutboundMessage | None: + """Submit a scheduled automation as an internal session turn and wait for it.""" + run_id = automation_run_id(msg.metadata) + if not run_id: + raise ValueError("automation turn metadata must include a run_id") + loop = asyncio.get_running_loop() + future: asyncio.Future[OutboundMessage | None] = loop.create_future() + if run_id in self._automation_waiters: + raise RuntimeError(f"automation run {run_id!r} is already pending") + self._automation_waiters[run_id] = future + try: + if self._running: + await self.bus.publish_inbound(msg) + else: + await self._dispatch(msg) + return await future + finally: + self._automation_waiters.pop(run_id, None) + + def _complete_automation_turn( + self, + msg: InboundMessage, + *, + response: OutboundMessage | None = None, + error: BaseException | None = None, + ) -> None: + run_id = automation_run_id(msg.metadata) + if not run_id: + return + future = self._automation_waiters.get(run_id) + if future is None or future.done(): + return + if error is not None: + future.set_exception(error) + else: + future.set_result(response) + + def _defer_automation_turn(self, session_key: str, msg: InboundMessage) -> None: + self._deferred_automation_queues.setdefault(session_key, []).append(msg) + + async def _publish_next_deferred_automation(self, session_key: str) -> None: + queue = self._deferred_automation_queues.get(session_key) + if not queue: + return + msg = queue.pop(0) + if not queue: + self._deferred_automation_queues.pop(session_key, None) + await self.bus.publish_inbound(msg) + def _persist_user_message_early( self, msg: InboundMessage, @@ -583,6 +642,17 @@ class AgentLoop: extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata) extra.update(kwargs) text = msg.content if isinstance(msg.content, str) else "" + if trigger := automation_trigger(msg.metadata): + persist_content = trigger.get("persist_content") + if isinstance(persist_content, str) and persist_content.strip(): + text = persist_content + extra.update({ + "_automation_trigger": True, + "automation_id": trigger.get("job_id"), + "automation_name": trigger.get("job_name"), + "automation_run_id": trigger.get("run_id"), + "automation_prompt_ref": trigger.get("prompt_ref"), + }) session.add_message("user", text, **extra) self._mark_pending_user_turn(session) self.sessions.save(session) @@ -883,6 +953,22 @@ class AgentLoop: self.commands.dispatch_priority, ) continue + if ( + defer_until_session_idle(msg.metadata) + and effective_key in self._pending_queues + ): + pending_msg = msg + if effective_key != msg.session_key: + pending_msg = dataclasses.replace( + msg, + session_key_override=effective_key, + ) + self._defer_automation_turn(effective_key, pending_msg) + logger.info( + "Deferred automation turn for active session {}", + effective_key, + ) + continue # If this session already has an active pending queue (i.e. a task # is processing this session), route the message there for mid-turn # injection instead of creating a competing task. @@ -996,7 +1082,12 @@ class AgentLoop: session_key=session_key, metadata=msg.metadata, ) + self._complete_automation_turn(msg, response=response) except asyncio.CancelledError: + self._complete_automation_turn( + msg, + error=asyncio.CancelledError(), + ) logger.info("Task cancelled for session {}", session_key) # Preserve partial context from the interrupted turn so # the user does not lose tool results and assistant @@ -1022,7 +1113,7 @@ class AgentLoop: exc_info=True, ) raise - except Exception: + except Exception as exc: logger.exception("Error processing message for session {}", session_key) await self.bus.publish_outbound(OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, @@ -1035,6 +1126,7 @@ class AgentLoop: session_key=session_key, metadata=msg.metadata, ) + self._complete_automation_turn(msg, error=exc) finally: # Drain any messages still in the pending queue and re-publish # them to the bus so they are processed as fresh inbound messages @@ -1065,12 +1157,14 @@ class AgentLoop: msg, session_key, "idle" ) self._runtime_events().clear_turn(session_key) + await self._publish_next_deferred_automation(session_key) finally: if pending is None: await self._runtime_events().run_status_changed( msg, session_key, "idle" ) self._runtime_events().clear_turn(session_key) + await self._publish_next_deferred_automation(session_key) async def close_mcp(self) -> None: """Drain pending background archives, then close MCP connections.""" @@ -1342,6 +1436,8 @@ class AgentLoop: ctx.session = self.sessions.get_or_create(ctx.session_key) await self._runtime_events().session_turn_started(msg, ctx.session_key) 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): self.sessions.save(ctx.session) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index ff376a87b..7268b49c9 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -9,7 +9,6 @@ from typing import Any from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.schema import ( - BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema, @@ -38,10 +37,6 @@ _CRON_PARAMETERS = tool_parameters_schema( "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00'). " "Naive values use the tool's default timezone." ), - deliver=BooleanSchema( - description="Whether to deliver the execution result to the user channel (default true)", - default=True, - ), job_id=StringSchema("REQUIRED when action='remove'. Job ID to remove (obtain via action='list')."), required=["action"], description=( @@ -76,11 +71,11 @@ class CronTool(Tool, ContextAware): return cls(cron_service=ctx.cron_service, default_timezone=ctx.timezone) def set_context(self, ctx: RequestContext) -> None: - """Set the current session context for delivery.""" + """Set the current session context for scheduled automation ownership.""" self._channel.set(ctx.channel) self._chat_id.set(ctx.chat_id) self._metadata.set(ctx.metadata) - self._session_key.set(ctx.session_key or f"{ctx.channel}:{ctx.chat_id}") + self._session_key.set(ctx.session_key or "") def set_cron_context(self, active: bool): """Mark whether the tool is executing inside a cron job callback.""" @@ -170,10 +165,9 @@ class CronTool(Tool, ContextAware): "describing what to do when the job triggers " "(e.g. the reminder text). Retry including message=\"...\"." ) - channel = self._channel.get() - chat_id = self._chat_id.get() - if not channel or not chat_id: - return "Error: no session context (channel/chat_id)" + session_key = self._session_key.get() + if not session_key: + return "Error: scheduled automations must be created from a chat session" if tz and not cron_expr: return "Error: tz can only be used with cron_expr" if tz: @@ -210,12 +204,8 @@ class CronTool(Tool, ContextAware): name=name or message[:30], schedule=schedule, message=message, - deliver=deliver, - channel=channel, - to=chat_id, delete_after_run=delete_after, - channel_meta=self._metadata.get(), - session_key=self._session_key.get() or None, + session_key=session_key, ) return f"Created job '{job.name}' (id: {job.id})" diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 8f60fd9ed..3e71cb3ba 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1,10 +1,12 @@ """CLI commands for nanobot.""" import asyncio +import hashlib import os import select import signal import sys +import time import uuid from collections.abc import Callable from contextlib import nullcontext, suppress @@ -975,12 +977,19 @@ def _run_gateway( from nanobot.bus.queue import MessageBus from nanobot.bus.runtime_events import RuntimeEventBus from nanobot.channels.manager import ChannelManager + from nanobot.cron.automation import ( + AUTOMATION_DEFER_UNTIL_IDLE_META, + AUTOMATION_TRIGGER_META, + ) from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot 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.routing import read_routing_context from nanobot.session.webui_turns import WebuiTurnCoordinator + from nanobot.utils.prompt_templates import render_template from nanobot.webui.token_usage import TokenUsageHook port = port if port is not None else config.gateway.port @@ -1025,7 +1034,7 @@ def _run_gateway( ).subscribe(runtime_events) from nanobot.agent.loop import UNIFIED_SESSION_KEY - from nanobot.bus.events import OutboundMessage + from nanobot.bus.events import InboundMessage, OutboundMessage def _channel_session_key(channel: str, chat_id: str) -> str: return ( @@ -1034,6 +1043,152 @@ def _run_gateway( else f"{channel}:{chat_id}" ) + 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( + session_key: str, + *, + turn_seed: str, + source_label: str | None, + ) -> tuple[str, str, dict[str, Any]]: + if ":" not in session_key: + raise ValueError(f"bound cron session_key is invalid: {session_key!r}") + channel, rest = session_key.split(":", 1) + if not channel or not rest: + raise ValueError(f"bound cron session_key is invalid: {session_key!r}") + + session_metadata = _session_metadata(session_key) + routed = read_routing_context(session_metadata) + if routed is not None: + channel, rest, metadata = routed + else: + metadata: dict[str, Any] = {} + + if channel == "websocket": + metadata["webui"] = True + scope = session_metadata.get(WORKSPACE_SCOPE_METADATA_KEY) + if isinstance(scope, dict): + metadata[WORKSPACE_SCOPE_METADATA_KEY] = dict(scope) + metadata.update( + _proactive_delivery_metadata( + "websocket", + metadata, + turn_seed=turn_seed, + source_label=source_label, + ) + ) + return channel, rest, metadata + + if channel == "slack" and ":" in rest: + chat_id, thread_ts = rest.split(":", 1) + if thread_ts: + metadata["slack"] = {"thread_ts": thread_ts} + return channel, chat_id, metadata + + return channel, rest, metadata + + def _automation_prompt_ref(prompt: str) -> dict[str, Any]: + return { + "id": "cron.agent_turn.reminder", + "version": 1, + "sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(), + } + + async def _run_bound_cron_job(job: CronJob) -> str | None: + session_key = job.payload.session_key + if not session_key: + raise ValueError(f"cron job {job.id} is missing payload.session_key") + + prompt = render_template( + "agent/cron_reminder.md", + strip=True, + message=job.payload.message, + ) + prompt_ref = _automation_prompt_ref(prompt) + run_id = f"{job.id}:{int(time.time() * 1000)}:{uuid.uuid4().hex[:8]}" + channel, chat_id, metadata = _bound_session_delivery_context( + session_key, + turn_seed=f"cron:{job.id}", + source_label=job.name, + ) + metadata[AUTOMATION_TRIGGER_META] = { + "job_id": job.id, + "job_name": job.name, + "run_id": run_id, + "prompt_ref": prompt_ref, + "persist_content": ( + f"Scheduled automation triggered: {job.name}\n\n{job.payload.message}" + ), + } + metadata[AUTOMATION_DEFER_UNTIL_IDLE_META] = True + + cron.write_run_record( + run_id, + { + "job_id": job.id, + "job_name": job.name, + "session_key": session_key, + "status": "queued", + "prompt_ref": prompt_ref, + "prompt_vars": {"message": job.payload.message}, + "rendered_prompt": prompt, + }, + ) + + cron_tool = agent.tools.get("cron") + cron_token = None + if isinstance(cron_tool, CronTool): + cron_token = cron_tool.set_cron_context(True) + try: + resp = await agent.submit_automation_turn( + InboundMessage( + channel=channel, + sender_id="cron", + chat_id=chat_id, + content=prompt, + metadata=metadata, + session_key_override=session_key, + ) + ) + except (Exception, asyncio.CancelledError) as exc: + error_text = str(exc) or exc.__class__.__name__ + cron.write_run_record( + run_id, + { + "job_id": job.id, + "job_name": job.name, + "session_key": session_key, + "status": "error", + "error": error_text, + "prompt_ref": prompt_ref, + "prompt_vars": {"message": job.payload.message}, + "rendered_prompt": prompt, + }, + ) + raise + finally: + if isinstance(cron_tool, CronTool) and cron_token is not None: + cron_tool.reset_cron_context(cron_token) + + response = resp.content if resp else "" + cron.write_run_record( + run_id, + { + "job_id": job.id, + "job_name": job.name, + "session_key": session_key, + "status": "ok", + "prompt_ref": prompt_ref, + "prompt_vars": {"message": job.payload.message}, + "rendered_prompt": prompt, + "response": response, + }, + ) + return response + async def _deliver_to_channel( msg: OutboundMessage, *, record: bool = False, session_key: str | None = None, ) -> None: @@ -1194,6 +1349,9 @@ def _run_gateway( logger.info("Heartbeat: silenced by post-run evaluation") return response + if job.payload.kind == "agent_turn" and job.payload.session_key: + return await _run_bound_cron_job(job) + reminder_note = ( "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 — " diff --git a/nanobot/cron/automation.py b/nanobot/cron/automation.py new file mode 100644 index 000000000..7eacac9ce --- /dev/null +++ b/nanobot/cron/automation.py @@ -0,0 +1,33 @@ +"""Shared metadata helpers for scheduled automation turns.""" + +from __future__ import annotations + +from typing import Any, Mapping + +AUTOMATION_TRIGGER_META = "_automation_trigger" +AUTOMATION_DEFER_UNTIL_IDLE_META = "_defer_until_session_idle" + + +def automation_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None: + """Return structured automation trigger metadata when present.""" + raw = (metadata or {}).get(AUTOMATION_TRIGGER_META) + return raw if isinstance(raw, dict) else None + + +def is_automation_turn(metadata: Mapping[str, Any] | None) -> bool: + return automation_trigger(metadata) is not None + + +def defer_until_session_idle(metadata: Mapping[str, Any] | None) -> bool: + return bool( + is_automation_turn(metadata) + and (metadata or {}).get(AUTOMATION_DEFER_UNTIL_IDLE_META) is True + ) + + +def automation_run_id(metadata: Mapping[str, Any] | None) -> str | None: + trigger = automation_trigger(metadata) + if not trigger: + return None + value = trigger.get("run_id") + return value if isinstance(value, str) and value else None diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 31c5b50a7..a75d024dd 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -84,6 +84,7 @@ class CronService: ): self.store_path = store_path self._action_path = store_path.parent / "action.jsonl" + self._run_records_dir = store_path.parent / "runs" self._lock = FileLock(str(self._action_path.parent) + ".lock") self.on_job = on_job self._store: CronStore | None = None @@ -325,6 +326,23 @@ class CronService: tmp_path.unlink(missing_ok=True) raise + @staticmethod + def _safe_run_record_name(run_id: str) -> str: + return "".join(c if c.isalnum() or c in "._-" else "_" for c in run_id) + + def write_run_record(self, run_id: str, record: dict[str, Any]) -> None: + """Write an internal audit record for one cron execution.""" + name = self._safe_run_record_name(run_id) + if not name: + name = str(uuid.uuid4()) + path = self._run_records_dir / f"{name}.json" + payload = { + **record, + "run_id": run_id, + "updated_at_ms": _now_ms(), + } + self._atomic_write(path, json.dumps(payload, indent=2, ensure_ascii=False)) + async def start(self) -> None: """Start the cron service.""" self._running = True @@ -473,6 +491,20 @@ class CronService: jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled] return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float('inf')) + def list_bound_agent_jobs_for_session( + self, + session_key: str, + *, + include_disabled: bool = True, + ) -> list[CronJob]: + """Return user-created bound automation jobs owned by *session_key*.""" + return [ + job + for job in self.list_jobs(include_disabled=include_disabled) + if job.payload.kind == "agent_turn" + and job.payload.session_key == session_key + ] + def add_job( self, name: str, diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 890b25c20..9041aa27e 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -36,6 +36,7 @@ _FORK_VOLATILE_METADATA_KEYS = { "pending_user_turn", "runtime_checkpoint", "thread_goal", + "_routing_context", "title", "title_user_edited", } diff --git a/nanobot/session/routing.py b/nanobot/session/routing.py new file mode 100644 index 000000000..cad4578c0 --- /dev/null +++ b/nanobot/session/routing.py @@ -0,0 +1,105 @@ +"""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.automation import is_automation_turn +from nanobot.session.manager import Session + +SESSION_ROUTING_METADATA_KEY = "_routing_context" + +_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 automation 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-automation delivery context for a session.""" + if is_automation_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 diff --git a/nanobot/templates/agent/cron_reminder.md b/nanobot/templates/agent/cron_reminder.md new file mode 100644 index 000000000..af9803d5a --- /dev/null +++ b/nanobot/templates/agent/cron_reminder.md @@ -0,0 +1,9 @@ +The scheduled time has arrived. Execute this scheduled automation now and report the result to the user in the same session. + +Rules: +- Speak directly to the user in their language. +- Do not narrate internal progress. +- Do not include user IDs. +- Do not add status reports like "Done" or "Reminded" unless they are the natural response. + +Automation: {{ message }} diff --git a/nanobot/webui/session_automations.py b/nanobot/webui/session_automations.py index 52d503f54..8a57b9442 100644 --- a/nanobot/webui/session_automations.py +++ b/nanobot/webui/session_automations.py @@ -8,7 +8,25 @@ from nanobot.cron.types import CronJob class _CronServiceLike(Protocol): - def list_jobs(self, *, include_disabled: bool = False) -> list[CronJob]: ... + def list_bound_agent_jobs_for_session( + self, + session_key: str, + *, + include_disabled: bool = True, + ) -> list[CronJob]: ... + + +def bound_session_automation_jobs( + cron_service: _CronServiceLike | None, + session_key: str, +) -> list[CronJob]: + """Return agent-turn automation jobs explicitly bound to *session_key*.""" + if cron_service is None: + return [] + return cron_service.list_bound_agent_jobs_for_session( + session_key, + include_disabled=True, + ) def session_automations_payload( @@ -16,22 +34,11 @@ def session_automations_payload( session_key: str, ) -> dict[str, Any]: """Return user-created automation jobs attached to a WebUI session.""" - jobs: list[CronJob] = [] - if cron_service is not None: - all_jobs = cron_service.list_jobs(include_disabled=True) - jobs = [job for job in all_jobs if _job_matches_session(job, session_key)] - return {"jobs": [_serialize_job(job) for job in jobs]} + return {"jobs": serialize_automation_jobs(bound_session_automation_jobs(cron_service, session_key))} -def _job_matches_session(job: CronJob, session_key: str) -> bool: - payload = job.payload - if payload.kind != "agent_turn": - return False - if payload.session_key: - return payload.session_key == session_key - if payload.channel and payload.to: - return f"{payload.channel}:{payload.to}" == session_key - return False +def serialize_automation_jobs(jobs: list[CronJob]) -> list[dict[str, Any]]: + return [_serialize_job(job) for job in jobs] def _serialize_job(job: CronJob) -> dict[str, Any]: diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 101b309fe..e0e0d321f 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -61,7 +61,11 @@ from nanobot.webui.http_utils import ( safe_host_header as _safe_host_header, ) from nanobot.webui.media_gateway import WebUIMediaGateway -from nanobot.webui.session_automations import session_automations_payload +from nanobot.webui.session_automations import ( + bound_session_automation_jobs, + serialize_automation_jobs, + session_automations_payload, +) from nanobot.webui.session_list_index import list_webui_sessions from nanobot.webui.sidebar_state import ( read_webui_sidebar_state, @@ -446,6 +450,20 @@ class GatewayHTTPHandler: return _http_error(400, "invalid session key") if not _is_websocket_channel_session_key(decoded_key): return _http_error(404, "session not found") + query = _parse_query(request.path) + delete_automations = (_query_first(query, "delete_automations") or "").lower() + bound_jobs = bound_session_automation_jobs(self.cron_service, decoded_key) + if bound_jobs and delete_automations not in {"1", "true", "yes"}: + return _http_json_response( + { + "deleted": False, + "blocked_by_automations": True, + "automations": serialize_automation_jobs(bound_jobs), + } + ) + if bound_jobs and self.cron_service is not None: + for job in bound_jobs: + self.cron_service.remove_job(job.id) deleted = self.session_manager.delete_session(decoded_key) delete_webui_thread(decoded_key) return _http_json_response({"deleted": bool(deleted)}) diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index 295bc4888..1c1f9de64 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -11,6 +11,7 @@ from nanobot.bus.queue import MessageBus 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, @@ -827,6 +828,12 @@ 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 diff --git a/tests/agent/test_runner_injections.py b/tests/agent/test_runner_injections.py index 3686574c8..b5d970a11 100644 --- a/tests/agent/test_runner_injections.py +++ b/tests/agent/test_runner_injections.py @@ -616,6 +616,54 @@ async def test_followup_routed_to_pending_queue(tmp_path): assert queued_msg.session_key == UNIFIED_SESSION_KEY +@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.""" + from nanobot.bus.events import InboundMessage + from nanobot.cron.automation import ( + AUTOMATION_DEFER_UNTIL_IDLE_META, + AUTOMATION_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={ + AUTOMATION_TRIGGER_META: {"run_id": "run-1"}, + AUTOMATION_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): + 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._deferred_automation_queues[session_key] == [msg] + + await loop._publish_next_deferred_automation(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 + + @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.""" diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py index 3441c4833..91520ed86 100644 --- a/tests/agent/test_session_manager_history.py +++ b/tests/agent/test_session_manager_history.py @@ -1,4 +1,5 @@ from nanobot.session.manager import Session, SessionManager +from nanobot.session.routing import SESSION_ROUTING_METADATA_KEY def _assert_no_orphans(history: list[dict]) -> None: @@ -432,6 +433,11 @@ 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") @@ -450,6 +456,7 @@ 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"] diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 8eba67588..bf2dafe59 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -188,6 +188,13 @@ async def test_session_automations_route_filters_by_webui_session( to=to, session_key=f"websocket:{to}", ) + cron.add_job( + name="Legacy same target", + schedule=hourly, + message="Legacy job should not be treated as bound", + channel="websocket", + to="abc", + ) cron.register_system_job( CronJob( id="heartbeat", @@ -659,6 +666,91 @@ 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", + ) + 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_agent_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", + ) + 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_agent_jobs_for_session("websocket:doomed") == [] + assert [job.name for job in cron.list_jobs(include_disabled=True)] == [ + "Legacy same target" + ] + 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 diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 3e30de858..01be99252 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -8,13 +8,14 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from typer.testing import CliRunner -from nanobot.bus.events import OutboundMessage +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.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 runner = CliRunner() @@ -1352,7 +1353,6 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context( "webui_turn_id": old_turn_id, "workspace_scope": {"mode": "default"}, }, - session_key="websocket:chat-1", ), ) @@ -1373,6 +1373,175 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context( } +def test_gateway_bound_cron_runs_as_session_turn( + monkeypatch, tmp_path: Path +) -> None: + 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] = {"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: provider) + monkeypatch.setattr( + "nanobot.providers.factory.build_provider_snapshot", + lambda _config: _test_provider_snapshot(provider, _config), + ) + monkeypatch.setattr( + "nanobot.providers.factory.load_provider_snapshot", + lambda _config_path=None: _test_provider_snapshot(provider, config), + ) + 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: + 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 = kwargs.get("provider", object()) + self.tools = {} + seen["agent"] = self + + async def submit_automation_turn(self, msg: InboundMessage): + seen["automation_msg"] = msg + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="Checked the repo.", + ) + + async def close_mcp(self) -> None: + return None + + async def run(self) -> None: + return None + + def stop(self) -> None: + return None + + class _StopAfterCronSetup: + def __init__(self, *_args, **_kwargs) -> None: + raise _StopGatewayError("stop") + + 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", _unexpected_evaluator) + + result = runner.invoke(app, ["gateway", "--config", str(config_file)]) + assert isinstance(result.exception, _StopGatewayError) + + cron = seen["cron"] + job = CronJob( + id="repo-check", + name="Repo check", + payload=CronPayload( + message="Check repository health.", + session_key="websocket:chat-1", + ), + ) + + response = asyncio.run(cron.on_job(job)) + + assert response == "Checked the repo." + msg = seen["automation_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 msg.metadata["webui"] is True + assert msg.metadata["workspace_scope"]["project_path"] == str(tmp_path) + assert msg.metadata["_webui_message_source"] == {"kind": "cron", "label": "Repo check"} + trigger = msg.metadata["_automation_trigger"] + 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." + ) + assert msg.metadata["_defer_until_session_idle"] 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", + ), + ) + + response = asyncio.run(cron.on_job(discord_job)) + + assert response == "Checked the repo." + msg = seen["automation_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" + + def test_gateway_cron_job_suppresses_intermediate_progress( monkeypatch, tmp_path: Path ) -> None: diff --git a/tests/cron/test_cron_tool_list.py b/tests/cron/test_cron_tool_list.py index b67879715..23d426031 100644 --- a/tests/cron/test_cron_tool_list.py +++ b/tests/cron/test_cron_tool_list.py @@ -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,29 @@ 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.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) - assert result.startswith("Created job") - job = tool._cron.list_jobs()[0] - assert job.payload.deliver is False + assert result == "Error: scheduled automations 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 +382,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 +392,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_only_session_key(tmp_path) -> None: + """CronTool stores the canonical session key without legacy delivery fields.""" tool = _make_tool(tmp_path) meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}} tool.set_context(RequestContext( @@ -396,8 +405,10 @@ 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.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: diff --git a/tests/cron/test_cron_tool_schema_contract.py b/tests/cron/test_cron_tool_schema_contract.py index e26989d85..b0454b9f7 100644 --- a/tests/cron/test_cron_tool_schema_contract.py +++ b/tests/cron/test_cron_tool_schema_contract.py @@ -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 diff --git a/tests/session/test_routing.py b/tests/session/test_routing.py new file mode 100644 index 000000000..fdd882f90 --- /dev/null +++ b/tests/session/test_routing.py @@ -0,0 +1,53 @@ +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", + }, + } diff --git a/tests/test_tool_contextvars.py b/tests/test_tool_contextvars.py index e2b7f66ab..a72296d67 100644 --- a/tests/test_tool_contextvars.py +++ b/tests/test_tool_contextvars.py @@ -99,14 +99,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 +120,7 @@ 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"} # --- Basic single-task regression tests --- @@ -228,15 +231,16 @@ 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" @pytest.mark.asyncio @@ -245,4 +249,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 automations must be created from a chat session" diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 70c6ef6cf..55f94fb7c 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -36,6 +36,7 @@ import { ClientProvider, useClient } from "@/providers/ClientProvider"; import type { ChatSummary, RuntimeSurface, + SessionAutomationJob, SettingsPayload, WorkspaceScopePayload, WorkspacesPayload, @@ -546,6 +547,8 @@ function Shell({ const [pendingDelete, setPendingDelete] = useState<{ key: string; label: string; + automations?: SessionAutomationJob[]; + confirmAutomations?: boolean; } | null>(null); const [pendingRename, setPendingRename] = useState<{ key: string; @@ -1275,24 +1278,28 @@ function Shell({ const fallbackKey = deletingActive ? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null) : activeKey; - setPendingDelete(null); - if (deletingActive) { - navigate({ - view: "chat", - activeKey: fallbackKey, - settingsSection: "overview", - }, { replace: true }); - } try { - await deleteChat(key); - } catch (e) { + const result = await deleteChat( + key, + pendingDelete.confirmAutomations ? { deleteAutomations: true } : undefined, + ); + if (result.blocked_by_automations) { + setPendingDelete({ + ...pendingDelete, + automations: result.automations ?? [], + confirmAutomations: true, + }); + return; + } + setPendingDelete(null); if (deletingActive) { navigate({ view: "chat", - activeKey: key, + activeKey: fallbackKey, settingsSection: "overview", }, { replace: true }); } + } catch (e) { console.error("Failed to delete session", e); } }, [pendingDelete, deleteChat, activeKey, navigate, sessions]); @@ -1559,6 +1566,7 @@ function Shell({ setPendingDelete(null)} onConfirm={onConfirmDelete} /> diff --git a/webui/src/components/DeleteConfirm.tsx b/webui/src/components/DeleteConfirm.tsx index fdad9e3ac..2a58a7dc5 100644 --- a/webui/src/components/DeleteConfirm.tsx +++ b/webui/src/components/DeleteConfirm.tsx @@ -10,10 +10,12 @@ import { } from "@/components/ui/alert-dialog"; import { Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; +import type { SessionAutomationJob } from "@/lib/types"; interface DeleteConfirmProps { open: boolean; title: string; + automations?: SessionAutomationJob[]; onCancel: () => void; onConfirm: () => void; } @@ -21,14 +23,18 @@ interface DeleteConfirmProps { export function DeleteConfirm({ open, title, + automations = [], onCancel, onConfirm, }: DeleteConfirmProps) { const { t } = useTranslation(); + const hasAutomations = automations.length > 0; + const visibleAutomations = automations.slice(0, 4); + const hiddenCount = Math.max(0, automations.length - visibleAutomations.length); return ( (!o ? onCancel() : undefined)}>
@@ -40,8 +46,31 @@ export function DeleteConfirm({ {t("deleteConfirm.title", { title })} - {t("deleteConfirm.description")} + {hasAutomations + ? t("deleteConfirm.automationsDescription", { + count: automations.length, + defaultValue: + "This chat has scheduled automations. Deleting it will also delete them.", + }) + : t("deleteConfirm.description")} + {hasAutomations ? ( +
+ {visibleAutomations.map((job) => ( +
+ {job.name || job.id} +
+ ))} + {hiddenCount > 0 ? ( +
+ {t("deleteConfirm.moreAutomations", { + count: hiddenCount, + defaultValue: "+ {{count}} more", + })} +
+ ) : null} +
+ ) : null} - {t("deleteConfirm.confirm")} + {hasAutomations + ? t("deleteConfirm.confirmWithAutomations", { + defaultValue: "Delete all", + }) + : t("deleteConfirm.confirm")} diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index ab2aed727..428b37312 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -9,7 +9,12 @@ import { listSessions, } from "@/lib/api"; import { deriveTitle } from "@/lib/format"; -import type { ChatSummary, UIMessage, WorkspaceScopePayload } from "@/lib/types"; +import type { + ChatSummary, + SessionDeleteResult, + UIMessage, + WorkspaceScopePayload, +} from "@/lib/types"; const EMPTY_MESSAGES: UIMessage[] = []; const INITIAL_HISTORY_PAGE_LIMIT = 160; @@ -31,7 +36,10 @@ export function useSessions(): { refresh: () => Promise; createChat: (workspaceScope?: WorkspaceScopePayload | null) => Promise; forkChat: (sourceChatId: string, beforeUserIndex: number, title?: string) => Promise; - deleteChat: (key: string) => Promise; + deleteChat: ( + key: string, + options?: { deleteAutomations?: boolean }, + ) => Promise; } { const { client, token } = useClient(); const [sessions, setSessions] = useState([]); @@ -124,10 +132,12 @@ export function useSessions(): { }, [client]); const deleteChat = useCallback( - async (key: string) => { - await apiDeleteSession(tokenRef.current, key); + async (key: string, options?: { deleteAutomations?: boolean }) => { + const result = await apiDeleteSession(tokenRef.current, key, options); + if (!result.deleted) return result; optimisticKeysRef.current.delete(key); setSessions((prev) => prev.filter((s) => s.key !== key)); + return result; }, [], ); diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 9b8aa2551..5a6fee9cd 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -9,6 +9,7 @@ import type { NetworkSafetySettingsUpdate, ProviderModelsPayload, ProviderSettingsUpdate, + SessionDeleteResult, SessionAutomationsPayload, SettingsPayload, SettingsUpdate, @@ -211,13 +212,18 @@ export async function fetchSkillDetail( export async function deleteSession( token: string, key: string, + optionsOrBase?: { deleteAutomations?: boolean } | string, base: string = "", -): Promise { - const body = await request<{ deleted: boolean }>( - `${base}/api/sessions/${encodeURIComponent(key)}/delete`, +): Promise { + const options = typeof optionsOrBase === "string" ? undefined : optionsOrBase; + const resolvedBase = typeof optionsOrBase === "string" ? optionsOrBase : base; + const query = new URLSearchParams(); + if (options?.deleteAutomations) query.set("delete_automations", "true"); + const suffix = query.toString() ? `?${query}` : ""; + return request( + `${resolvedBase}/api/sessions/${encodeURIComponent(key)}/delete${suffix}`, token, ); - return body.deleted; } export async function fetchSettings( diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 3365b83f4..f02a2e650 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -118,6 +118,12 @@ export interface SessionAutomationJob { export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; } +export interface SessionDeleteResult { + deleted: boolean; + blocked_by_automations?: boolean; + automations?: SessionAutomationJob[]; +} + export interface SkillSummary { name: string; description: string; diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index f4c5972f2..71a5b8648 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -131,6 +131,17 @@ describe("webui API helpers", () => { ); }); + it("passes the automation cascade flag when deleting a session", async () => { + await deleteSession("tok", "websocket:chat-1", { deleteAutomations: true }); + + expect(fetch).toHaveBeenCalledWith( + "/api/sessions/websocket%3Achat-1/delete?delete_automations=true", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); + it("serializes settings updates as a narrow query string", async () => { await updateSettings("tok", { modelPreset: "default", diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 3fa3e8124..8735fa00b 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -149,6 +149,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => { deleteChat: async (key: string) => { await deleteChatSpy(key); setSessions((prev: ChatSummary[]) => prev.filter((s) => s.key !== key)); + return { deleted: true }; }, }; }, diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx index a606b249a..826a862a1 100644 --- a/webui/src/tests/useSessions.test.tsx +++ b/webui/src/tests/useSessions.test.tsx @@ -103,7 +103,7 @@ describe("useSessions", () => { preview: "Beta", }, ]); - vi.mocked(api.deleteSession).mockResolvedValue(true); + vi.mocked(api.deleteSession).mockResolvedValue({ deleted: true }); const { result } = renderHook(() => useSessions(), { wrapper: wrap(fakeClient()), @@ -115,10 +115,42 @@ describe("useSessions", () => { await result.current.deleteChat("websocket:chat-a"); }); - expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a"); + expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a", undefined); expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]); }); + it("keeps a session when delete is blocked by bound automations", async () => { + vi.mocked(api.listSessions).mockResolvedValue([ + { + key: "websocket:chat-a", + channel: "websocket", + chatId: "chat-a", + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:00:00Z", + preview: "Alpha", + }, + ]); + vi.mocked(api.deleteSession).mockResolvedValue({ + deleted: false, + blocked_by_automations: true, + automations: [], + }); + + const { result } = renderHook(() => useSessions(), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.sessions).toHaveLength(1)); + + let deleteResult: Awaited> | undefined; + await act(async () => { + deleteResult = await result.current.deleteChat("websocket:chat-a"); + }); + + expect(deleteResult?.blocked_by_automations).toBe(true); + expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-a"]); + }); + it("refreshes sessions when the websocket reports a session update", async () => { vi.mocked(api.listSessions) .mockResolvedValueOnce([ From f82ab9f192fa632959c8b2428b1e3663c5a60241 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Thu, 11 Jun 2026 22:01:23 +0800 Subject: [PATCH 02/24] fix: record cancelled cron runs maintainer edit: treat job-level CancelledError as a failed cron run so bound automation cancellations update run history and do not break subsequent scheduling. --- nanobot/cron/service.py | 7 +++++++ tests/cron/test_cron_service.py | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index a75d024dd..ff8882a86 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -448,6 +448,13 @@ class CronService: job.state.last_error = None logger.info("Cron: job '{}' completed", job.name) + except asyncio.CancelledError as e: + current = asyncio.current_task() + if current is not None and current.cancelling(): + raise + job.state.last_status = "error" + job.state.last_error = str(e) or e.__class__.__name__ + logger.exception("Cron: job '{}' was cancelled", job.name) except Exception as e: job.state.last_status = "error" job.state.last_error = str(e) diff --git a/tests/cron/test_cron_service.py b/tests/cron/test_cron_service.py index fa304e06e..195ba9c97 100644 --- a/tests/cron/test_cron_service.py +++ b/tests/cron/test_cron_service.py @@ -137,6 +137,33 @@ 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_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" From b4b6c046572afec57058e3162be53faa359e5ea2 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Thu, 11 Jun 2026 22:26:06 +0800 Subject: [PATCH 03/24] fix: preserve legacy cron delivery payloads maintainer edit: keep existing cron jobs with legacy delivery fields on the legacy execution path, even when they already carry a sessionKey. This preserves deliver=false behavior and channel-specific routing metadata for upgraded jobs. --- nanobot/cli/commands.py | 13 +++- tests/cli/test_commands.py | 145 +++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 3e71cb3ba..a248556b1 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1189,6 +1189,17 @@ def _run_gateway( ) return response + def _is_bound_cron_job(job: CronJob) -> bool: + payload = job.payload + if payload.kind != "agent_turn" or not payload.session_key: + return False + return not ( + payload.deliver + or payload.channel + or payload.to + or payload.channel_meta + ) + async def _deliver_to_channel( msg: OutboundMessage, *, record: bool = False, session_key: str | None = None, ) -> None: @@ -1349,7 +1360,7 @@ def _run_gateway( logger.info("Heartbeat: silenced by post-run evaluation") return response - if job.payload.kind == "agent_turn" and job.payload.session_key: + if _is_bound_cron_job(job): return await _run_bound_cron_job(job) reminder_note = ( diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 01be99252..86901dab3 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -1373,6 +1373,151 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context( } +def test_gateway_legacy_cron_payloads_with_session_key_stay_legacy( + monkeypatch, tmp_path: Path +) -> None: + config_file = _write_instance_config(tmp_path) + config = Config() + config.agents.defaults.workspace = str(tmp_path / "config-workspace") + bus = MagicMock() + bus.publish_outbound = AsyncMock() + seen: dict[str, object] = {"process_calls": [], "evaluations": [], "saved_keys": []} + + class _FakeSession: + def __init__(self) -> None: + self.messages = [] + + def add_message(self, role: str, content: str, **kwargs) -> None: + self.messages.append({"role": role, "content": content, **kwargs}) + + class _FakeSessionManager: + def __init__(self, _workspace: Path) -> None: + self.session = _FakeSession() + seen["session_manager"] = self + + def read_session_file(self, _key: str) -> dict[str, object]: + return {"metadata": {}} + + def get_or_create(self, key: str) -> _FakeSession: + seen["saved_keys"].append(key) + return self.session + + def save(self, session: _FakeSession) -> None: + seen["saved_session"] = session + + class _FakeCron: + def __init__(self, _store_path: Path) -> None: + self.on_job = None + seen["cron"] = self + + 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 = kwargs.get("provider", object()) + self.tools = {} + + async def process_direct(self, prompt: str, **kwargs): + seen["process_calls"].append((prompt, kwargs)) + return OutboundMessage( + channel=kwargs["channel"], + chat_id=kwargs["chat_id"], + content="Legacy response.", + ) + + async def submit_automation_turn(self, _msg: InboundMessage): + raise AssertionError("legacy cron payload must not run as bound automation") + + async def close_mcp(self) -> None: + return None + + async def run(self) -> None: + return None + + def stop(self) -> None: + return None + + class _StopAfterCronSetup: + def __init__(self, *_args, **_kwargs) -> None: + raise _StopGatewayError("stop") + + async def _capture_evaluate_response(*args, **_kwargs) -> bool: + seen["evaluations"].append(args) + return True + + _patch_cli_command_runtime( + monkeypatch, + config, + message_bus=lambda: bus, + session_manager=_FakeSessionManager, + cron_service=_FakeCron, + ) + monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop) + monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup) + monkeypatch.setattr( + "nanobot.cli.commands.evaluate_response", + _capture_evaluate_response, + ) + + result = runner.invoke(app, ["gateway", "--config", str(config_file)]) + assert isinstance(result.exception, _StopGatewayError) + cron = seen["cron"] + + silent_job = CronJob( + id="silent-legacy", + name="Silent legacy", + payload=CronPayload( + message="Run silently.", + deliver=False, + channel="telegram", + to="user-1", + session_key="telegram:user-1", + ), + ) + + response = asyncio.run(cron.on_job(silent_job)) + + assert response == "Legacy response." + prompt, kwargs = seen["process_calls"][-1] + assert "Reminder: Run silently." in prompt + assert kwargs["session_key"] == "cron:silent-legacy" + assert kwargs["channel"] == "telegram" + assert kwargs["chat_id"] == "user-1" + assert seen["evaluations"] == [] + bus.publish_outbound.assert_not_awaited() + + topic_job = CronJob( + id="topic-legacy", + name="Topic legacy", + payload=CronPayload( + message="Ping the topic.", + deliver=True, + channel="telegram", + to="-100123", + channel_meta={"message_thread_id": 42}, + session_key="telegram:-100123:topic:42", + ), + ) + + response = asyncio.run(cron.on_job(topic_job)) + + assert response == "Legacy response." + _prompt, kwargs = seen["process_calls"][-1] + assert kwargs["session_key"] == "cron:topic-legacy" + assert kwargs["channel"] == "telegram" + assert kwargs["chat_id"] == "-100123" + assert len(seen["evaluations"]) == 1 + bus.publish_outbound.assert_awaited_once() + delivered = bus.publish_outbound.await_args.args[0] + assert delivered.channel == "telegram" + assert delivered.chat_id == "-100123" + assert delivered.metadata["message_thread_id"] == 42 + assert seen["saved_keys"] == ["telegram:-100123:topic:42"] + + def test_gateway_bound_cron_runs_as_session_turn( monkeypatch, tmp_path: Path ) -> None: From 3725b42e0e6d3e4675c6b34d7c064b2fb607d4ef Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Thu, 11 Jun 2026 23:09:21 +0800 Subject: [PATCH 04/24] fix: use shared bound cron predicate maintainer edit: make gateway execution, WebUI automation listing, and delete protection agree on the new bound cron shape. Legacy delivery payloads that carry sessionKey are excluded from the WebUI-bound automation surface. --- nanobot/cli/commands.py | 14 ++----------- nanobot/cron/automation.py | 15 +++++++++++++ nanobot/cron/service.py | 3 ++- tests/channels/test_websocket_http_routes.py | 4 ++-- tests/cron/test_cron_service.py | 22 ++++++++++++++++++++ 5 files changed, 43 insertions(+), 15 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index a248556b1..3fb327c51 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -980,6 +980,7 @@ def _run_gateway( from nanobot.cron.automation import ( AUTOMATION_DEFER_UNTIL_IDLE_META, AUTOMATION_TRIGGER_META, + is_bound_agent_job, ) from nanobot.cron.service import CronService from nanobot.cron.types import CronJob @@ -1189,17 +1190,6 @@ def _run_gateway( ) return response - def _is_bound_cron_job(job: CronJob) -> bool: - payload = job.payload - if payload.kind != "agent_turn" or not payload.session_key: - return False - return not ( - payload.deliver - or payload.channel - or payload.to - or payload.channel_meta - ) - async def _deliver_to_channel( msg: OutboundMessage, *, record: bool = False, session_key: str | None = None, ) -> None: @@ -1360,7 +1350,7 @@ def _run_gateway( logger.info("Heartbeat: silenced by post-run evaluation") return response - if _is_bound_cron_job(job): + if is_bound_agent_job(job): return await _run_bound_cron_job(job) reminder_note = ( diff --git a/nanobot/cron/automation.py b/nanobot/cron/automation.py index 7eacac9ce..298680a2f 100644 --- a/nanobot/cron/automation.py +++ b/nanobot/cron/automation.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any, Mapping +from nanobot.cron.types import CronJob + AUTOMATION_TRIGGER_META = "_automation_trigger" AUTOMATION_DEFER_UNTIL_IDLE_META = "_defer_until_session_idle" @@ -31,3 +33,16 @@ def automation_run_id(metadata: Mapping[str, Any] | None) -> str | None: return None value = trigger.get("run_id") return value if isinstance(value, str) and value else None + + +def is_bound_agent_job(job: CronJob) -> bool: + """True for new session-bound user automations, excluding legacy delivery payloads.""" + payload = job.payload + if payload.kind != "agent_turn" or not payload.session_key: + return False + return not ( + payload.deliver + or payload.channel + or payload.to + or payload.channel_meta + ) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index ff8882a86..30ee7aea9 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -14,6 +14,7 @@ from typing import Any, Callable, Coroutine, Literal from filelock import FileLock from loguru import logger +from nanobot.cron.automation import is_bound_agent_job from nanobot.cron.types import ( CronJob, CronJobState, @@ -508,7 +509,7 @@ class CronService: return [ job for job in self.list_jobs(include_disabled=include_disabled) - if job.payload.kind == "agent_turn" + if is_bound_agent_job(job) and job.payload.session_key == session_key ] diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index bf2dafe59..bc11c2e15 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -184,16 +184,16 @@ async def test_session_automations_route_filters_by_webui_session( name=name, schedule=hourly, message=message, - channel="websocket", - to=to, session_key=f"websocket:{to}", ) cron.add_job( name="Legacy same target", schedule=hourly, message="Legacy job should not be treated as bound", + deliver=True, channel="websocket", to="abc", + session_key="websocket:abc", ) cron.register_system_job( CronJob( diff --git a/tests/cron/test_cron_service.py b/tests/cron/test_cron_service.py index 195ba9c97..f258fdd22 100644 --- a/tests/cron/test_cron_service.py +++ b/tests/cron/test_cron_service.py @@ -65,6 +65,28 @@ def test_add_job_preserves_channel_meta_and_session_key(tmp_path) -> None: assert reloaded.payload.session_key == "slack:C123:1234567890.123456" +def test_list_bound_agent_jobs_excludes_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", + ) + 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_agent_jobs_for_session("websocket:chat-1") == [bound] + + @pytest.mark.asyncio async def test_channel_meta_and_session_key_survive_store_reload(tmp_path) -> None: store_path = tmp_path / "cron" / "jobs.json" From 8dac6b288900ae8cb9f9a3ae358837f71b7ea915 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Thu, 11 Jun 2026 23:53:45 +0800 Subject: [PATCH 05/24] fix: show websocket cron jobs in automations --- nanobot/webui/session_automations.py | 38 +++++++++++++------- nanobot/webui/ws_http.py | 12 +++---- tests/channels/test_websocket_http_routes.py | 12 ++++--- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/nanobot/webui/session_automations.py b/nanobot/webui/session_automations.py index 8a57b9442..c60f05735 100644 --- a/nanobot/webui/session_automations.py +++ b/nanobot/webui/session_automations.py @@ -4,29 +4,26 @@ from __future__ import annotations from typing import Any, Protocol +from nanobot.cron.automation import is_bound_agent_job from nanobot.cron.types import CronJob class _CronServiceLike(Protocol): - def list_bound_agent_jobs_for_session( - self, - session_key: str, - *, - include_disabled: bool = True, - ) -> list[CronJob]: ... + def list_jobs(self, *, include_disabled: bool = False) -> list[CronJob]: ... -def bound_session_automation_jobs( +def session_automation_jobs( cron_service: _CronServiceLike | None, session_key: str, ) -> list[CronJob]: - """Return agent-turn automation jobs explicitly bound to *session_key*.""" + """Return user automations attached to the WebUI session.""" if cron_service is None: return [] - return cron_service.list_bound_agent_jobs_for_session( - session_key, - include_disabled=True, - ) + return [ + job + for job in cron_service.list_jobs(include_disabled=True) + if _matches_webui_session(job, session_key) + ] def session_automations_payload( @@ -34,7 +31,22 @@ def session_automations_payload( session_key: str, ) -> dict[str, Any]: """Return user-created automation jobs attached to a WebUI session.""" - return {"jobs": serialize_automation_jobs(bound_session_automation_jobs(cron_service, session_key))} + return { + "jobs": serialize_automation_jobs(session_automation_jobs(cron_service, session_key)) + } + + +def _matches_webui_session(job: CronJob, session_key: str) -> bool: + payload = job.payload + if payload.kind != "agent_turn": + return False + if is_bound_agent_job(job): + return payload.session_key == session_key + return bool( + payload.channel == "websocket" + and payload.to + and session_key == f"websocket:{payload.to}" + ) def serialize_automation_jobs(jobs: list[CronJob]) -> list[dict[str, Any]]: diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index e0e0d321f..70e19e01b 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -62,8 +62,8 @@ from nanobot.webui.http_utils import ( ) from nanobot.webui.media_gateway import WebUIMediaGateway from nanobot.webui.session_automations import ( - bound_session_automation_jobs, serialize_automation_jobs, + session_automation_jobs, session_automations_payload, ) from nanobot.webui.session_list_index import list_webui_sessions @@ -452,17 +452,17 @@ class GatewayHTTPHandler: return _http_error(404, "session not found") query = _parse_query(request.path) delete_automations = (_query_first(query, "delete_automations") or "").lower() - bound_jobs = bound_session_automation_jobs(self.cron_service, decoded_key) - if bound_jobs and delete_automations not in {"1", "true", "yes"}: + automation_jobs = session_automation_jobs(self.cron_service, decoded_key) + if automation_jobs and delete_automations not in {"1", "true", "yes"}: return _http_json_response( { "deleted": False, "blocked_by_automations": True, - "automations": serialize_automation_jobs(bound_jobs), + "automations": serialize_automation_jobs(automation_jobs), } ) - if bound_jobs and self.cron_service is not None: - for job in bound_jobs: + if automation_jobs and self.cron_service is not None: + for job in automation_jobs: self.cron_service.remove_job(job.id) deleted = self.session_manager.delete_session(decoded_key) delete_webui_thread(decoded_key) diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index bc11c2e15..c3614c7e2 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -189,7 +189,7 @@ async def test_session_automations_route_filters_by_webui_session( cron.add_job( name="Legacy same target", schedule=hourly, - message="Legacy job should not be treated as bound", + message="Legacy job should still show in WebUI", deliver=True, channel="websocket", to="abc", @@ -227,11 +227,15 @@ 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 body["jobs"][1]["payload"]["message"] == "Legacy job should still show in WebUI" finally: await channel.stop() await server_task @@ -743,9 +747,7 @@ async def test_session_delete_can_cascade_bound_automations( assert resp.json()["deleted"] is True assert not path.exists() assert cron.list_bound_agent_jobs_for_session("websocket:doomed") == [] - assert [job.name for job in cron.list_jobs(include_disabled=True)] == [ - "Legacy same target" - ] + assert cron.list_jobs(include_disabled=True) == [] finally: await channel.stop() await server_task From 369237f6a8661e1958f5f8f5a0f09259480f1526 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Thu, 11 Jun 2026 23:53:51 +0800 Subject: [PATCH 06/24] fix: allow slower webui chat creation --- webui/src/hooks/useSessions.ts | 10 ++++++++-- webui/src/tests/useSessions.test.tsx | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index 428b37312..7618b7e17 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -19,6 +19,7 @@ import type { const EMPTY_MESSAGES: UIMessage[] = []; const INITIAL_HISTORY_PAGE_LIMIT = 160; const OLDER_HISTORY_PAGE_LIMIT = 120; +const CHAT_CREATE_TIMEOUT_MS = 60_000; function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] { return messages.map((m, idx) => ({ @@ -86,7 +87,7 @@ export function useSessions(): { }, [client, refresh]); const createChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null): Promise => { - const chatId = await client.newChat(5_000, workspaceScope); + const chatId = await client.newChat(CHAT_CREATE_TIMEOUT_MS, workspaceScope); const key = `websocket:${chatId}`; optimisticKeysRef.current.add(key); // Optimistic insert; a subsequent refresh will replace it with the @@ -112,7 +113,12 @@ export function useSessions(): { beforeUserIndex: number, title?: string, ): Promise => { - const chatId = await client.forkChat(sourceChatId, beforeUserIndex, title); + const chatId = await client.forkChat( + sourceChatId, + beforeUserIndex, + title, + CHAT_CREATE_TIMEOUT_MS, + ); const key = `websocket:${chatId}`; optimisticKeysRef.current.add(key); setSessions((prev) => [ diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx index 826a862a1..323f67ee7 100644 --- a/webui/src/tests/useSessions.test.tsx +++ b/webui/src/tests/useSessions.test.tsx @@ -219,7 +219,7 @@ describe("useSessions", () => { await result.current.createChat(); }); - expect(client.newChat).toHaveBeenCalledWith(5000, undefined); + expect(client.newChat).toHaveBeenCalledWith(60_000, undefined); expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-new"]); await act(async () => { @@ -258,7 +258,7 @@ describe("useSessions", () => { await result.current.createChat(workspaceScope); }); - expect(client.newChat).toHaveBeenCalledWith(5000, workspaceScope); + expect(client.newChat).toHaveBeenCalledWith(60_000, workspaceScope); expect(result.current.sessions[0]?.workspaceScope).toEqual(workspaceScope); }); From 29f1473940d70748e59134cb54ee154c0ab61f17 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Fri, 12 Jun 2026 00:15:57 +0800 Subject: [PATCH 07/24] fix: keep session automations bound-only --- nanobot/webui/session_automations.py | 30 +++++++------------- tests/channels/test_websocket_http_routes.py | 12 ++++---- 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/nanobot/webui/session_automations.py b/nanobot/webui/session_automations.py index c60f05735..19af69a64 100644 --- a/nanobot/webui/session_automations.py +++ b/nanobot/webui/session_automations.py @@ -4,12 +4,16 @@ from __future__ import annotations from typing import Any, Protocol -from nanobot.cron.automation import is_bound_agent_job from nanobot.cron.types import CronJob class _CronServiceLike(Protocol): - def list_jobs(self, *, include_disabled: bool = False) -> list[CronJob]: ... + def list_bound_agent_jobs_for_session( + self, + session_key: str, + *, + include_disabled: bool = True, + ) -> list[CronJob]: ... def session_automation_jobs( @@ -19,11 +23,10 @@ def session_automation_jobs( """Return user automations attached to the WebUI session.""" if cron_service is None: return [] - return [ - job - for job in cron_service.list_jobs(include_disabled=True) - if _matches_webui_session(job, session_key) - ] + return cron_service.list_bound_agent_jobs_for_session( + session_key, + include_disabled=True, + ) def session_automations_payload( @@ -36,19 +39,6 @@ def session_automations_payload( } -def _matches_webui_session(job: CronJob, session_key: str) -> bool: - payload = job.payload - if payload.kind != "agent_turn": - return False - if is_bound_agent_job(job): - return payload.session_key == session_key - return bool( - payload.channel == "websocket" - and payload.to - and session_key == f"websocket:{payload.to}" - ) - - def serialize_automation_jobs(jobs: list[CronJob]) -> list[dict[str, Any]]: return [_serialize_job(job) for job in jobs] diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index c3614c7e2..bc11c2e15 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -189,7 +189,7 @@ async def test_session_automations_route_filters_by_webui_session( cron.add_job( name="Legacy same target", schedule=hourly, - message="Legacy job should still show in WebUI", + message="Legacy job should not be treated as bound", deliver=True, channel="websocket", to="abc", @@ -227,15 +227,11 @@ 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", - "Legacy same target", - ] + assert [job["name"] for job in body["jobs"]] == ["Morning check"] 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 body["jobs"][1]["payload"]["message"] == "Legacy job should still show in WebUI" finally: await channel.stop() await server_task @@ -747,7 +743,9 @@ async def test_session_delete_can_cascade_bound_automations( assert resp.json()["deleted"] is True assert not path.exists() assert cron.list_bound_agent_jobs_for_session("websocket:doomed") == [] - assert cron.list_jobs(include_disabled=True) == [] + assert [job.name for job in cron.list_jobs(include_disabled=True)] == [ + "Legacy same target" + ] finally: await channel.stop() await server_task From e46a99ced9379c1bc3223aa2283dd9b3289bf620 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Fri, 12 Jun 2026 00:28:12 +0800 Subject: [PATCH 08/24] fix: bind webui cron jobs to visible session --- nanobot/agent/loop.py | 24 ++++++++++++++++++++++++ tests/test_tool_contextvars.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 4a9947e4a..e137a61c0 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -528,6 +528,12 @@ class AgentLoop: effective_key = UNIFIED_SESSION_KEY else: effective_key = f"{channel}:{chat_id}" + effective_key = self._tool_context_session_key( + channel=channel, + chat_id=chat_id, + metadata=metadata, + session_key=effective_key, + ) request_ctx = RequestContext( channel=channel, @@ -542,6 +548,24 @@ class AgentLoop: if tool and isinstance(tool, ContextAware): tool.set_context(request_ctx) + def _tool_context_session_key( + self, + *, + channel: str, + chat_id: str, + metadata: dict | None, + session_key: str, + ) -> str: + """Return the session key tools should use for ownership-scoped resources.""" + if ( + self._unified_session + and channel == "websocket" + and (metadata or {}).get("webui") is True + and chat_id + ): + return f"websocket:{chat_id}" + return session_key + @staticmethod def _runtime_chat_id(msg: InboundMessage) -> str: """Return the chat id shown in runtime metadata for the model.""" diff --git a/tests/test_tool_contextvars.py b/tests/test_tool_contextvars.py index a72296d67..3826ba37f 100644 --- a/tests/test_tool_contextvars.py +++ b/tests/test_tool_contextvars.py @@ -4,6 +4,7 @@ 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 @@ -243,6 +244,35 @@ async def test_cron_tool_basic_set_context_and_execute(tmp_path) -> None: assert jobs[0].payload.session_key == "wechat:user-789" +@pytest.mark.asyncio +async def test_webui_cron_tool_uses_visible_session_under_unified_session(tmp_path) -> None: + """WebUI-created automations should attach to the visible thread, not unified memory.""" + 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:default", + ) + + 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" + + @pytest.mark.asyncio async def test_cron_tool_no_context_returns_error(tmp_path) -> None: """Without set_context, add should fail with a clear error.""" From 1ad9d77bc7dc1f1c85a657a85741773138077c1c Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Fri, 12 Jun 2026 00:54:32 +0800 Subject: [PATCH 09/24] fix: avoid completed cron tail pending state --- nanobot/webui/transcript.py | 24 +++++++ tests/utils/test_webui_transcript.py | 85 +++++++++++++++++++++++ webui/src/hooks/useNanobotStream.ts | 13 ++-- webui/src/hooks/useSessions.ts | 17 +++-- webui/src/lib/activity-timeline.ts | 32 +++++++++ webui/src/lib/types.ts | 1 + webui/src/tests/useNanobotStream.test.tsx | 30 ++++++++ webui/src/tests/useSessions.test.tsx | 34 +++++++++ 8 files changed, 223 insertions(+), 13 deletions(-) diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index ee2734283..0d19a7119 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -1823,6 +1823,29 @@ def fork_boundary_message_count(lines: list[dict[str, Any]]) -> int | None: return None +def has_pending_tool_calls(lines: list[dict[str, Any]]) -> bool: + """Return True when the selected transcript tail looks like an unfinished turn.""" + for rec in reversed(lines): + ev = rec.get("event") + if ev == "turn_end": + return False + if ev == "user": + return False + if ev == "message": + return rec.get("kind") in {"tool_hint", "progress", "reasoning"} + if ev in { + "delta", + "stream_end", + "reasoning_delta", + "reasoning_end", + "file_edit", + }: + return True + if ev in {WEBUI_FORK_MARKER_EVENT}: + continue + return False + + def build_webui_thread_response( session_key: str, *, @@ -1855,6 +1878,7 @@ def build_webui_thread_response( "schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION, "sessionKey": session_key, "messages": msgs, + "has_pending_tool_calls": has_pending_tool_calls(lines), } if page is not None: page["loaded_message_count"] = len(msgs) diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index 0675b659a..4f0173400 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -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" diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index e53377bab..bac44ad00 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -8,6 +8,7 @@ import { normalizeToolProgressEvents, toolTraceLinesFromEvents, } from "@/lib/tool-traces"; +import { hasPendingAgentActivity } from "@/lib/activity-timeline"; import type { StreamError } from "@/lib/nanobot-client"; import type { InboundEvent, @@ -450,12 +451,8 @@ export function useNanobotStream( } { const { client } = useClient(); const [messages, setMessages] = useState(initialMessages); - /** If the last loaded message is a trace row (e.g. "Using 2 tools"), - * the model was still processing when the page loaded — keep the - * loading spinner alive so the user sees the model is active. */ - const initialStreaming = initialMessages.length > 0 - ? initialMessages[initialMessages.length - 1].kind === "trace" - : false; + /** If history ends in unfinished agent activity, keep the loading spinner alive. */ + const initialStreaming = hasPendingAgentActivity(initialMessages); const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls); /** Unix epoch seconds when the current user turn started; cleared on ``idle``. */ const [runStartedAt, setRunStartedAt] = useState(null); @@ -694,9 +691,7 @@ export function useNanobotStream( useEffect(() => { setMessages(initialMessages); setIsStreaming( - (initialMessages.length > 0 - ? initialMessages[initialMessages.length - 1].kind === "trace" - : false) || hasPendingToolCalls, + hasPendingAgentActivity(initialMessages) || hasPendingToolCalls, ); setStreamError(null); setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null); diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index 7618b7e17..43adaadd6 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -8,6 +8,7 @@ import { fetchWebuiThread, listSessions, } from "@/lib/api"; +import { hasPendingAgentActivity } from "@/lib/activity-timeline"; import { deriveTitle } from "@/lib/format"; import type { ChatSummary, @@ -29,6 +30,16 @@ function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] { })); } +function hasPendingToolCallsFromThread( + body: Awaited>, + messages: UIMessage[], +): boolean { + if (typeof body?.has_pending_tool_calls === "boolean") { + return body.has_pending_tool_calls; + } + return hasPendingAgentActivity(messages); +} + /** Sidebar state: fetches the full session list and exposes create / delete actions. */ export function useSessions(): { sessions: ChatSummary[]; @@ -257,8 +268,7 @@ export function useSessionHistory(key: string | null): { return; } const ui = persistedMessagesToUi(body.messages); - const last = ui[ui.length - 1]; - const hasPending = last?.kind === "trace"; + const hasPending = hasPendingToolCallsFromThread(body, ui); const forkBoundary = typeof body.fork_boundary_message_count === "number" ? Math.max(0, Math.min(body.fork_boundary_message_count, ui.length)) : null; @@ -342,13 +352,12 @@ export function useSessionHistory(key: string | null): { ? null : prev.forkBoundaryMessageCount + older.length; const nextMessages = [...older, ...prev.messages]; - const last = nextMessages[nextMessages.length - 1]; return { ...prev, messages: nextMessages, loadingOlder: false, error: null, - hasPendingToolCalls: last?.kind === "trace", + hasPendingToolCalls: hasPendingAgentActivity(nextMessages), forkBoundaryMessageCount: olderBoundary ?? shiftedBoundary, beforeCursor: body.page?.before_cursor ?? null, hasMoreBefore: body.page?.has_more_before === true, diff --git a/webui/src/lib/activity-timeline.ts b/webui/src/lib/activity-timeline.ts index 9f2b049b6..f1f912cef 100644 --- a/webui/src/lib/activity-timeline.ts +++ b/webui/src/lib/activity-timeline.ts @@ -52,6 +52,38 @@ export function isAgentActivityMember(message: UIMessage): boolean { return isReasoningOnlyAssistant(message) || message.kind === "trace"; } +export function hasPendingAgentActivity(messages: UIMessage[]): boolean { + if (messages.length === 0) return false; + const last = messages[messages.length - 1]; + if (!isAgentActivityMember(last)) return false; + + let trailingStart = messages.length - 1; + while ( + trailingStart > 0 + && isAgentActivityMember(messages[trailingStart - 1]) + ) { + trailingStart -= 1; + } + + const trailing = messages.slice(trailingStart); + if (trailing.some((message) => message.isStreaming || message.reasoningStreaming)) { + return true; + } + + const previous = messages[trailingStart - 1]; + if (!previous || previous.role !== "assistant" || isAgentActivityMember(previous)) { + return true; + } + + const trailingTurnIds = new Set( + trailing + .map((message) => message.turnId) + .filter((turnId): turnId is string => typeof turnId === "string" && turnId.length > 0), + ); + if (!previous.turnId) return trailingTurnIds.size > 0; + return trailingTurnIds.size > 0 && !trailingTurnIds.has(previous.turnId); +} + export function normalizeActivityTimeline( messages: UIMessage[], options: NormalizeActivityTimelineOptions = {}, diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index f02a2e650..df51f0887 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -881,6 +881,7 @@ export interface WebuiThreadPersistedPayload { savedAt?: string; messages: UIMessage[]; fork_boundary_message_count?: number; + has_pending_tool_calls?: boolean; page?: WebuiThreadPagePayload; workspace_scope?: WorkspaceScopePayload; } diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index dcec94df5..b983fc193 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -180,6 +180,36 @@ describe("useNanobotStream", () => { }); }); + it("does not start streaming from completed trailing activity after an answer", () => { + const fake = fakeClient(); + const initialMessages = [ + { + id: "a1", + role: "assistant" as const, + content: "Cron test", + turnId: "cron:run", + createdAt: Date.now(), + }, + { + id: "t1", + role: "tool" as const, + kind: "trace" as const, + content: "message({})", + traces: ["message({})"], + turnId: "cron:run", + createdAt: Date.now(), + }, + ]; + + const { result } = renderHook( + () => useNanobotStream("chat-cron-done", initialMessages), + { wrapper: wrap(fake.client) }, + ); + + expect(result.current.messages.at(-1)?.kind).toBe("trace"); + expect(result.current.isStreaming).toBe(false); + }); + it("drops pending stream work when switching chats", async () => { const fake = fakeClient(); const { result, rerender } = renderHook( diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx index 323f67ee7..148bf4628 100644 --- a/webui/src/tests/useSessions.test.tsx +++ b/webui/src/tests/useSessions.test.tsx @@ -416,6 +416,40 @@ describe("useSessions", () => { expect(result.current.hasPendingToolCalls).toBe(true); }); + it("uses the server pending flag for completed tails that still end with trace rows", async () => { + vi.mocked(api.fetchWebuiThread).mockResolvedValue({ + schemaVersion: 3, + has_pending_tool_calls: false, + messages: [ + { + id: "a1", + role: "assistant", + content: "Cron test", + turnId: "cron:run", + createdAt: 1, + }, + { + id: "t1", + role: "tool", + kind: "trace", + content: "message({})", + traces: ["message({})"], + turnId: "cron:run", + createdAt: 2, + }, + ], + }); + + const { result } = renderHook(() => useSessionHistory("websocket:chat-cron-done"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.messages.at(-1)?.kind).toBe("trace"); + expect(result.current.hasPendingToolCalls).toBe(false); + }); + it("does not flag transcript as pending when last row is not a trace", async () => { vi.mocked(api.fetchWebuiThread).mockResolvedValue({ schemaVersion: 3, From 0ff8cd0cb3828f611d5402ecd90614d6eb26902d Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 12 Jun 2026 10:19:12 +0800 Subject: [PATCH 10/24] fix: honor unified session for webui automations --- nanobot/agent/loop.py | 25 ------ nanobot/channels/manager.py | 1 + nanobot/webui/gateway_services.py | 2 + nanobot/webui/ws_http.py | 11 ++- tests/channels/test_websocket_http_routes.py | 94 ++++++++++++++++++++ tests/test_tool_contextvars.py | 6 +- 6 files changed, 110 insertions(+), 29 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e137a61c0..151621748 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -528,13 +528,6 @@ class AgentLoop: effective_key = UNIFIED_SESSION_KEY else: effective_key = f"{channel}:{chat_id}" - effective_key = self._tool_context_session_key( - channel=channel, - chat_id=chat_id, - metadata=metadata, - session_key=effective_key, - ) - request_ctx = RequestContext( channel=channel, chat_id=chat_id, @@ -548,24 +541,6 @@ class AgentLoop: if tool and isinstance(tool, ContextAware): tool.set_context(request_ctx) - def _tool_context_session_key( - self, - *, - channel: str, - chat_id: str, - metadata: dict | None, - session_key: str, - ) -> str: - """Return the session key tools should use for ownership-scoped resources.""" - if ( - self._unified_session - and channel == "websocket" - and (metadata or {}).get("webui") is True - and chat_id - ): - return f"websocket:{chat_id}" - return session_key - @staticmethod def _runtime_chat_id(msg: InboundMessage) -> str: """Return the chat id shown in runtime metadata for the model.""" diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index b59925232..cc5c62b1a 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -125,6 +125,7 @@ class ChannelManager: runtime_model_name=self._webui_runtime_model_name, runtime_surface=self._webui_runtime_surface, runtime_capabilities_overrides=self._webui_runtime_capabilities, + unified_session=self.config.agents.defaults.unified_session, cron_service=self._cron_service, logger=logger, ) diff --git a/nanobot/webui/gateway_services.py b/nanobot/webui/gateway_services.py index 15649d08d..53d3f0db1 100644 --- a/nanobot/webui/gateway_services.py +++ b/nanobot/webui/gateway_services.py @@ -39,6 +39,7 @@ def build_gateway_services( runtime_model_name: Any | None, runtime_surface: str, runtime_capabilities_overrides: dict[str, Any] | None, + unified_session: bool = False, disabled_skills: set[str] | None = None, cron_service: Any | None = None, logger: Any = default_logger, @@ -61,6 +62,7 @@ def build_gateway_services( runtime_model_name=runtime_model_name, runtime_surface=runtime_surface, runtime_capabilities_overrides=runtime_capabilities_overrides, + unified_session=unified_session, bus=bus, tokens=tokens, media=media, diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 70e19e01b..37397aa70 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -20,6 +20,7 @@ from loguru import logger from websockets.http11 import Request as WsRequest from websockets.http11 import Response +from nanobot.agent.loop import UNIFIED_SESSION_KEY from nanobot.command.builtin import builtin_command_palette from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload @@ -139,6 +140,7 @@ class GatewayHTTPHandler: runtime_model_name: Callable[[], str | None] | None, runtime_surface: str, runtime_capabilities_overrides: dict[str, Any] | None, + unified_session: bool = False, bus: MessageBus, tokens: GatewayTokenStore, media: WebUIMediaGateway, @@ -161,6 +163,7 @@ class GatewayHTTPHandler: self.cron_service = cron_service self._log = log self._runtime_surface = runtime_surface + self._unified_session = unified_session from nanobot.webui.settings_api import runtime_capabilities as _rc from nanobot.webui.settings_routes import WebUISettingsRouter @@ -437,7 +440,7 @@ class GatewayHTTPHandler: if not _is_websocket_channel_session_key(decoded_key): return _http_error(404, "session not found") return _http_json_response( - session_automations_payload(self.cron_service, decoded_key) + session_automations_payload(self.cron_service, self._automation_display_key(decoded_key)) ) def _handle_session_delete(self, request: WsRequest, key: str) -> Response: @@ -468,6 +471,12 @@ class GatewayHTTPHandler: delete_webui_thread(decoded_key) return _http_json_response({"deleted": bool(deleted)}) + def _automation_display_key(self, session_key: str) -> str: + """Return the cron ownership key shown for this WebUI thread.""" + if self._unified_session: + return UNIFIED_SESSION_KEY + return session_key + # -- Media routes ------------------------------------------------------- def _dispatch_media_routes(self, request: WsRequest, got: str) -> Response | None: diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index bc11c2e15..a62d79d96 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -11,6 +11,7 @@ from urllib.parse import urlencode import httpx import pytest +from nanobot.agent.loop import UNIFIED_SESSION_KEY from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig from nanobot.cron.service import CronService from nanobot.cron.types import CronJob, CronPayload, CronSchedule @@ -29,6 +30,7 @@ def _make_handler( workspace_path: Path | None = None, runtime_model_name: Any | None = None, cron_service: CronService | None = None, + unified_session: bool = False, ) -> GatewayServices: config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg workspace = workspace_path or Path.cwd() @@ -42,6 +44,7 @@ def _make_handler( runtime_model_name=runtime_model_name, runtime_surface="browser", runtime_capabilities_overrides=None, + unified_session=unified_session, cron_service=cron_service, ) @@ -55,6 +58,7 @@ def _ch( port: int = _PORT, runtime_model_name: Any | None = None, cron_service: CronService | None = None, + unified_session: bool = False, **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, + unified_session=unified_session, ) return WebSocketChannel(cfg, bus, gateway=gateway) @@ -237,6 +242,51 @@ async def test_session_automations_route_filters_by_webui_session( await server_task +@pytest.mark.asyncio +async def test_session_automations_route_uses_unified_owner_when_enabled( + 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, + ) + cron.add_job( + name="Visible thread only", + schedule=hourly, + message="Do not show in unified mode", + session_key="websocket:abc", + ) + channel = _ch( + bus, + session_manager=_seed_session(tmp_path, key="websocket:abc"), + cron_service=cron, + unified_session=True, + 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}"} + + 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"] + finally: + await channel.stop() + await server_task + + @pytest.mark.asyncio async def test_webui_skills_route_requires_token_and_hides_paths( bus: MagicMock, tmp_path: Path @@ -751,6 +801,50 @@ async def test_session_delete_can_cascade_bound_automations( await server_task +@pytest.mark.asyncio +async def test_session_delete_does_not_cascade_unified_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="Shared daily check", + schedule=CronSchedule(kind="every", every_ms=86_400_000), + message="Check the shared session", + session_key=UNIFIED_SESSION_KEY, + ) + channel = _ch( + bus, + session_manager=sm, + cron_service=cron, + unified_session=True, + 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 + 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)] == [ + "Shared 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 diff --git a/tests/test_tool_contextvars.py b/tests/test_tool_contextvars.py index 3826ba37f..ff02b7f56 100644 --- a/tests/test_tool_contextvars.py +++ b/tests/test_tool_contextvars.py @@ -245,8 +245,8 @@ async def test_cron_tool_basic_set_context_and_execute(tmp_path) -> None: @pytest.mark.asyncio -async def test_webui_cron_tool_uses_visible_session_under_unified_session(tmp_path) -> None: - """WebUI-created automations should attach to the visible thread, not unified memory.""" +async def test_webui_cron_tool_uses_unified_session_when_enabled(tmp_path) -> None: + """WebUI-created automations should follow unified session ownership.""" tool = CronTool(CronService(tmp_path / "jobs.json")) class _Tools: @@ -270,7 +270,7 @@ async def test_webui_cron_tool_uses_visible_session_under_unified_session(tmp_pa jobs = tool._cron.list_jobs() assert len(jobs) == 1 - assert jobs[0].payload.session_key == "websocket:chat-123" + assert jobs[0].payload.session_key == "unified:default" @pytest.mark.asyncio From 0e3a57b3719872fe5c04b5bd7894760fe4a157f1 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 12 Jun 2026 11:12:26 +0800 Subject: [PATCH 11/24] docs: clarify cron session ownership --- .agent/cron-session-memory.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/.agent/cron-session-memory.md b/.agent/cron-session-memory.md index d1b5be582..82254b3c6 100644 --- a/.agent/cron-session-memory.md +++ b/.agent/cron-session-memory.md @@ -39,8 +39,10 @@ These fields are legacy-only. New cron creation should not depend on them. Use explicit branching: -- **Bound user automation**: `payload.kind == "agent_turn"` and - `payload.session_key` is present. This uses the new session-turn model. +- **Bound user automation**: `payload.kind == "agent_turn"`, + `payload.session_key` is present, and no legacy delivery fields + (`deliver`, `channel`, `to`, or `channel_meta`) are set. This uses the new + session-turn model. - **Legacy unbound automation**: user job with no `payload.session_key`. Keep the existing behavior. Do not migrate, infer, bind, or add UI for these jobs in this change. @@ -210,9 +212,24 @@ Rules: session being deleted. - Do not block on system jobs. - Do not block on legacy unbound jobs. +- In unified-session mode, WebUI chats display automations owned by + `unified:default`, but deleting an individual `websocket:*` thread should not + block on or delete those unified automations. - If the user manually deletes files outside the WebUI/API, do not try to compensate. +## Unified Session Mode + +When `unified_session` is enabled, WebUI-created automations should bind to the +same unified session as normal WebUI chat turns: `unified:default`. + +- All WebUI chats should display automations owned by `unified:default`. +- Individual WebUI thread deletion should remain scoped to the concrete + `websocket:*` thread being deleted. +- Toggling `unified_session` does not migrate existing cron jobs. Existing jobs + keep their stored `payload.session_key` and continue to execute against that + owner until explicitly removed or recreated. + ## WebUI Scope This change should not grow into a full automation manager. From d9d481bc15099ec601f45c0c40b3fb0f5d83f5b2 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 12 Jun 2026 11:43:23 +0800 Subject: [PATCH 12/24] refactor: centralize cron session metadata keys --- .agent/cron-session-memory.md | 2 +- nanobot/agent/loop.py | 17 +++---- nanobot/agent/tools/cron.py | 3 +- nanobot/cli/commands.py | 51 +++++++++----------- nanobot/cron/automation.py | 1 + nanobot/session/keys.py | 12 +++++ nanobot/session/manager.py | 3 +- nanobot/session/metadata.py | 3 ++ nanobot/session/routing.py | 3 +- nanobot/webui/metadata.py | 4 ++ nanobot/webui/transcript.py | 3 +- nanobot/webui/ws_http.py | 2 +- tests/agent/test_loop_save_turn.py | 36 ++++++++++++++ tests/agent/test_runner_injections.py | 2 +- tests/channels/test_websocket_http_routes.py | 2 +- tests/cli/test_commands.py | 27 ++++++----- tests/cron/test_cron_tool_list.py | 2 +- tests/test_tool_contextvars.py | 5 +- 18 files changed, 116 insertions(+), 62 deletions(-) create mode 100644 nanobot/session/keys.py create mode 100644 nanobot/session/metadata.py create mode 100644 nanobot/webui/metadata.py diff --git a/.agent/cron-session-memory.md b/.agent/cron-session-memory.md index 82254b3c6..ecc2ee75a 100644 --- a/.agent/cron-session-memory.md +++ b/.agent/cron-session-memory.md @@ -130,7 +130,7 @@ Instead, persist a readable automation trigger event, for example: { "role": "user", "content": "Scheduled automation triggered: daily monitor\n\nCheck ...", - "_automation_trigger": true, + "_automation_turn": true, "automation_id": "abc123", "automation_name": "daily monitor", "automation_run_id": "abc123:1770000000000", diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 151621748..2c1cf2375 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -40,6 +40,7 @@ from nanobot.bus.runtime_events import ( from nanobot.command import CommandContext, CommandRouter, register_builtin_commands from nanobot.config.schema import AgentDefaults, ModelPresetConfig from nanobot.cron.automation import ( + AUTOMATION_HISTORY_META, automation_run_id, automation_trigger, defer_until_session_idle, @@ -57,6 +58,7 @@ from nanobot.session.goal_state import ( runner_wall_llm_timeout_s, sustained_goal_active, ) +from nanobot.session.keys import UNIFIED_SESSION_KEY, session_key_for_channel 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 @@ -78,8 +80,6 @@ if TYPE_CHECKING: from nanobot.cron.service import CronService -UNIFIED_SESSION_KEY = "unified:default" - class TurnState(Enum): RESTORE = auto() COMPACT = auto() @@ -522,12 +522,11 @@ class AgentLoop: """Update context for all tools that need routing info.""" from nanobot.agent.tools.context import ContextAware - if session_key is not None: - effective_key = session_key - elif self._unified_session: - effective_key = UNIFIED_SESSION_KEY - else: - effective_key = f"{channel}:{chat_id}" + effective_key = session_key or session_key_for_channel( + channel, + chat_id, + unified_session=self._unified_session, + ) request_ctx = RequestContext( channel=channel, chat_id=chat_id, @@ -646,7 +645,7 @@ class AgentLoop: if isinstance(persist_content, str) and persist_content.strip(): text = persist_content extra.update({ - "_automation_trigger": True, + AUTOMATION_HISTORY_META: True, "automation_id": trigger.get("job_id"), "automation_name": trigger.get("job_name"), "automation_run_id": trigger.get("run_id"), diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 7268b49c9..b1d3b41e2 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -142,7 +142,7 @@ class CronTool(Tool, ContextAware): if action == "add": if self._in_cron_context.get(): return "Error: cannot schedule new jobs from within a cron job execution" - return self._add_job(name, message, every_seconds, cron_expr, tz, at, deliver) + return self._add_job(name, message, every_seconds, cron_expr, tz, at) elif action == "list": return self._list_jobs() elif action == "remove": @@ -157,7 +157,6 @@ class CronTool(Tool, ContextAware): cron_expr: str | None, tz: str | None, at: str | None, - deliver: bool = True, ) -> str: if not message: return ( diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 3fb327c51..0f48635e2 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -64,6 +64,10 @@ from nanobot.utils.restart import ( # noqa: E402 format_restart_completed_message, should_show_cli_restart_notice, ) +from nanobot.webui.metadata import ( # noqa: E402 + WEBUI_MESSAGE_SOURCE_METADATA_KEY, + WEBUI_TURN_METADATA_KEY, +) def _sanitize_surrogates(text: str) -> str: @@ -89,8 +93,6 @@ class SafeFileHistory(FileHistory): super().store_string(_sanitize_surrogates(string)) -_WEBUI_TURN_META_KEY = "webui_turn_id" -_WEBUI_MESSAGE_SOURCE_META_KEY = "_webui_message_source" _PROACTIVE_WEBUI_METADATA: ContextVar[dict[str, Any] | None] = ContextVar( "proactive_webui_metadata", default=None, @@ -106,13 +108,13 @@ def _proactive_delivery_metadata( ) -> dict[str, Any]: """Return channel metadata for a fresh proactive delivery turn.""" out = dict(metadata or {}) - out.pop(_WEBUI_TURN_META_KEY, None) + out.pop(WEBUI_TURN_METADATA_KEY, None) if channel == "websocket": - out[_WEBUI_TURN_META_KEY] = f"{turn_seed}:{uuid.uuid4().hex}" + out[WEBUI_TURN_METADATA_KEY] = f"{turn_seed}:{uuid.uuid4().hex}" source: dict[str, str] = {"kind": "cron"} if source_label: source["label"] = source_label - out[_WEBUI_MESSAGE_SOURCE_META_KEY] = source + out[WEBUI_MESSAGE_SOURCE_METADATA_KEY] = source return out app = typer.Typer( @@ -1034,14 +1036,14 @@ def _run_gateway( schedule_background=lambda coro: agent._schedule_background(coro), ).subscribe(runtime_events) - from nanobot.agent.loop import UNIFIED_SESSION_KEY from nanobot.bus.events import InboundMessage, OutboundMessage + from nanobot.session.keys import session_key_for_channel def _channel_session_key(channel: str, chat_id: str) -> str: - return ( - UNIFIED_SESSION_KEY - if config.agents.defaults.unified_session - else f"{channel}:{chat_id}" + return session_key_for_channel( + channel, + chat_id, + unified_session=config.agents.defaults.unified_session, ) def _session_metadata(session_key: str) -> dict[str, Any]: @@ -1125,17 +1127,20 @@ def _run_gateway( ), } metadata[AUTOMATION_DEFER_UNTIL_IDLE_META] = True + run_record_base: dict[str, Any] = { + "job_id": job.id, + "job_name": job.name, + "session_key": session_key, + "prompt_ref": prompt_ref, + "prompt_vars": {"message": job.payload.message}, + "rendered_prompt": prompt, + } cron.write_run_record( run_id, { - "job_id": job.id, - "job_name": job.name, - "session_key": session_key, + **run_record_base, "status": "queued", - "prompt_ref": prompt_ref, - "prompt_vars": {"message": job.payload.message}, - "rendered_prompt": prompt, }, ) @@ -1159,14 +1164,9 @@ def _run_gateway( cron.write_run_record( run_id, { - "job_id": job.id, - "job_name": job.name, - "session_key": session_key, + **run_record_base, "status": "error", "error": error_text, - "prompt_ref": prompt_ref, - "prompt_vars": {"message": job.payload.message}, - "rendered_prompt": prompt, }, ) raise @@ -1178,13 +1178,8 @@ def _run_gateway( cron.write_run_record( run_id, { - "job_id": job.id, - "job_name": job.name, - "session_key": session_key, + **run_record_base, "status": "ok", - "prompt_ref": prompt_ref, - "prompt_vars": {"message": job.payload.message}, - "rendered_prompt": prompt, "response": response, }, ) diff --git a/nanobot/cron/automation.py b/nanobot/cron/automation.py index 298680a2f..f619870ff 100644 --- a/nanobot/cron/automation.py +++ b/nanobot/cron/automation.py @@ -8,6 +8,7 @@ from nanobot.cron.types import CronJob AUTOMATION_TRIGGER_META = "_automation_trigger" AUTOMATION_DEFER_UNTIL_IDLE_META = "_defer_until_session_idle" +AUTOMATION_HISTORY_META = "_automation_turn" def automation_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None: diff --git a/nanobot/session/keys.py b/nanobot/session/keys.py new file mode 100644 index 000000000..ce581bdc8 --- /dev/null +++ b/nanobot/session/keys.py @@ -0,0 +1,12 @@ +"""Shared session key constants and helpers.""" + +from __future__ import annotations + +UNIFIED_SESSION_KEY = "unified:default" + + +def session_key_for_channel(channel: str, chat_id: str, *, unified_session: bool = False) -> str: + """Return the session key for a channel/chat pair.""" + if unified_session: + return UNIFIED_SESSION_KEY + return f"{channel}:{chat_id}" diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 9041aa27e..503f43146 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -14,6 +14,7 @@ from typing import Any from loguru import logger from nanobot.config.paths import get_legacy_sessions_dir +from nanobot.session.metadata import SESSION_ROUTING_METADATA_KEY from nanobot.utils.helpers import ( ensure_dir, estimate_message_tokens, @@ -36,7 +37,7 @@ _FORK_VOLATILE_METADATA_KEYS = { "pending_user_turn", "runtime_checkpoint", "thread_goal", - "_routing_context", + SESSION_ROUTING_METADATA_KEY, "title", "title_user_edited", } diff --git a/nanobot/session/metadata.py b/nanobot/session/metadata.py new file mode 100644 index 000000000..f756a07bd --- /dev/null +++ b/nanobot/session/metadata.py @@ -0,0 +1,3 @@ +"""Shared session metadata keys.""" + +SESSION_ROUTING_METADATA_KEY = "_routing_context" diff --git a/nanobot/session/routing.py b/nanobot/session/routing.py index cad4578c0..5ae53c552 100644 --- a/nanobot/session/routing.py +++ b/nanobot/session/routing.py @@ -7,8 +7,7 @@ from typing import Any, Mapping from nanobot.bus.events import InboundMessage from nanobot.cron.automation import is_automation_turn from nanobot.session.manager import Session - -SESSION_ROUTING_METADATA_KEY = "_routing_context" +from nanobot.session.metadata import SESSION_ROUTING_METADATA_KEY _ROUTING_METADATA_KEYS = { "chat_type", diff --git a/nanobot/webui/metadata.py b/nanobot/webui/metadata.py new file mode 100644 index 000000000..03d426bfd --- /dev/null +++ b/nanobot/webui/metadata.py @@ -0,0 +1,4 @@ +"""Shared WebUI metadata keys.""" + +WEBUI_TURN_METADATA_KEY = "webui_turn_id" +WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source" diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index 0d19a7119..e3a8f1dfc 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -18,6 +18,7 @@ from loguru import logger from nanobot.config.paths import get_webui_dir from nanobot.session.manager import SessionManager +from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3 WEBUI_FORK_MARKER_EVENT = "fork_marker" @@ -29,8 +30,6 @@ _TRANSCRIPT_SEGMENT_RE = re.compile(r"^\d{6}\.jsonl$") _DEFAULT_TRANSCRIPT_PAGE_LIMIT = 160 _MAX_TRANSCRIPT_PAGE_LIMIT = 1000 _WEBUI_TURN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") -WEBUI_TURN_METADATA_KEY = "webui_turn_id" -WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source" _MARKDOWN_LOCAL_IMAGE_RE = re.compile( r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)" ) diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 37397aa70..648f35ecf 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -20,8 +20,8 @@ from loguru import logger from websockets.http11 import Request as WsRequest from websockets.http11 import Response -from nanobot.agent.loop import UNIFIED_SESSION_KEY 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.webui.file_preview import WebUIFilePreviewError, file_preview_payload from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index 1c1f9de64..0b212f5fc 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -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.automation import AUTOMATION_HISTORY_META, AUTOMATION_TRIGGER_META from nanobot.providers.base import LLMResponse from nanobot.session.goal_state import GOAL_STATE_KEY from nanobot.session.manager import Session, SessionManager @@ -65,6 +66,41 @@ 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: + 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="Automation: internal prompt", + metadata={ + AUTOMATION_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", + } + }, + ), + session, + ) + + 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 + + def test_clean_generated_title_strips_reasoning_tags() -> None: assert clean_generated_title("reasoning WebUI polish") == "WebUI polish" assert clean_generated_title("Title: The user said hello") == "" diff --git a/tests/agent/test_runner_injections.py b/tests/agent/test_runner_injections.py index b5d970a11..3133a1698 100644 --- a/tests/agent/test_runner_injections.py +++ b/tests/agent/test_runner_injections.py @@ -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 diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index a62d79d96..f8fd70c15 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -11,10 +11,10 @@ from urllib.parse import urlencode import httpx import pytest -from nanobot.agent.loop import UNIFIED_SESSION_KEY 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 diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 86901dab3..975126ad8 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -11,11 +11,13 @@ 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.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() @@ -23,7 +25,7 @@ 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"}, } @@ -36,9 +38,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(): @@ -1350,7 +1352,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context( to="chat-1", channel_meta={ "webui": True, - "webui_turn_id": old_turn_id, + WEBUI_TURN_METADATA_KEY: old_turn_id, "workspace_scope": {"mode": "default"}, }, ), @@ -1365,9 +1367,9 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context( 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"] == { + assert delivered.metadata[WEBUI_TURN_METADATA_KEY].startswith("cron:drink-water:") + assert delivered.metadata[WEBUI_TURN_METADATA_KEY] != old_turn_id + assert delivered.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == { "kind": "cron", "label": "drink water", } @@ -1653,14 +1655,17 @@ def test_gateway_bound_cron_runs_as_session_turn( assert "Automation: 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"] == {"kind": "cron", "label": "Repo check"} - trigger = msg.metadata["_automation_trigger"] + assert msg.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == { + "kind": "cron", + "label": "Repo check", + } + trigger = msg.metadata[AUTOMATION_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." ) - assert msg.metadata["_defer_until_session_idle"] is True + assert msg.metadata[AUTOMATION_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] diff --git a/tests/cron/test_cron_tool_list.py b/tests/cron/test_cron_tool_list.py index 23d426031..499979a66 100644 --- a/tests/cron/test_cron_tool_list.py +++ b/tests/cron/test_cron_tool_list.py @@ -347,7 +347,7 @@ 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 == "Error: scheduled automations must be created from a chat session" assert tool._cron.list_jobs() == [] diff --git a/tests/test_tool_contextvars.py b/tests/test_tool_contextvars.py index ff02b7f56..5f413a9c2 100644 --- a/tests/test_tool_contextvars.py +++ b/tests/test_tool_contextvars.py @@ -10,6 +10,7 @@ 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 @@ -262,7 +263,7 @@ async def test_webui_cron_tool_uses_unified_session_when_enabled(tmp_path) -> No "websocket", "chat-123", metadata={"webui": True}, - session_key="unified:default", + session_key=UNIFIED_SESSION_KEY, ) result = await tool.execute(action="add", message="standup", every_seconds=300) @@ -270,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:default" + assert jobs[0].payload.session_key == UNIFIED_SESSION_KEY @pytest.mark.asyncio From 271b3651d7bee51be6bfbd4401b9fcd4f61c4ced Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 12 Jun 2026 11:57:35 +0800 Subject: [PATCH 13/24] refactor: use cron turn naming internally --- .agent/cron-session-memory.md | 85 ++++++++-------- nanobot/agent/cron_turns.py | 88 ++++++++++++++++ nanobot/agent/loop.py | 101 +++++-------------- nanobot/agent/tools/cron.py | 4 +- nanobot/cli/commands.py | 24 ++--- nanobot/cron/automation.py | 49 --------- nanobot/cron/service.py | 8 +- nanobot/cron/session_turns.py | 49 +++++++++ nanobot/session/routing.py | 8 +- nanobot/templates/agent/cron_reminder.md | 4 +- nanobot/webui/session_automations.py | 4 +- tests/agent/test_loop_save_turn.py | 24 ++--- tests/agent/test_runner_injections.py | 22 ++-- tests/agent/test_task_cancel.py | 11 +- tests/agent/test_unified_session.py | 13 +-- tests/channels/test_websocket_http_routes.py | 6 +- tests/cli/test_commands.py | 22 ++-- tests/cron/test_cron_service.py | 2 +- tests/cron/test_cron_tool_list.py | 2 +- 19 files changed, 283 insertions(+), 243 deletions(-) create mode 100644 nanobot/agent/cron_turns.py delete mode 100644 nanobot/cron/automation.py create mode 100644 nanobot/cron/session_turns.py diff --git a/.agent/cron-session-memory.md b/.agent/cron-session-memory.md index ecc2ee75a..57f5afc17 100644 --- a/.agent/cron-session-memory.md +++ b/.agent/cron-session-memory.md @@ -1,7 +1,7 @@ # Cron / Session / Memory Design Decisions This note records the agreed design direction for fixing the mismatch between -scheduled automations and chat session memory. +scheduled cron jobs and chat session memory. ## Problem @@ -14,7 +14,7 @@ The visible failure mode is awkward: a cron job reports something into a chat, the user discusses it in that chat, and the next cron run behaves as if that discussion never happened. -The fix is not to make cron a separate delivery system. A user automation should +The fix is not to make cron a separate delivery system. A user cron job should be a scheduled input into a session. ## Core Model @@ -39,11 +39,11 @@ These fields are legacy-only. New cron creation should not depend on them. Use explicit branching: -- **Bound user automation**: `payload.kind == "agent_turn"`, +- **Bound user cron job**: `payload.kind == "agent_turn"`, `payload.session_key` is present, and no legacy delivery fields (`deliver`, `channel`, `to`, or `channel_meta`) are set. This uses the new session-turn model. -- **Legacy unbound automation**: user job with no `payload.session_key`. Keep the +- **Legacy unbound cron job**: user job with no `payload.session_key`. Keep the existing behavior. Do not migrate, infer, bind, or add UI for these jobs in this change. - **System job**: `payload.kind == "system_event"` or known internal jobs such @@ -54,7 +54,7 @@ The project should not grow a compatibility subsystem for legacy jobs. Missing ## New Job Creation -`CronTool` must create user automations with a `session_key`. +`CronTool` must create user cron jobs with a `session_key`. - If no request/session context exists, `cron action=add` should fail. - Do not create new unbound jobs. @@ -66,16 +66,16 @@ The project should not grow a compatibility subsystem for legacy jobs. Missing ## Execution Path -Bound user automations should execute through `AgentLoop` as internal inbound +Bound user cron jobs should execute through `AgentLoop` as internal inbound session events, not as an out-of-band `agent.process_direct()` call. The intended flow is: ```text -cron due -> create automation inbound -> AgentLoop dispatches session turn +cron due -> create cron inbound -> AgentLoop dispatches session turn ``` -The inbound event should carry metadata identifying the automation, such as: +The inbound event should carry metadata identifying the cron run, such as: - job id - job name @@ -93,28 +93,28 @@ to legacy `payload.channel`, `payload.to`, or `payload.channel_meta` for bound jobs. Those fields are only for the legacy unbound path. The scheduler must not mark a bound job run as complete just because the inbound -event was queued. It should either wait for the automation turn to complete and +event was queued. It should either wait for the cron turn to complete and record the real outcome, or explicitly model the run as separate states such as -`queued` and `turn_completed`. A failed automation turn must be reflected in the +`queued` and `turn_completed`. A failed cron turn must be reflected in the cron run record/job state, not hidden behind a successful enqueue. ## Active Session Behavior Cron must not interrupt an active session turn. -- If the target session is idle, run the automation turn immediately. -- If the target session is running, defer the automation until the current turn +- If the target session is idle, run the cron turn immediately. +- If the target session is running, defer the cron turn until the current turn completes. -- Do not inject the automation into the active turn's runtime context. -- Do not route automation messages into the existing mid-turn pending injection +- Do not inject the cron turn into the active turn's runtime context. +- Do not route cron messages into the existing mid-turn pending injection queue. -- UI/runtime status may show that an automation is queued, but the current LLM - call should not see the queued automation. +- UI/runtime status may show that a cron run is queued, but the current LLM + call should not see the queued cron turn. -Automation inbound events need explicit metadata, for example -`_automation_trigger` plus `_defer_until_session_idle`. `AgentLoop.run()` must +Cron inbound events need explicit metadata, for example +`_cron_trigger` plus `_cron_defer_until_session_idle`. `AgentLoop.run()` must recognize that metadata before the existing `_pending_queues` mid-turn injection -branch. If the session is active, the event goes to a deferred automation queue, +branch. If the session is active, the event goes to a deferred cron queue, not the pending injection queue. The user experience goal is: cron can run after the current answer, but it @@ -124,17 +124,17 @@ should not take over an answer already in progress. Do not persist the raw internal execution prompt as a normal user message. -Instead, persist a readable automation trigger event, for example: +Instead, persist a readable cron trigger event, for example: ```json { "role": "user", - "content": "Scheduled automation triggered: daily monitor\n\nCheck ...", - "_automation_turn": true, - "automation_id": "abc123", - "automation_name": "daily monitor", - "automation_run_id": "abc123:1770000000000", - "automation_prompt_ref": { + "content": "Scheduled cron job triggered: daily monitor\n\nCheck ...", + "_cron_turn": true, + "cron_job_id": "abc123", + "cron_job_name": "daily monitor", + "cron_run_id": "abc123:1770000000000", + "cron_prompt_ref": { "id": "cron.agent_turn.reminder", "version": 1, "sha256": "..." @@ -160,7 +160,7 @@ Preferred direction: - Move the cron execution prompt out of `commands.py` into a named template. - Use a stable prompt id such as `cron.agent_turn.reminder`. -- Store `prompt_ref` and `automation_run_id` in session history. +- Store `prompt_ref` and `cron_run_id` in session history. - Store the full rendered prompt, prompt variables, and errors in an internal run record. @@ -169,15 +169,15 @@ cron store grow without bound. ## Visibility and Evaluation -A bound user automation is a real session turn. +A bound user cron job is a real session turn. - If it succeeds, save and publish the assistant response. -- Do not pass bound automation responses through `evaluate_response()`. +- Do not pass bound cron responses through `evaluate_response()`. - Keep `evaluate_response()` only for system/legacy paths where the old behavior still applies. - Avoid states where session history contains a response the user never saw. -If a bound automation starts executing, it must leave a visible closure in the +If a bound cron job starts executing, it must leave a visible closure in the session: - success response @@ -189,9 +189,10 @@ the user-facing transcript. ## Deleting Sessions -Deleting a session with bound automations should be a two-step operation. +Deleting a session with bound cron jobs should be a two-step operation. -Default delete behavior should block and return the associated automations: +Default delete behavior should block and return the associated cron jobs. +Existing WebUI/API response field names are kept for compatibility: ```json { @@ -203,7 +204,7 @@ Default delete behavior should block and return the associated automations: } ``` -After explicit confirmation, the API may delete the bound user automations and +After explicit confirmation, the API may delete the bound user cron jobs and then delete the session/thread. Rules: @@ -212,18 +213,18 @@ Rules: session being deleted. - Do not block on system jobs. - Do not block on legacy unbound jobs. -- In unified-session mode, WebUI chats display automations owned by +- In unified-session mode, WebUI chats display cron jobs owned by `unified:default`, but deleting an individual `websocket:*` thread should not - block on or delete those unified automations. + block on or delete those unified cron jobs. - If the user manually deletes files outside the WebUI/API, do not try to compensate. ## Unified Session Mode -When `unified_session` is enabled, WebUI-created automations should bind to the +When `unified_session` is enabled, WebUI-created cron jobs should bind to the same unified session as normal WebUI chat turns: `unified:default`. -- All WebUI chats should display automations owned by `unified:default`. +- All WebUI chats should display cron jobs owned by `unified:default`. - Individual WebUI thread deletion should remain scoped to the concrete `websocket:*` thread being deleted. - Toggling `unified_session` does not migrate existing cron jobs. Existing jobs @@ -232,15 +233,15 @@ same unified session as normal WebUI chat turns: `unified:default`. ## WebUI Scope -This change should not grow into a full automation manager. +This change should not grow into a global scheduler/task manager. Keep the scope focused: - Fix cron/session/memory semantics for new bound jobs. - Preserve legacy job behavior. -- Add deletion protection for sessions with bound automations. -- Update the existing session automation panel only as needed for the new - bound-job status. +- Add deletion protection for sessions with bound cron jobs. +- Update the existing WebUI panel that lists scheduled jobs only as needed for + the new bound-job status. Do not add deterministic legacy migration, legacy binding UI, or a global calendar/task manager in this change. @@ -258,6 +259,6 @@ execution path that behaves differently from scheduled runs. - No legacy migration. - No automatic binding of legacy jobs. - No runtime-context prompt asking the model to bind jobs. -- No new global automation manager. +- No new global scheduler/task manager. - No new delivery-target abstraction. - No user-visible manual cron run. diff --git a/nanobot/agent/cron_turns.py b/nanobot/agent/cron_turns.py new file mode 100644 index 000000000..54c34095e --- /dev/null +++ b/nanobot/agent/cron_turns.py @@ -0,0 +1,88 @@ +"""Coordination for scheduled cron turns.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Iterable + +from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.cron.session_turns import cron_run_id, defer_cron_until_session_idle + + +class CronTurnCoordinator: + """Manage scheduled cron turns without mixing them into live injections.""" + + def __init__( + self, + *, + publish_inbound: Callable[[InboundMessage], Awaitable[None]], + dispatch: Callable[[InboundMessage], Awaitable[object]], + is_running: Callable[[], bool], + ) -> None: + self._publish_inbound = publish_inbound + self._dispatch = dispatch + self._is_running = is_running + self.deferred_queues: dict[str, list[InboundMessage]] = {} + self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {} + + async def submit(self, msg: InboundMessage) -> OutboundMessage | None: + """Submit a scheduled cron turn and wait for its session response.""" + run_id = cron_run_id(msg.metadata) + if not run_id: + raise ValueError("cron turn metadata must include a run_id") + if run_id in self._waiters: + raise RuntimeError(f"cron run {run_id!r} is already pending") + + loop = asyncio.get_running_loop() + future: asyncio.Future[OutboundMessage | None] = loop.create_future() + self._waiters[run_id] = future + try: + if self._is_running(): + await self._publish_inbound(msg) + else: + await self._dispatch(msg) + return await future + finally: + self._waiters.pop(run_id, None) + + def should_defer( + self, + msg: InboundMessage, + *, + session_key: str, + active_session_keys: Iterable[str], + ) -> bool: + return ( + defer_cron_until_session_idle(msg.metadata) + and session_key in active_session_keys + ) + + def complete( + self, + msg: InboundMessage, + *, + response: OutboundMessage | None = None, + error: BaseException | None = None, + ) -> None: + run_id = cron_run_id(msg.metadata) + if not run_id: + return + future = self._waiters.get(run_id) + if future is None or future.done(): + return + if error is not None: + future.set_exception(error) + else: + future.set_result(response) + + def defer(self, session_key: str, msg: InboundMessage) -> None: + self.deferred_queues.setdefault(session_key, []).append(msg) + + async def publish_next_deferred(self, session_key: str) -> None: + queue = self.deferred_queues.get(session_key) + if not queue: + return + msg = queue.pop(0) + if not queue: + self.deferred_queues.pop(session_key, None) + await self._publish_inbound(msg) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 2c1cf2375..fddc8180e 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -19,6 +19,7 @@ from nanobot.agent import context as agent_context from nanobot.agent import model_presets as preset_helpers from nanobot.agent.autocompact import AutoCompact from nanobot.agent.context import ContextBuilder +from nanobot.agent.cron_turns import CronTurnCoordinator from nanobot.agent.hook import AgentHook, CompositeHook from nanobot.agent.memory import Consolidator from nanobot.agent.progress_hook import AgentProgressHook @@ -39,11 +40,9 @@ from nanobot.bus.runtime_events import ( ) from nanobot.command import CommandContext, CommandRouter, register_builtin_commands from nanobot.config.schema import AgentDefaults, ModelPresetConfig -from nanobot.cron.automation import ( - AUTOMATION_HISTORY_META, - automation_run_id, - automation_trigger, - defer_until_session_idle, +from nanobot.cron.session_turns import ( + CRON_HISTORY_META, + cron_trigger, ) from nanobot.providers.base import LLMProvider from nanobot.providers.factory import ProviderSnapshot @@ -306,10 +305,11 @@ class AgentLoop: # When a session has an active task, new messages for that session # are routed here instead of creating a new task. self._pending_queues: dict[str, asyncio.Queue] = {} - # Scheduled automations wait for the current visible turn to finish. - # They must not be injected into the active model call as follow-up text. - self._deferred_automation_queues: dict[str, list[InboundMessage]] = {} - self._automation_waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {} + self._cron_turns = CronTurnCoordinator( + publish_inbound=self.bus.publish_inbound, + dispatch=self._dispatch, + is_running=lambda: self._running, + ) # NANOBOT_MAX_CONCURRENT_REQUESTS: <=0 means unlimited; default 3. _max = int(os.environ.get("NANOBOT_MAX_CONCURRENT_REQUESTS", "3")) self._concurrency_gate: asyncio.Semaphore | None = ( @@ -573,54 +573,8 @@ class AgentLoop: def _runtime_events(self) -> RuntimeEventPublisher: return ensure_runtime_event_publisher(self) - async def submit_automation_turn(self, msg: InboundMessage) -> OutboundMessage | None: - """Submit a scheduled automation as an internal session turn and wait for it.""" - run_id = automation_run_id(msg.metadata) - if not run_id: - raise ValueError("automation turn metadata must include a run_id") - loop = asyncio.get_running_loop() - future: asyncio.Future[OutboundMessage | None] = loop.create_future() - if run_id in self._automation_waiters: - raise RuntimeError(f"automation run {run_id!r} is already pending") - self._automation_waiters[run_id] = future - try: - if self._running: - await self.bus.publish_inbound(msg) - else: - await self._dispatch(msg) - return await future - finally: - self._automation_waiters.pop(run_id, None) - - def _complete_automation_turn( - self, - msg: InboundMessage, - *, - response: OutboundMessage | None = None, - error: BaseException | None = None, - ) -> None: - run_id = automation_run_id(msg.metadata) - if not run_id: - return - future = self._automation_waiters.get(run_id) - if future is None or future.done(): - return - if error is not None: - future.set_exception(error) - else: - future.set_result(response) - - def _defer_automation_turn(self, session_key: str, msg: InboundMessage) -> None: - self._deferred_automation_queues.setdefault(session_key, []).append(msg) - - async def _publish_next_deferred_automation(self, session_key: str) -> None: - queue = self._deferred_automation_queues.get(session_key) - if not queue: - return - msg = queue.pop(0) - if not queue: - self._deferred_automation_queues.pop(session_key, None) - await self.bus.publish_inbound(msg) + async def submit_cron_turn(self, msg: InboundMessage) -> OutboundMessage | None: + return await self._cron_turns.submit(msg) def _persist_user_message_early( self, @@ -640,16 +594,16 @@ class AgentLoop: extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata) extra.update(kwargs) text = msg.content if isinstance(msg.content, str) else "" - if trigger := automation_trigger(msg.metadata): + if trigger := cron_trigger(msg.metadata): persist_content = trigger.get("persist_content") if isinstance(persist_content, str) and persist_content.strip(): text = persist_content extra.update({ - AUTOMATION_HISTORY_META: True, - "automation_id": trigger.get("job_id"), - "automation_name": trigger.get("job_name"), - "automation_run_id": trigger.get("run_id"), - "automation_prompt_ref": trigger.get("prompt_ref"), + CRON_HISTORY_META: True, + "cron_job_id": trigger.get("job_id"), + "cron_job_name": trigger.get("job_name"), + "cron_run_id": trigger.get("run_id"), + "cron_prompt_ref": trigger.get("prompt_ref"), }) session.add_message("user", text, **extra) self._mark_pending_user_turn(session) @@ -951,9 +905,10 @@ class AgentLoop: self.commands.dispatch_priority, ) continue - if ( - defer_until_session_idle(msg.metadata) - and effective_key in self._pending_queues + if self._cron_turns.should_defer( + msg, + session_key=effective_key, + active_session_keys=self._pending_queues.keys(), ): pending_msg = msg if effective_key != msg.session_key: @@ -961,9 +916,9 @@ class AgentLoop: msg, session_key_override=effective_key, ) - self._defer_automation_turn(effective_key, pending_msg) + self._cron_turns.defer(effective_key, pending_msg) logger.info( - "Deferred automation turn for active session {}", + "Deferred cron turn for active session {}", effective_key, ) continue @@ -1080,9 +1035,9 @@ class AgentLoop: session_key=session_key, metadata=msg.metadata, ) - self._complete_automation_turn(msg, response=response) + self._cron_turns.complete(msg, response=response) except asyncio.CancelledError: - self._complete_automation_turn( + self._cron_turns.complete( msg, error=asyncio.CancelledError(), ) @@ -1124,7 +1079,7 @@ class AgentLoop: session_key=session_key, metadata=msg.metadata, ) - self._complete_automation_turn(msg, error=exc) + self._cron_turns.complete(msg, error=exc) finally: # Drain any messages still in the pending queue and re-publish # them to the bus so they are processed as fresh inbound messages @@ -1155,14 +1110,14 @@ class AgentLoop: msg, session_key, "idle" ) self._runtime_events().clear_turn(session_key) - await self._publish_next_deferred_automation(session_key) + await self._cron_turns.publish_next_deferred(session_key) finally: if pending is None: await self._runtime_events().run_status_changed( msg, session_key, "idle" ) self._runtime_events().clear_turn(session_key) - await self._publish_next_deferred_automation(session_key) + await self._cron_turns.publish_next_deferred(session_key) async def close_mcp(self) -> None: """Drain pending background archives, then close MCP connections.""" diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index b1d3b41e2..07584b71e 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -71,7 +71,7 @@ class CronTool(Tool, ContextAware): return cls(cron_service=ctx.cron_service, default_timezone=ctx.timezone) def set_context(self, ctx: RequestContext) -> None: - """Set the current session context for scheduled automation ownership.""" + """Set the current session context for scheduled cron job ownership.""" self._channel.set(ctx.channel) self._chat_id.set(ctx.chat_id) self._metadata.set(ctx.metadata) @@ -166,7 +166,7 @@ class CronTool(Tool, ContextAware): ) session_key = self._session_key.get() if not session_key: - return "Error: scheduled automations must be created from a chat session" + return "Error: scheduled cron jobs must be created from a chat session" if tz and not cron_expr: return "Error: tz can only be used with cron_expr" if tz: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 0f48635e2..9de150115 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -979,12 +979,12 @@ def _run_gateway( from nanobot.bus.queue import MessageBus from nanobot.bus.runtime_events import RuntimeEventBus from nanobot.channels.manager import ChannelManager - from nanobot.cron.automation import ( - AUTOMATION_DEFER_UNTIL_IDLE_META, - AUTOMATION_TRIGGER_META, - is_bound_agent_job, - ) from nanobot.cron.service import CronService + from nanobot.cron.session_turns import ( + CRON_DEFER_UNTIL_IDLE_META, + CRON_TRIGGER_META, + is_bound_cron_job, + ) from nanobot.cron.types import CronJob from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot from nanobot.providers.image_generation import image_gen_provider_configs @@ -1093,7 +1093,7 @@ def _run_gateway( return channel, rest, metadata - def _automation_prompt_ref(prompt: str) -> dict[str, Any]: + def _cron_prompt_ref(prompt: str) -> dict[str, Any]: return { "id": "cron.agent_turn.reminder", "version": 1, @@ -1110,23 +1110,23 @@ def _run_gateway( strip=True, message=job.payload.message, ) - prompt_ref = _automation_prompt_ref(prompt) + prompt_ref = _cron_prompt_ref(prompt) run_id = f"{job.id}:{int(time.time() * 1000)}:{uuid.uuid4().hex[:8]}" channel, chat_id, metadata = _bound_session_delivery_context( session_key, turn_seed=f"cron:{job.id}", source_label=job.name, ) - metadata[AUTOMATION_TRIGGER_META] = { + metadata[CRON_TRIGGER_META] = { "job_id": job.id, "job_name": job.name, "run_id": run_id, "prompt_ref": prompt_ref, "persist_content": ( - f"Scheduled automation triggered: {job.name}\n\n{job.payload.message}" + f"Scheduled cron job triggered: {job.name}\n\n{job.payload.message}" ), } - metadata[AUTOMATION_DEFER_UNTIL_IDLE_META] = True + metadata[CRON_DEFER_UNTIL_IDLE_META] = True run_record_base: dict[str, Any] = { "job_id": job.id, "job_name": job.name, @@ -1149,7 +1149,7 @@ def _run_gateway( if isinstance(cron_tool, CronTool): cron_token = cron_tool.set_cron_context(True) try: - resp = await agent.submit_automation_turn( + resp = await agent.submit_cron_turn( InboundMessage( channel=channel, sender_id="cron", @@ -1345,7 +1345,7 @@ def _run_gateway( logger.info("Heartbeat: silenced by post-run evaluation") return response - if is_bound_agent_job(job): + if is_bound_cron_job(job): return await _run_bound_cron_job(job) reminder_note = ( diff --git a/nanobot/cron/automation.py b/nanobot/cron/automation.py deleted file mode 100644 index f619870ff..000000000 --- a/nanobot/cron/automation.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Shared metadata helpers for scheduled automation turns.""" - -from __future__ import annotations - -from typing import Any, Mapping - -from nanobot.cron.types import CronJob - -AUTOMATION_TRIGGER_META = "_automation_trigger" -AUTOMATION_DEFER_UNTIL_IDLE_META = "_defer_until_session_idle" -AUTOMATION_HISTORY_META = "_automation_turn" - - -def automation_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None: - """Return structured automation trigger metadata when present.""" - raw = (metadata or {}).get(AUTOMATION_TRIGGER_META) - return raw if isinstance(raw, dict) else None - - -def is_automation_turn(metadata: Mapping[str, Any] | None) -> bool: - return automation_trigger(metadata) is not None - - -def defer_until_session_idle(metadata: Mapping[str, Any] | None) -> bool: - return bool( - is_automation_turn(metadata) - and (metadata or {}).get(AUTOMATION_DEFER_UNTIL_IDLE_META) is True - ) - - -def automation_run_id(metadata: Mapping[str, Any] | None) -> str | None: - trigger = automation_trigger(metadata) - if not trigger: - return None - value = trigger.get("run_id") - return value if isinstance(value, str) and value else None - - -def is_bound_agent_job(job: CronJob) -> bool: - """True for new session-bound user automations, excluding legacy delivery payloads.""" - payload = job.payload - if payload.kind != "agent_turn" or not payload.session_key: - return False - return not ( - payload.deliver - or payload.channel - or payload.to - or payload.channel_meta - ) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 30ee7aea9..57c4dc204 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -14,7 +14,7 @@ from typing import Any, Callable, Coroutine, Literal from filelock import FileLock from loguru import logger -from nanobot.cron.automation import is_bound_agent_job +from nanobot.cron.session_turns import is_bound_cron_job from nanobot.cron.types import ( CronJob, CronJobState, @@ -499,17 +499,17 @@ class CronService: jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled] return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float('inf')) - def list_bound_agent_jobs_for_session( + def list_bound_cron_jobs_for_session( self, session_key: str, *, include_disabled: bool = True, ) -> list[CronJob]: - """Return user-created bound automation jobs owned by *session_key*.""" + """Return user-created bound cron jobs owned by *session_key*.""" return [ job for job in self.list_jobs(include_disabled=include_disabled) - if is_bound_agent_job(job) + if is_bound_cron_job(job) and job.payload.session_key == session_key ] diff --git a/nanobot/cron/session_turns.py b/nanobot/cron/session_turns.py new file mode 100644 index 000000000..7a55e36ef --- /dev/null +++ b/nanobot/cron/session_turns.py @@ -0,0 +1,49 @@ +"""Shared metadata helpers for scheduled cron session turns.""" + +from __future__ import annotations + +from typing import Any, Mapping + +from nanobot.cron.types import CronJob + +CRON_TRIGGER_META = "_cron_trigger" +CRON_DEFER_UNTIL_IDLE_META = "_cron_defer_until_session_idle" +CRON_HISTORY_META = "_cron_turn" + + +def cron_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None: + """Return structured cron trigger metadata when present.""" + raw = (metadata or {}).get(CRON_TRIGGER_META) + return raw if isinstance(raw, dict) else None + + +def is_cron_turn(metadata: Mapping[str, Any] | None) -> bool: + return cron_trigger(metadata) is not None + + +def defer_cron_until_session_idle(metadata: Mapping[str, Any] | None) -> bool: + return bool( + is_cron_turn(metadata) + and (metadata or {}).get(CRON_DEFER_UNTIL_IDLE_META) is True + ) + + +def cron_run_id(metadata: Mapping[str, Any] | None) -> str | None: + trigger = cron_trigger(metadata) + if not trigger: + return None + value = trigger.get("run_id") + return value if isinstance(value, str) and value else None + + +def is_bound_cron_job(job: CronJob) -> bool: + """True for new session-bound cron jobs, excluding legacy delivery payloads.""" + payload = job.payload + if payload.kind != "agent_turn" or not payload.session_key: + return False + return not ( + payload.deliver + or payload.channel + or payload.to + or payload.channel_meta + ) diff --git a/nanobot/session/routing.py b/nanobot/session/routing.py index 5ae53c552..5d7c97791 100644 --- a/nanobot/session/routing.py +++ b/nanobot/session/routing.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any, Mapping from nanobot.bus.events import InboundMessage -from nanobot.cron.automation import is_automation_turn +from nanobot.cron.session_turns import is_cron_turn from nanobot.session.manager import Session from nanobot.session.metadata import SESSION_ROUTING_METADATA_KEY @@ -26,7 +26,7 @@ _ROUTING_METADATA_KEYS = { } _CHANNEL_ROUTING_METADATA_KEYS = { # Feishu needs a message anchor to reply into an existing topic. Other - # channels should avoid stale reply anchors for scheduled automation turns. + # channels should avoid stale reply anchors for scheduled cron turns. "feishu": {"message_id"}, } _SLACK_ROUTING_KEYS = {"channel_type", "thread_ts"} @@ -74,8 +74,8 @@ def routing_context_for_message(msg: InboundMessage) -> dict[str, Any]: def persist_routing_context(session: Session, msg: InboundMessage) -> bool: - """Persist the latest non-automation delivery context for a session.""" - if is_automation_turn(msg.metadata): + """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: diff --git a/nanobot/templates/agent/cron_reminder.md b/nanobot/templates/agent/cron_reminder.md index af9803d5a..64f94f21b 100644 --- a/nanobot/templates/agent/cron_reminder.md +++ b/nanobot/templates/agent/cron_reminder.md @@ -1,4 +1,4 @@ -The scheduled time has arrived. Execute this scheduled automation now and report the result to the user in the same session. +The scheduled time has arrived. Execute this scheduled cron job now and report the result to the user in the same session. Rules: - Speak directly to the user in their language. @@ -6,4 +6,4 @@ Rules: - Do not include user IDs. - Do not add status reports like "Done" or "Reminded" unless they are the natural response. -Automation: {{ message }} +Cron job: {{ message }} diff --git a/nanobot/webui/session_automations.py b/nanobot/webui/session_automations.py index 19af69a64..3eb56e5b2 100644 --- a/nanobot/webui/session_automations.py +++ b/nanobot/webui/session_automations.py @@ -8,7 +8,7 @@ from nanobot.cron.types import CronJob class _CronServiceLike(Protocol): - def list_bound_agent_jobs_for_session( + def list_bound_cron_jobs_for_session( self, session_key: str, *, @@ -23,7 +23,7 @@ def session_automation_jobs( """Return user automations attached to the WebUI session.""" if cron_service is None: return [] - return cron_service.list_bound_agent_jobs_for_session( + return cron_service.list_bound_cron_jobs_for_session( session_key, include_disabled=True, ) diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index 0b212f5fc..5295065d0 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -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: diff --git a/tests/agent/test_runner_injections.py b/tests/agent/test_runner_injections.py index 3133a1698..97d653f3a 100644 --- a/tests/agent/test_runner_injections.py +++ b/tests/agent/test_runner_injections.py @@ -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 diff --git a/tests/agent/test_task_cancel.py b/tests/agent/test_task_cancel.py index 0111c2f59..e6a59d5e1 100644 --- a/tests/agent/test_task_cancel.py +++ b/tests/agent/test_task_cancel.py @@ -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 diff --git a/tests/agent/test_unified_session.py b/tests/agent/test_unified_session.py index 48fd91bdc..d6059f747 100644 --- a/tests/agent/test_unified_session.py +++ b/tests/agent/test_unified_session.py @@ -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) diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index f8fd70c15..50e935f8d 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -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: diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 975126ad8..74d623696 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -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" diff --git a/tests/cron/test_cron_service.py b/tests/cron/test_cron_service.py index f258fdd22..98e37dc13 100644 --- a/tests/cron/test_cron_service.py +++ b/tests/cron/test_cron_service.py @@ -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 diff --git a/tests/cron/test_cron_tool_list.py b/tests/cron/test_cron_tool_list.py index 499979a66..4af4de13a 100644 --- a/tests/cron/test_cron_tool_list.py +++ b/tests/cron/test_cron_tool_list.py @@ -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() == [] From 80524e9e88d86a19b75abf9d37c03d07e95c27e0 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 12 Jun 2026 14:00:53 +0800 Subject: [PATCH 14/24] refactor: bind cron jobs to origin sessions --- .agent/cron-session-memory.md | 19 ++-- nanobot/agent/loop.py | 3 - nanobot/agent/tools/cron.py | 2 +- nanobot/cli/commands.py | 18 +--- nanobot/session/manager.py | 2 - nanobot/session/metadata.py | 3 - nanobot/session/routing.py | 104 ------------------- nanobot/webui/ws_http.py | 3 - tests/agent/test_loop_save_turn.py | 7 -- tests/agent/test_session_manager_history.py | 7 -- tests/channels/test_websocket_http_routes.py | 46 ++++---- tests/cli/test_commands.py | 39 +------ tests/session/test_routing.py | 53 ---------- tests/test_tool_contextvars.py | 8 +- 14 files changed, 47 insertions(+), 267 deletions(-) delete mode 100644 nanobot/session/metadata.py delete mode 100644 nanobot/session/routing.py delete mode 100644 tests/session/test_routing.py diff --git a/.agent/cron-session-memory.md b/.agent/cron-session-memory.md index 57f5afc17..df1859472 100644 --- a/.agent/cron-session-memory.md +++ b/.agent/cron-session-memory.md @@ -213,20 +213,23 @@ Rules: session being deleted. - Do not block on system jobs. - Do not block on legacy unbound jobs. -- In unified-session mode, WebUI chats display cron jobs owned by - `unified:default`, but deleting an individual `websocket:*` thread should not - block on or delete those unified cron jobs. +- In unified-session mode, WebUI-created cron jobs still belong to the concrete + `websocket:*` chat that created them, so deleting that chat should block on or + delete those jobs. - If the user manually deletes files outside the WebUI/API, do not try to compensate. ## Unified Session Mode -When `unified_session` is enabled, WebUI-created cron jobs should bind to the -same unified session as normal WebUI chat turns: `unified:default`. +When `unified_session` is enabled, WebUI-created cron jobs should still bind to +the concrete WebUI chat that created them, for example `websocket:`. +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`. -- Individual WebUI thread deletion should remain scoped to the concrete - `websocket:*` thread being deleted. +- Each WebUI chat should display cron jobs owned by that concrete chat. +- Individual WebUI thread deletion should block on cron jobs owned by that + concrete `websocket:*` thread. - Toggling `unified_session` does not migrate existing cron jobs. Existing jobs keep their stored `payload.session_key` and continue to execute against that owner until explicitly removed or recreated. diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index fddc8180e..daf5dd35e 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -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.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.helpers import image_placeholder_text 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) await self._runtime_events().session_turn_started(msg, ctx.session_key) 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): self.sessions.save(ctx.session) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 07584b71e..100b64486 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -75,7 +75,7 @@ class CronTool(Tool, ContextAware): self._channel.set(ctx.channel) self._chat_id.set(ctx.chat_id) 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): """Mark whether the tool is executing inside a cron job callback.""" diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 9de150115..048925ae7 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -988,9 +988,7 @@ def _run_gateway( from nanobot.cron.types import CronJob from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot 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.routing import read_routing_context from nanobot.session.webui_turns import WebuiTurnCoordinator from nanobot.utils.prompt_templates import render_template from nanobot.webui.token_usage import TokenUsageHook @@ -1046,11 +1044,6 @@ def _run_gateway( 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( session_key: str, *, @@ -1063,18 +1056,10 @@ def _run_gateway( if not channel or not rest: raise ValueError(f"bound cron session_key is invalid: {session_key!r}") - session_metadata = _session_metadata(session_key) - routed = read_routing_context(session_metadata) - if routed is not None: - channel, rest, metadata = routed - else: - metadata: dict[str, Any] = {} + metadata: dict[str, Any] = {} if channel == "websocket": metadata["webui"] = True - scope = session_metadata.get(WORKSPACE_SCOPE_METADATA_KEY) - if isinstance(scope, dict): - metadata[WORKSPACE_SCOPE_METADATA_KEY] = dict(scope) metadata.update( _proactive_delivery_metadata( "websocket", @@ -1156,7 +1141,6 @@ def _run_gateway( chat_id=chat_id, content=prompt, metadata=metadata, - session_key_override=session_key, ) ) except (Exception, asyncio.CancelledError) as exc: diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 503f43146..890b25c20 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -14,7 +14,6 @@ from typing import Any from loguru import logger from nanobot.config.paths import get_legacy_sessions_dir -from nanobot.session.metadata import SESSION_ROUTING_METADATA_KEY from nanobot.utils.helpers import ( ensure_dir, estimate_message_tokens, @@ -37,7 +36,6 @@ _FORK_VOLATILE_METADATA_KEYS = { "pending_user_turn", "runtime_checkpoint", "thread_goal", - SESSION_ROUTING_METADATA_KEY, "title", "title_user_edited", } diff --git a/nanobot/session/metadata.py b/nanobot/session/metadata.py deleted file mode 100644 index f756a07bd..000000000 --- a/nanobot/session/metadata.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Shared session metadata keys.""" - -SESSION_ROUTING_METADATA_KEY = "_routing_context" diff --git a/nanobot/session/routing.py b/nanobot/session/routing.py deleted file mode 100644 index 5d7c97791..000000000 --- a/nanobot/session/routing.py +++ /dev/null @@ -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 diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 648f35ecf..f88ec4916 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -21,7 +21,6 @@ from websockets.http11 import Request as WsRequest from websockets.http11 import Response 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.webui.file_preview import WebUIFilePreviewError, file_preview_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: """Return the cron ownership key shown for this WebUI thread.""" - if self._unified_session: - return UNIFIED_SESSION_KEY return session_key # -- Media routes ------------------------------------------------------- diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index 5295065d0..f5862cc86 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -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 diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py index 91520ed86..3441c4833 100644 --- a/tests/agent/test_session_manager_history.py +++ b/tests/agent/test_session_manager_history.py @@ -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"] diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 50e935f8d..96d40f767 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -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() diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 74d623696..99c1bb399 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -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( diff --git a/tests/session/test_routing.py b/tests/session/test_routing.py deleted file mode 100644 index fdd882f90..000000000 --- a/tests/session/test_routing.py +++ /dev/null @@ -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", - }, - } diff --git a/tests/test_tool_contextvars.py b/tests/test_tool_contextvars.py index 5f413a9c2..4dd70c527 100644 --- a/tests/test_tool_contextvars.py +++ b/tests/test_tool_contextvars.py @@ -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" From bc18142650d894680b52b3cf22ea1c7a52a08002 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 12 Jun 2026 14:07:55 +0800 Subject: [PATCH 15/24] chore: drop cron design note from pr --- .agent/cron-session-memory.md | 267 ---------------------------------- 1 file changed, 267 deletions(-) delete mode 100644 .agent/cron-session-memory.md diff --git a/.agent/cron-session-memory.md b/.agent/cron-session-memory.md deleted file mode 100644 index df1859472..000000000 --- a/.agent/cron-session-memory.md +++ /dev/null @@ -1,267 +0,0 @@ -# Cron / Session / Memory Design Decisions - -This note records the agreed design direction for fixing the mismatch between -scheduled cron jobs and chat session memory. - -## Problem - -User-created cron jobs currently run their agent turn under an internal key such -as `cron:{job.id}` and only deliver the final response back to the user channel. -That splits the turn's working memory from the session where the user sees and -continues the conversation. - -The visible failure mode is awkward: a cron job reports something into a chat, -the user discusses it in that chat, and the next cron run behaves as if that -discussion never happened. - -The fix is not to make cron a separate delivery system. A user cron job should -be a scheduled input into a session. - -## Core Model - -For new user-created cron jobs, `payload.session_key` is the canonical anchor. - -- The cron job belongs to that session. -- The cron job reads that session's memory/history. -- The cron job produces a normal session turn. -- There is no separate delivery target concept for new jobs. - -Legacy fields remain in the store only for compatibility: - -- `payload.channel` -- `payload.to` -- `payload.channel_meta` -- `payload.deliver` - -These fields are legacy-only. New cron creation should not depend on them. - -## Job Categories - -Use explicit branching: - -- **Bound user cron job**: `payload.kind == "agent_turn"`, - `payload.session_key` is present, and no legacy delivery fields - (`deliver`, `channel`, `to`, or `channel_meta`) are set. This uses the new - session-turn model. -- **Legacy unbound cron job**: user job with no `payload.session_key`. Keep the - existing behavior. Do not migrate, infer, bind, or add UI for these jobs in - this change. -- **System job**: `payload.kind == "system_event"` or known internal jobs such - as `dream` / `heartbeat`. Keep their specialized paths. - -The project should not grow a compatibility subsystem for legacy jobs. Missing -`session_key` means old behavior. - -## New Job Creation - -`CronTool` must create user cron jobs with a `session_key`. - -- If no request/session context exists, `cron action=add` should fail. -- Do not create new unbound jobs. -- Do not infer `session_key` from `channel/to` for new jobs. -- Remove `deliver` from the advertised tool schema. It can remain as a Python - compatibility argument, but it must not affect new bound jobs. -- New bound jobs should persist `message` and `session_key`; legacy delivery - fields should not be populated as part of the new path. - -## Execution Path - -Bound user cron jobs should execute through `AgentLoop` as internal inbound -session events, not as an out-of-band `agent.process_direct()` call. - -The intended flow is: - -```text -cron due -> create cron inbound -> AgentLoop dispatches session turn -``` - -The inbound event should carry metadata identifying the cron run, such as: - -- job id -- job name -- run id -- prompt reference -- persisted trigger content - -This keeps locking, runtime status, session persistence, and WebUI behavior on -the same path as normal chat turns. - -`session_key` is the ownership anchor, but an `InboundMessage` still needs an -execution context. Bound cron must resolve `channel`, `chat_id`, and any -channel metadata from the target session/session metadata. It must not fall back -to legacy `payload.channel`, `payload.to`, or `payload.channel_meta` for bound -jobs. Those fields are only for the legacy unbound path. - -The scheduler must not mark a bound job run as complete just because the inbound -event was queued. It should either wait for the cron turn to complete and -record the real outcome, or explicitly model the run as separate states such as -`queued` and `turn_completed`. A failed cron turn must be reflected in the -cron run record/job state, not hidden behind a successful enqueue. - -## Active Session Behavior - -Cron must not interrupt an active session turn. - -- If the target session is idle, run the cron turn immediately. -- If the target session is running, defer the cron turn until the current turn - completes. -- Do not inject the cron turn into the active turn's runtime context. -- Do not route cron messages into the existing mid-turn pending injection - queue. -- UI/runtime status may show that a cron run is queued, but the current LLM - call should not see the queued cron turn. - -Cron inbound events need explicit metadata, for example -`_cron_trigger` plus `_cron_defer_until_session_idle`. `AgentLoop.run()` must -recognize that metadata before the existing `_pending_queues` mid-turn injection -branch. If the session is active, the event goes to a deferred cron queue, -not the pending injection queue. - -The user experience goal is: cron can run after the current answer, but it -should not take over an answer already in progress. - -## Session History - -Do not persist the raw internal execution prompt as a normal user message. - -Instead, persist a readable cron trigger event, for example: - -```json -{ - "role": "user", - "content": "Scheduled cron job triggered: daily monitor\n\nCheck ...", - "_cron_turn": true, - "cron_job_id": "abc123", - "cron_job_name": "daily monitor", - "cron_run_id": "abc123:1770000000000", - "cron_prompt_ref": { - "id": "cron.agent_turn.reminder", - "version": 1, - "sha256": "..." - } -} -``` - -The assistant result should be saved as the normal assistant response for that -turn, with source metadata suitable for WebUI rendering. - -This gives future turns useful context without leaking internal instruction text -into the transcript. - -## Prompt Traceability - -The rendered execution prompt should remain traceable, but it should not be part -of normal session history. - -Use a named/versioned prompt reference in session history and save the full -rendered prompt in an internal run record. - -Preferred direction: - -- Move the cron execution prompt out of `commands.py` into a named template. -- Use a stable prompt id such as `cron.agent_turn.reminder`. -- Store `prompt_ref` and `cron_run_id` in session history. -- Store the full rendered prompt, prompt variables, and errors in an internal - run record. - -Avoid putting full prompt text into `jobs.json`; run records should not make the -cron store grow without bound. - -## Visibility and Evaluation - -A bound user cron job is a real session turn. - -- If it succeeds, save and publish the assistant response. -- Do not pass bound cron responses through `evaluate_response()`. -- Keep `evaluate_response()` only for system/legacy paths where the old behavior - still applies. -- Avoid states where session history contains a response the user never saw. - -If a bound cron job starts executing, it must leave a visible closure in the -session: - -- success response -- short failure message -- or an empty-result status message - -Full exceptions and diagnostic details belong in the internal run record, not in -the user-facing transcript. - -## Deleting Sessions - -Deleting a session with bound cron jobs should be a two-step operation. - -Default delete behavior should block and return the associated cron jobs. -Existing WebUI/API response field names are kept for compatibility: - -```json -{ - "deleted": false, - "blocked_by_automations": true, - "automations": [ - {"id": "abc123", "name": "daily monitor", "enabled": true} - ] -} -``` - -After explicit confirmation, the API may delete the bound user cron jobs and -then delete the session/thread. - -Rules: - -- Only block on user-created bound jobs whose `payload.session_key` equals the - session being deleted. -- Do not block on system jobs. -- Do not block on legacy unbound jobs. -- In unified-session mode, WebUI-created cron jobs still belong to the concrete - `websocket:*` chat that created them, so deleting that chat should block on or - delete those jobs. -- If the user manually deletes files outside the WebUI/API, do not try to - compensate. - -## Unified Session Mode - -When `unified_session` is enabled, WebUI-created cron jobs should still bind to -the concrete WebUI chat that created them, for example `websocket:`. -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. - -- Each WebUI chat should display cron jobs owned by that concrete chat. -- Individual WebUI thread deletion should block on cron jobs owned by that - concrete `websocket:*` thread. -- Toggling `unified_session` does not migrate existing cron jobs. Existing jobs - keep their stored `payload.session_key` and continue to execute against that - owner until explicitly removed or recreated. - -## WebUI Scope - -This change should not grow into a global scheduler/task manager. - -Keep the scope focused: - -- Fix cron/session/memory semantics for new bound jobs. -- Preserve legacy job behavior. -- Add deletion protection for sessions with bound cron jobs. -- Update the existing WebUI panel that lists scheduled jobs only as needed for - the new bound-job status. - -Do not add deterministic legacy migration, legacy binding UI, or a global -calendar/task manager in this change. - -## Manual Run - -Do not add a user-visible "run now" feature as part of this design. - -`CronService.run_job()` may remain an internal/test helper. It should not become -a product surface, and the implementation should avoid creating a separate -execution path that behaves differently from scheduled runs. - -## Non-Goals - -- No legacy migration. -- No automatic binding of legacy jobs. -- No runtime-context prompt asking the model to bind jobs. -- No new global scheduler/task manager. -- No new delivery-target abstraction. -- No user-visible manual cron run. From c4b64a4caf2b3a13507477b987f72350eaf8f6cc Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 12 Jun 2026 14:21:09 +0800 Subject: [PATCH 16/24] refactor: preserve origin session routing for cron --- nanobot/agent/tools/cron.py | 12 ++--- nanobot/channels/manager.py | 1 - nanobot/cli/commands.py | 19 ++----- nanobot/cron/session_delivery.py | 57 ++++++++++++++++++++ nanobot/webui/gateway_services.py | 2 - nanobot/webui/ws_http.py | 8 +-- tests/channels/test_websocket_http_routes.py | 8 +-- tests/cli/test_commands.py | 48 +++++++++++++++-- tests/cron/test_session_delivery.py | 45 ++++++++++++++++ tests/test_tool_contextvars.py | 21 ++++++++ 10 files changed, 179 insertions(+), 42 deletions(-) create mode 100644 nanobot/cron/session_delivery.py create mode 100644 tests/cron/test_session_delivery.py diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 100b64486..6f554d7bd 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -15,6 +15,7 @@ from nanobot.agent.tools.schema import ( ) from nanobot.cron.service import CronService from nanobot.cron.types import CronJob, CronJobState, CronSchedule +from nanobot.session.keys import UNIFIED_SESSION_KEY _CRON_PARAMETERS = tool_parameters_schema( action=StringSchema("Action to perform", enum=["add", "list", "remove"]), @@ -56,9 +57,6 @@ class CronTool(Tool, ContextAware): def __init__(self, cron_service: CronService, default_timezone: str = "UTC"): self._cron = cron_service self._default_timezone = default_timezone - self._channel: ContextVar[str] = ContextVar("cron_channel", default="") - self._chat_id: ContextVar[str] = ContextVar("cron_chat_id", default="") - self._metadata: ContextVar[dict] = ContextVar("cron_metadata", default={}) self._session_key: ContextVar[str] = ContextVar("cron_session_key", default="") self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False) @@ -72,10 +70,10 @@ class CronTool(Tool, ContextAware): def set_context(self, ctx: RequestContext) -> None: """Set the current session context for scheduled cron job ownership.""" - self._channel.set(ctx.channel) - self._chat_id.set(ctx.chat_id) - self._metadata.set(ctx.metadata) - self._session_key.set(f"{ctx.channel}:{ctx.chat_id}" if ctx.channel and ctx.chat_id else "") + raw_key = f"{ctx.channel}:{ctx.chat_id}" if ctx.channel and ctx.chat_id else "" + self._session_key.set( + raw_key if ctx.session_key in {None, "", UNIFIED_SESSION_KEY} else ctx.session_key + ) def set_cron_context(self, active: bool): """Mark whether the tool is executing inside a cron job callback.""" diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index cc5c62b1a..b59925232 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -125,7 +125,6 @@ class ChannelManager: runtime_model_name=self._webui_runtime_model_name, runtime_surface=self._webui_runtime_surface, runtime_capabilities_overrides=self._webui_runtime_capabilities, - unified_session=self.config.agents.defaults.unified_session, cron_service=self._cron_service, logger=logger, ) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 048925ae7..ea6d6cdf2 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -980,6 +980,7 @@ def _run_gateway( from nanobot.bus.runtime_events import RuntimeEventBus from nanobot.channels.manager import ChannelManager from nanobot.cron.service import CronService + from nanobot.cron.session_delivery import bound_session_inbound_context from nanobot.cron.session_turns import ( CRON_DEFER_UNTIL_IDLE_META, CRON_TRIGGER_META, @@ -1050,13 +1051,7 @@ def _run_gateway( turn_seed: str, source_label: str | None, ) -> tuple[str, str, dict[str, Any]]: - if ":" not in session_key: - raise ValueError(f"bound cron session_key is invalid: {session_key!r}") - channel, rest = session_key.split(":", 1) - if not channel or not rest: - raise ValueError(f"bound cron session_key is invalid: {session_key!r}") - - metadata: dict[str, Any] = {} + channel, chat_id, metadata = bound_session_inbound_context(session_key) if channel == "websocket": metadata["webui"] = True @@ -1068,15 +1063,8 @@ def _run_gateway( source_label=source_label, ) ) - return channel, rest, metadata - if channel == "slack" and ":" in rest: - chat_id, thread_ts = rest.split(":", 1) - if thread_ts: - metadata["slack"] = {"thread_ts": thread_ts} - return channel, chat_id, metadata - - return channel, rest, metadata + return channel, chat_id, metadata def _cron_prompt_ref(prompt: str) -> dict[str, Any]: return { @@ -1141,6 +1129,7 @@ def _run_gateway( chat_id=chat_id, content=prompt, metadata=metadata, + session_key_override=session_key, ) ) except (Exception, asyncio.CancelledError) as exc: diff --git a/nanobot/cron/session_delivery.py b/nanobot/cron/session_delivery.py new file mode 100644 index 000000000..1d10bb890 --- /dev/null +++ b/nanobot/cron/session_delivery.py @@ -0,0 +1,57 @@ +"""Helpers for routing bound cron turns back through their origin session.""" + +from __future__ import annotations + +from typing import Any + + +def bound_session_inbound_context(session_key: str) -> tuple[str, str, dict[str, Any]]: + """Return ``(channel, chat_id, metadata)`` for a bound cron session key.""" + if ":" not in session_key: + raise ValueError(f"bound cron session_key is invalid: {session_key!r}") + channel, rest = session_key.split(":", 1) + if not channel or not rest: + raise ValueError(f"bound cron session_key is invalid: {session_key!r}") + + metadata: dict[str, Any] = {} + + if channel == "discord" and ":thread:" in rest: + parent_channel_id, thread_id = rest.split(":thread:", 1) + if parent_channel_id and thread_id: + metadata.update({ + "context_chat_id": parent_channel_id, + "parent_channel_id": parent_channel_id, + "thread_id": thread_id, + }) + return channel, thread_id, metadata + + if channel == "feishu" and ":" in rest: + chat_id, thread_id = rest.split(":", 1) + if chat_id and thread_id: + metadata.update({ + "chat_type": "group", + "message_id": thread_id, + "thread_id": thread_id, + }) + return channel, chat_id, metadata + + if channel == "slack" and ":" in rest: + chat_id, thread_ts = rest.split(":", 1) + if thread_ts: + metadata["slack"] = {"thread_ts": thread_ts} + return channel, chat_id, metadata + + if channel == "telegram" and ":topic:" in rest: + chat_id, thread_id = rest.split(":topic:", 1) + if thread_id: + metadata["message_thread_id"] = ( + int(thread_id) if thread_id.isdigit() else thread_id + ) + return channel, chat_id, metadata + + if channel == "dingtalk" and rest.startswith("group:"): + parts = rest.split(":", 2) + if len(parts) >= 2 and parts[1]: + return channel, f"group:{parts[1]}", metadata + + return channel, rest, metadata diff --git a/nanobot/webui/gateway_services.py b/nanobot/webui/gateway_services.py index 53d3f0db1..15649d08d 100644 --- a/nanobot/webui/gateway_services.py +++ b/nanobot/webui/gateway_services.py @@ -39,7 +39,6 @@ def build_gateway_services( runtime_model_name: Any | None, runtime_surface: str, runtime_capabilities_overrides: dict[str, Any] | None, - unified_session: bool = False, disabled_skills: set[str] | None = None, cron_service: Any | None = None, logger: Any = default_logger, @@ -62,7 +61,6 @@ def build_gateway_services( runtime_model_name=runtime_model_name, runtime_surface=runtime_surface, runtime_capabilities_overrides=runtime_capabilities_overrides, - unified_session=unified_session, bus=bus, tokens=tokens, media=media, diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index f88ec4916..70e19e01b 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -139,7 +139,6 @@ class GatewayHTTPHandler: runtime_model_name: Callable[[], str | None] | None, runtime_surface: str, runtime_capabilities_overrides: dict[str, Any] | None, - unified_session: bool = False, bus: MessageBus, tokens: GatewayTokenStore, media: WebUIMediaGateway, @@ -162,7 +161,6 @@ class GatewayHTTPHandler: self.cron_service = cron_service self._log = log self._runtime_surface = runtime_surface - self._unified_session = unified_session from nanobot.webui.settings_api import runtime_capabilities as _rc from nanobot.webui.settings_routes import WebUISettingsRouter @@ -439,7 +437,7 @@ class GatewayHTTPHandler: if not _is_websocket_channel_session_key(decoded_key): return _http_error(404, "session not found") return _http_json_response( - session_automations_payload(self.cron_service, self._automation_display_key(decoded_key)) + session_automations_payload(self.cron_service, decoded_key) ) def _handle_session_delete(self, request: WsRequest, key: str) -> Response: @@ -470,10 +468,6 @@ class GatewayHTTPHandler: delete_webui_thread(decoded_key) return _http_json_response({"deleted": bool(deleted)}) - def _automation_display_key(self, session_key: str) -> str: - """Return the cron ownership key shown for this WebUI thread.""" - return session_key - # -- Media routes ------------------------------------------------------- def _dispatch_media_routes(self, request: WsRequest, got: str) -> Response | None: diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 96d40f767..d8c137630 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -30,7 +30,6 @@ def _make_handler( workspace_path: Path | None = None, runtime_model_name: Any | None = None, cron_service: CronService | None = None, - unified_session: bool = False, ) -> GatewayServices: config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg workspace = workspace_path or Path.cwd() @@ -44,7 +43,6 @@ def _make_handler( runtime_model_name=runtime_model_name, runtime_surface="browser", runtime_capabilities_overrides=None, - unified_session=unified_session, cron_service=cron_service, ) @@ -58,7 +56,6 @@ def _ch( port: int = _PORT, runtime_model_name: Any | None = None, cron_service: CronService | None = None, - unified_session: bool = False, **extra: Any, ) -> WebSocketChannel: cfg: dict[str, Any] = { @@ -77,7 +74,6 @@ def _ch( workspace_path=workspace_path, runtime_model_name=runtime_model_name, cron_service=cron_service, - unified_session=unified_session, ) return WebSocketChannel(cfg, bus, gateway=gateway) @@ -243,7 +239,7 @@ async def test_session_automations_route_filters_by_webui_session( @pytest.mark.asyncio -async def test_session_automations_route_uses_origin_owner_when_unified_enabled( +async def test_session_automations_route_ignores_unified_owner( bus: MagicMock, tmp_path: Path ) -> None: cron = CronService(tmp_path / "cron" / "jobs.json") @@ -264,7 +260,6 @@ async def test_session_automations_route_uses_origin_owner_when_unified_enabled( bus, session_manager=_seed_session(tmp_path, key="websocket:abc"), cron_service=cron, - unified_session=True, port=29917, ) server_task = asyncio.create_task(channel.start()) @@ -823,7 +818,6 @@ async def test_session_delete_blocks_origin_automation_when_unified_enabled( bus, session_manager=sm, cron_service=cron, - unified_session=True, port=29918, ) server_task = asyncio.create_task(channel.start()) diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 99c1bb399..84dd0d170 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -1622,7 +1622,7 @@ 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 is None + 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] == { @@ -1645,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:777", + session_key="discord:456:thread:777", ), ) @@ -1656,7 +1656,49 @@ 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 is None + 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", + ), + ) + + 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", + ), + ) + + 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_cron_job_suppresses_intermediate_progress( diff --git a/tests/cron/test_session_delivery.py b/tests/cron/test_session_delivery.py new file mode 100644 index 000000000..02948a3fa --- /dev/null +++ b/tests/cron/test_session_delivery.py @@ -0,0 +1,45 @@ +import pytest + +from nanobot.cron.session_delivery import bound_session_inbound_context + + +@pytest.mark.parametrize( + ("session_key", "expected"), + [ + ("websocket:chat-1", ("websocket", "chat-1", {})), + ( + "discord:456:thread:777", + ( + "discord", + "777", + { + "context_chat_id": "456", + "parent_channel_id": "456", + "thread_id": "777", + }, + ), + ), + ( + "feishu:oc_abc:om_root123", + ( + "feishu", + "oc_abc", + { + "chat_type": "group", + "message_id": "om_root123", + "thread_id": "om_root123", + }, + ), + ), + ("slack:C123:1700.42", ("slack", "C123", {"slack": {"thread_ts": "1700.42"}})), + ("telegram:-100123:topic:42", ("telegram", "-100123", {"message_thread_id": 42})), + ("dingtalk:group:conv-1:user-1", ("dingtalk", "group:conv-1", {})), + ], +) +def test_bound_session_inbound_context(session_key, expected) -> None: + assert bound_session_inbound_context(session_key) == expected + + +def test_bound_session_inbound_context_rejects_invalid_key() -> None: + with pytest.raises(ValueError): + bound_session_inbound_context("unified") diff --git a/tests/test_tool_contextvars.py b/tests/test_tool_contextvars.py index 4dd70c527..ea9b4753e 100644 --- a/tests/test_tool_contextvars.py +++ b/tests/test_tool_contextvars.py @@ -274,6 +274,27 @@ async def test_webui_cron_tool_uses_origin_session_when_unified_enabled(tmp_path assert jobs[0].payload.session_key == "websocket:chat-123" +@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" + + @pytest.mark.asyncio async def test_cron_tool_no_context_returns_error(tmp_path) -> None: """Without set_context, add should fail with a clear error.""" From b232a527942e0e25307f155ee77dc00e69eda7ab Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 12 Jun 2026 14:51:02 +0800 Subject: [PATCH 17/24] fix: tighten cron session deletion UX --- docs/chat-apps.md | 4 +- nanobot/agent/tools/cron.py | 2 +- webui/src/components/DeleteConfirm.tsx | 84 ++++++++++++++++++++++-- webui/src/i18n/locales/en/common.json | 17 ++++- webui/src/i18n/locales/es/common.json | 17 ++++- webui/src/i18n/locales/fr/common.json | 17 ++++- webui/src/i18n/locales/id/common.json | 17 ++++- webui/src/i18n/locales/ja/common.json | 17 ++++- webui/src/i18n/locales/ko/common.json | 17 ++++- webui/src/i18n/locales/vi/common.json | 17 ++++- webui/src/i18n/locales/zh-CN/common.json | 17 ++++- webui/src/i18n/locales/zh-TW/common.json | 17 ++++- 12 files changed, 225 insertions(+), 18 deletions(-) diff --git a/docs/chat-apps.md b/docs/chat-apps.md index 068e7edfc..f23ed7b91 100644 --- a/docs/chat-apps.md +++ b/docs/chat-apps.md @@ -572,7 +572,9 @@ nanobot gateway DM the bot directly or @mention it in a channel — it should respond! > [!TIP] -> - `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all channel messages), or `"allowlist"` (restrict to specific channels). +> - `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all channel messages), or `"allowlist"` (restrict to specific channels via `groupAllowFrom`). +> - `groupAllowFrom`: channel IDs the bot may respond in when `groupPolicy` is `"allowlist"`. +> - `groupRequireMention`: when `true` and `groupPolicy` is `"allowlist"`, the bot only replies to channels in `groupAllowFrom` **and** only when @mentioned (instead of every message). No effect for `"mention"`/`"open"`. Use this to scope the bot to approved channels while keeping mention-only behavior. > - DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs. diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 6f554d7bd..9587756c5 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -72,7 +72,7 @@ class CronTool(Tool, ContextAware): """Set the current session context for scheduled cron job ownership.""" raw_key = f"{ctx.channel}:{ctx.chat_id}" if ctx.channel and ctx.chat_id else "" self._session_key.set( - raw_key if ctx.session_key in {None, "", UNIFIED_SESSION_KEY} else ctx.session_key + raw_key if ctx.session_key == UNIFIED_SESSION_KEY else (ctx.session_key or "") ) def set_cron_context(self, active: bool): diff --git a/webui/src/components/DeleteConfirm.tsx b/webui/src/components/DeleteConfirm.tsx index 2a58a7dc5..0f5e7f04c 100644 --- a/webui/src/components/DeleteConfirm.tsx +++ b/webui/src/components/DeleteConfirm.tsx @@ -8,8 +8,11 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import type { TFunction } from "i18next"; import { Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { currentLocale } from "@/i18n"; +import { fmtDateTime } from "@/lib/format"; import type { SessionAutomationJob } from "@/lib/types"; interface DeleteConfirmProps { @@ -28,6 +31,7 @@ export function DeleteConfirm({ onConfirm, }: DeleteConfirmProps) { const { t } = useTranslation(); + const locale = currentLocale(); const hasAutomations = automations.length > 0; const visibleAutomations = automations.slice(0, 4); const hiddenCount = Math.max(0, automations.length - visibleAutomations.length); @@ -49,23 +53,29 @@ export function DeleteConfirm({ {hasAutomations ? t("deleteConfirm.automationsDescription", { count: automations.length, - defaultValue: - "This chat has scheduled automations. Deleting it will also delete them.", }) : t("deleteConfirm.description")} {hasAutomations ? ( -
+
{visibleAutomations.map((job) => ( -
- {job.name || job.id} +
+
+ {job.name || job.id} +
+
+ + {formatAutomationSchedule(job, t, locale)} + + · + {formatAutomationNextRun(job, t, locale)} +
))} {hiddenCount > 0 ? (
{t("deleteConfirm.moreAutomations", { count: hiddenCount, - defaultValue: "+ {{count}} more", })}
) : null} @@ -85,7 +95,7 @@ export function DeleteConfirm({ > {hasAutomations ? t("deleteConfirm.confirmWithAutomations", { - defaultValue: "Delete all", + count: automations.length, }) : t("deleteConfirm.confirm")} @@ -94,3 +104,63 @@ export function DeleteConfirm({ ); } + +function formatAutomationSchedule( + job: SessionAutomationJob, + t: TFunction, + locale: string, +): string { + if (job.schedule.kind === "at" && job.schedule.at_ms) { + return t("deleteConfirm.schedule.at", { time: fmtDateTime(job.schedule.at_ms, locale) }); + } + if (job.schedule.kind === "every" && job.schedule.every_ms) { + return t("deleteConfirm.schedule.every", { + duration: formatDuration(job.schedule.every_ms, locale), + }); + } + if (job.schedule.kind === "cron" && job.schedule.expr) { + return job.schedule.tz + ? t("deleteConfirm.schedule.cronWithTz", { + expr: job.schedule.expr, + tz: job.schedule.tz, + }) + : t("deleteConfirm.schedule.cron", { expr: job.schedule.expr }); + } + return t("deleteConfirm.schedule.unknown"); +} + +function formatAutomationNextRun( + job: SessionAutomationJob, + t: TFunction, + locale: string, +): string { + if (!job.enabled) return t("deleteConfirm.next.disabled"); + const next = job.state.next_run_at_ms; + if (!next) return t("deleteConfirm.next.none"); + return t("deleteConfirm.next.label", { time: fmtDateTime(next, locale) }); +} + +function formatDuration(ms: number, locale: string): string { + const units: Array<[Intl.NumberFormatOptions["unit"], number]> = [ + ["day", 86_400_000], + ["hour", 3_600_000], + ["minute", 60_000], + ["second", 1000], + ]; + for (const [unit, size] of units) { + if (ms >= size && ms % size === 0) { + return new Intl.NumberFormat(locale, { + style: "unit", + unit, + unitDisplay: "long", + maximumFractionDigits: 0, + }).format(ms / size); + } + } + return new Intl.NumberFormat(locale, { + style: "unit", + unit: "minute", + unitDisplay: "long", + maximumFractionDigits: 1, + }).format(ms / 60_000); +} diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 605e66f01..3729d9e13 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -551,7 +551,22 @@ "title": "Delete this chat?", "description": "This action cannot be undone.", "cancel": "Cancel", - "confirm": "Delete" + "confirm": "Delete", + "automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.", + "moreAutomations": "+ {{count}} more", + "confirmWithAutomations": "Delete chat and automations", + "schedule": { + "at": "{{time}}", + "every": "Every {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "Custom schedule" + }, + "next": { + "label": "Next: {{time}}", + "disabled": "Paused", + "none": "No next run" + } }, "connection": { "idle": "Idle", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 165940215..1cf97f7bd 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -551,7 +551,22 @@ "title": "¿Eliminar este chat?", "description": "Esta acción no se puede deshacer.", "cancel": "Cancelar", - "confirm": "Eliminar" + "confirm": "Eliminar", + "automationsDescription": "Este chat tiene automatizaciones programadas. Al eliminarlo también se eliminarán.", + "moreAutomations": "+ {{count}} más", + "confirmWithAutomations": "Eliminar chat y automatizaciones", + "schedule": { + "at": "{{time}}", + "every": "Cada {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "Programación personalizada" + }, + "next": { + "label": "Siguiente: {{time}}", + "disabled": "Pausada", + "none": "Sin próxima ejecución" + } }, "connection": { "idle": "Inactivo", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 2f87ec4b9..ed37dcb30 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -551,7 +551,22 @@ "title": "Supprimer cette discussion ?", "description": "Cette action est irréversible.", "cancel": "Annuler", - "confirm": "Supprimer" + "confirm": "Supprimer", + "automationsDescription": "Cette discussion contient des automatisations planifiées. La supprimer les supprimera aussi.", + "moreAutomations": "+ {{count}} autres", + "confirmWithAutomations": "Supprimer la discussion et les automatisations", + "schedule": { + "at": "{{time}}", + "every": "Tous les {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "Planification personnalisée" + }, + "next": { + "label": "Prochaine exécution : {{time}}", + "disabled": "En pause", + "none": "Aucune prochaine exécution" + } }, "connection": { "idle": "Inactif", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 72770eaea..2aae43477 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -551,7 +551,22 @@ "title": "Hapus obrolan ini?", "description": "Tindakan ini tidak dapat dibatalkan.", "cancel": "Batal", - "confirm": "Hapus" + "confirm": "Hapus", + "automationsDescription": "Obrolan ini memiliki automasi terjadwal. Menghapusnya juga akan menghapus automasi tersebut.", + "moreAutomations": "+ {{count}} lagi", + "confirmWithAutomations": "Hapus obrolan dan automasi", + "schedule": { + "at": "{{time}}", + "every": "Setiap {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "Jadwal khusus" + }, + "next": { + "label": "Berikutnya: {{time}}", + "disabled": "Dijeda", + "none": "Tidak ada jadwal berikutnya" + } }, "connection": { "idle": "Idle", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index fef2bf225..3100e98ba 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -551,7 +551,22 @@ "title": "このチャットを削除しますか?", "description": "この操作は元に戻せません。", "cancel": "キャンセル", - "confirm": "削除" + "confirm": "削除", + "automationsDescription": "このチャットにはスケジュール済みの自動タスクがあります。削除するとそれらも削除されます。", + "moreAutomations": "他 {{count}} 件", + "confirmWithAutomations": "チャットと自動タスクを削除", + "schedule": { + "at": "{{time}}", + "every": "{{duration}} ごと", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "カスタムスケジュール" + }, + "next": { + "label": "次回: {{time}}", + "disabled": "一時停止中", + "none": "次回実行なし" + } }, "connection": { "idle": "待機中", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index dcc0b2172..952e48f68 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -551,7 +551,22 @@ "title": "이 채팅을 삭제할까요?", "description": "이 작업은 되돌릴 수 없습니다.", "cancel": "취소", - "confirm": "삭제" + "confirm": "삭제", + "automationsDescription": "이 채팅에는 예약된 자동화가 있습니다. 채팅을 삭제하면 자동화도 함께 삭제됩니다.", + "moreAutomations": "+ {{count}}개 더", + "confirmWithAutomations": "채팅과 자동화 삭제", + "schedule": { + "at": "{{time}}", + "every": "{{duration}}마다", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "사용자 지정 일정" + }, + "next": { + "label": "다음: {{time}}", + "disabled": "일시 중지됨", + "none": "다음 실행 없음" + } }, "connection": { "idle": "대기 중", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index ee2e8463c..21faca3d7 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -551,7 +551,22 @@ "title": "Xóa cuộc trò chuyện này?", "description": "Không thể hoàn tác thao tác này.", "cancel": "Hủy", - "confirm": "Xóa" + "confirm": "Xóa", + "automationsDescription": "Cuộc trò chuyện này có các tự động hóa đã lên lịch. Xóa cuộc trò chuyện cũng sẽ xóa chúng.", + "moreAutomations": "+ {{count}} mục nữa", + "confirmWithAutomations": "Xóa trò chuyện và tự động hóa", + "schedule": { + "at": "{{time}}", + "every": "Mỗi {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "Lịch tùy chỉnh" + }, + "next": { + "label": "Tiếp theo: {{time}}", + "disabled": "Đã tạm dừng", + "none": "Không có lần chạy tiếp theo" + } }, "connection": { "idle": "Rảnh", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 29aa44baa..29de0f71e 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -551,7 +551,22 @@ "title": "删除这个对话?", "description": "此操作无法撤销。", "cancel": "取消", - "confirm": "删除" + "confirm": "删除", + "automationsDescription": "这个对话有关联的自动任务。删除对话也会删除这些自动任务。", + "moreAutomations": "另有 {{count}} 个", + "confirmWithAutomations": "删除对话和自动任务", + "schedule": { + "at": "{{time}}", + "every": "每 {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "自定义计划" + }, + "next": { + "label": "下次:{{time}}", + "disabled": "已暂停", + "none": "没有下次运行" + } }, "connection": { "idle": "空闲", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index b5b382fac..9143f8047 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -551,7 +551,22 @@ "title": "刪除這個對話?", "description": "此操作無法復原。", "cancel": "取消", - "confirm": "刪除" + "confirm": "刪除", + "automationsDescription": "這個對話有關聯的自動任務。刪除對話也會刪除這些自動任務。", + "moreAutomations": "另有 {{count}} 個", + "confirmWithAutomations": "刪除對話和自動任務", + "schedule": { + "at": "{{time}}", + "every": "每 {{duration}}", + "cron": "Cron {{expr}}", + "cronWithTz": "Cron {{expr}} · {{tz}}", + "unknown": "自訂計畫" + }, + "next": { + "label": "下次:{{time}}", + "disabled": "已暫停", + "none": "沒有下次執行" + } }, "connection": { "idle": "閒置", From 5ae907bc2f83617122daef83648fc25ee0d20d29 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 12 Jun 2026 15:07:25 +0800 Subject: [PATCH 18/24] refactor: store cron origin delivery context --- nanobot/agent/tools/cron.py | 16 ++++++ nanobot/cli/commands.py | 8 +-- nanobot/cron/service.py | 22 +++++++++ nanobot/cron/session_delivery.py | 56 +++------------------ nanobot/cron/types.py | 9 ++-- tests/cli/test_commands.py | 19 ++++++++ tests/cron/test_cron_service.py | 40 +++++++++++++++ tests/cron/test_cron_tool_list.py | 10 +++- tests/cron/test_session_delivery.py | 75 ++++++++++++++--------------- tests/test_tool_contextvars.py | 12 +++++ 10 files changed, 171 insertions(+), 96 deletions(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 9587756c5..29ac52a9f 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -58,6 +58,12 @@ class CronTool(Tool, ContextAware): self._cron = cron_service self._default_timezone = default_timezone self._session_key: ContextVar[str] = ContextVar("cron_session_key", default="") + self._origin_channel: ContextVar[str] = ContextVar("cron_origin_channel", default="") + self._origin_chat_id: ContextVar[str] = ContextVar("cron_origin_chat_id", default="") + self._origin_metadata: ContextVar[dict[str, Any] | None] = ContextVar( + "cron_origin_metadata", + default=None, + ) self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False) @classmethod @@ -74,6 +80,9 @@ class CronTool(Tool, ContextAware): self._session_key.set( raw_key if ctx.session_key == UNIFIED_SESSION_KEY else (ctx.session_key or "") ) + self._origin_channel.set(ctx.channel or "") + self._origin_chat_id.set(ctx.chat_id or "") + self._origin_metadata.set(dict(ctx.metadata or {})) def set_cron_context(self, active: bool): """Mark whether the tool is executing inside a cron job callback.""" @@ -165,6 +174,10 @@ class CronTool(Tool, ContextAware): session_key = self._session_key.get() if not session_key: return "Error: scheduled cron jobs must be created from a chat session" + origin_channel = self._origin_channel.get() + origin_chat_id = self._origin_chat_id.get() + if not origin_channel or not origin_chat_id: + return "Error: scheduled cron jobs must be created from a chat session" if tz and not cron_expr: return "Error: tz can only be used with cron_expr" if tz: @@ -203,6 +216,9 @@ class CronTool(Tool, ContextAware): message=message, delete_after_run=delete_after, session_key=session_key, + origin_channel=origin_channel, + origin_chat_id=origin_chat_id, + origin_metadata=dict(self._origin_metadata.get() or {}), ) return f"Created job '{job.name}' (id: {job.id})" diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index ea6d6cdf2..99107e67a 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -980,7 +980,7 @@ def _run_gateway( from nanobot.bus.runtime_events import RuntimeEventBus from nanobot.channels.manager import ChannelManager from nanobot.cron.service import CronService - from nanobot.cron.session_delivery import bound_session_inbound_context + from nanobot.cron.session_delivery import origin_delivery_context from nanobot.cron.session_turns import ( CRON_DEFER_UNTIL_IDLE_META, CRON_TRIGGER_META, @@ -1046,12 +1046,12 @@ def _run_gateway( ) def _bound_session_delivery_context( - session_key: str, + job: CronJob, *, turn_seed: str, source_label: str | None, ) -> tuple[str, str, dict[str, Any]]: - channel, chat_id, metadata = bound_session_inbound_context(session_key) + channel, chat_id, metadata = origin_delivery_context(job) if channel == "websocket": metadata["webui"] = True @@ -1086,7 +1086,7 @@ def _run_gateway( prompt_ref = _cron_prompt_ref(prompt) run_id = f"{job.id}:{int(time.time() * 1000)}:{uuid.uuid4().hex[:8]}" channel, chat_id, metadata = _bound_session_delivery_context( - session_key, + job, turn_seed=f"cron:{job.id}", source_label=job.name, ) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 57c4dc204..68145893e 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -138,6 +138,19 @@ class CronService: or {} ), session_key=j["payload"].get("sessionKey") or j["payload"].get("session_key"), + origin_channel=( + j["payload"].get("originChannel") + or j["payload"].get("origin_channel") + ), + origin_chat_id=( + j["payload"].get("originChatId") + or j["payload"].get("origin_chat_id") + ), + origin_metadata=( + j["payload"].get("originMetadata") + or j["payload"].get("origin_metadata") + or {} + ), ), state=CronJobState( next_run_at_ms=j.get("state", {}).get("nextRunAtMs"), @@ -268,6 +281,9 @@ class CronService: "to": j.payload.to, "channelMeta": j.payload.channel_meta, "sessionKey": j.payload.session_key, + "originChannel": j.payload.origin_channel, + "originChatId": j.payload.origin_chat_id, + "originMetadata": j.payload.origin_metadata, }, "state": { "nextRunAtMs": j.state.next_run_at_ms, @@ -524,6 +540,9 @@ class CronService: delete_after_run: bool = False, channel_meta: dict | None = None, session_key: str | None = None, + origin_channel: str | None = None, + origin_chat_id: str | None = None, + origin_metadata: dict | None = None, ) -> CronJob: """Add a new job.""" _validate_schedule_for_add(schedule) @@ -542,6 +561,9 @@ class CronService: to=to, channel_meta=channel_meta or {}, session_key=session_key, + origin_channel=origin_channel, + origin_chat_id=origin_chat_id, + origin_metadata=origin_metadata or {}, ), state=CronJobState(next_run_at_ms=_compute_next_run(schedule, now)), created_at_ms=now, diff --git a/nanobot/cron/session_delivery.py b/nanobot/cron/session_delivery.py index 1d10bb890..15a65c41a 100644 --- a/nanobot/cron/session_delivery.py +++ b/nanobot/cron/session_delivery.py @@ -4,54 +4,12 @@ from __future__ import annotations from typing import Any +from nanobot.cron.types import CronJob -def bound_session_inbound_context(session_key: str) -> tuple[str, str, dict[str, Any]]: - """Return ``(channel, chat_id, metadata)`` for a bound cron session key.""" - if ":" not in session_key: - raise ValueError(f"bound cron session_key is invalid: {session_key!r}") - channel, rest = session_key.split(":", 1) - if not channel or not rest: - raise ValueError(f"bound cron session_key is invalid: {session_key!r}") - metadata: dict[str, Any] = {} - - if channel == "discord" and ":thread:" in rest: - parent_channel_id, thread_id = rest.split(":thread:", 1) - if parent_channel_id and thread_id: - metadata.update({ - "context_chat_id": parent_channel_id, - "parent_channel_id": parent_channel_id, - "thread_id": thread_id, - }) - return channel, thread_id, metadata - - if channel == "feishu" and ":" in rest: - chat_id, thread_id = rest.split(":", 1) - if chat_id and thread_id: - metadata.update({ - "chat_type": "group", - "message_id": thread_id, - "thread_id": thread_id, - }) - return channel, chat_id, metadata - - if channel == "slack" and ":" in rest: - chat_id, thread_ts = rest.split(":", 1) - if thread_ts: - metadata["slack"] = {"thread_ts": thread_ts} - return channel, chat_id, metadata - - if channel == "telegram" and ":topic:" in rest: - chat_id, thread_id = rest.split(":topic:", 1) - if thread_id: - metadata["message_thread_id"] = ( - int(thread_id) if thread_id.isdigit() else thread_id - ) - return channel, chat_id, metadata - - if channel == "dingtalk" and rest.startswith("group:"): - parts = rest.split(":", 2) - if len(parts) >= 2 and parts[1]: - return channel, f"group:{parts[1]}", metadata - - return channel, rest, metadata +def origin_delivery_context(job: CronJob) -> tuple[str, str, dict[str, Any]]: + """Return ``(channel, chat_id, metadata)`` for a session-bound cron job.""" + payload = job.payload + if not payload.origin_channel or not payload.origin_chat_id: + raise ValueError(f"cron job {job.id} is missing origin delivery context") + return payload.origin_channel, payload.origin_chat_id, dict(payload.origin_metadata or {}) diff --git a/nanobot/cron/types.py b/nanobot/cron/types.py index 24280da96..75e1a4fb3 100644 --- a/nanobot/cron/types.py +++ b/nanobot/cron/types.py @@ -1,7 +1,7 @@ """Cron types.""" from dataclasses import dataclass, field -from typing import Literal +from typing import Any, Literal @dataclass @@ -23,12 +23,15 @@ class CronPayload: """What to do when the job runs.""" kind: Literal["system_event", "agent_turn"] = "agent_turn" message: str = "" - # Deliver response to channel + # Legacy delivery fields used by pre-session-bound cron jobs. deliver: bool = False channel: str | None = None # e.g. "whatsapp" to: str | None = None # e.g. phone number - channel_meta: dict = field(default_factory=dict) # channel-specific routing (e.g. Slack thread_ts) + channel_meta: dict[str, Any] = field(default_factory=dict) session_key: str | None = None # original session key for correct session recording + origin_channel: str | None = None + origin_chat_id: str | None = None + origin_metadata: dict[str, Any] = field(default_factory=dict) @dataclass diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 84dd0d170..b204c1a36 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -1611,6 +1611,8 @@ def test_gateway_bound_cron_runs_as_session_turn( payload=CronPayload( message="Check repository health.", session_key="websocket:chat-1", + origin_channel="websocket", + origin_chat_id="chat-1", ), ) @@ -1646,6 +1648,13 @@ def test_gateway_bound_cron_runs_as_session_turn( 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", + }, ), ) @@ -1667,6 +1676,9 @@ def test_gateway_bound_cron_runs_as_session_turn( 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}, ), ) @@ -1686,6 +1698,13 @@ def test_gateway_bound_cron_runs_as_session_turn( 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", + }, ), ) diff --git a/tests/cron/test_cron_service.py b/tests/cron/test_cron_service.py index 98e37dc13..fc5194f22 100644 --- a/tests/cron/test_cron_service.py +++ b/tests/cron/test_cron_service.py @@ -87,6 +87,37 @@ def test_list_bound_agent_jobs_excludes_legacy_delivery_payloads(tmp_path) -> No assert service.list_bound_cron_jobs_for_session("websocket:chat-1") == [bound] +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 async def test_channel_meta_and_session_key_survive_store_reload(tmp_path) -> None: store_path = tmp_path / "cron" / "jobs.json" @@ -103,6 +134,9 @@ 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() @@ -111,11 +145,17 @@ async def test_channel_meta_and_session_key_survive_store_reload(tmp_path) -> No payload = raw["jobs"][0]["payload"] assert payload["channelMeta"] == meta 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.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 diff --git a/tests/cron/test_cron_tool_list.py b/tests/cron/test_cron_tool_list.py index 4af4de13a..bcf518ed6 100644 --- a/tests/cron/test_cron_tool_list.py +++ b/tests/cron/test_cron_tool_list.py @@ -339,6 +339,9 @@ def test_add_job_binds_current_session_key(tmp_path) -> None: assert result.startswith("Created job") job = tool._cron.list_jobs()[0] 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 @@ -392,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_only_session_key(tmp_path) -> None: - """CronTool stores the canonical session key without legacy delivery fields.""" +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( @@ -406,6 +409,9 @@ def test_add_job_captures_only_session_key(tmp_path) -> None: jobs = tool._cron.list_jobs() assert len(jobs) == 1 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 == {} diff --git a/tests/cron/test_session_delivery.py b/tests/cron/test_session_delivery.py index 02948a3fa..f20207a4b 100644 --- a/tests/cron/test_session_delivery.py +++ b/tests/cron/test_session_delivery.py @@ -1,45 +1,44 @@ import pytest -from nanobot.cron.session_delivery import bound_session_inbound_context +from nanobot.cron.session_delivery import origin_delivery_context +from nanobot.cron.types import CronJob, CronPayload -@pytest.mark.parametrize( - ("session_key", "expected"), - [ - ("websocket:chat-1", ("websocket", "chat-1", {})), - ( - "discord:456:thread:777", - ( - "discord", - "777", - { - "context_chat_id": "456", - "parent_channel_id": "456", - "thread_id": "777", - }, - ), +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, ), - ( - "feishu:oc_abc:om_root123", - ( - "feishu", - "oc_abc", - { - "chat_type": "group", - "message_id": "om_root123", - "thread_id": "om_root123", - }, - ), + ) + + 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", ), - ("slack:C123:1700.42", ("slack", "C123", {"slack": {"thread_ts": "1700.42"}})), - ("telegram:-100123:topic:42", ("telegram", "-100123", {"message_thread_id": 42})), - ("dingtalk:group:conv-1:user-1", ("dingtalk", "group:conv-1", {})), - ], -) -def test_bound_session_inbound_context(session_key, expected) -> None: - assert bound_session_inbound_context(session_key) == expected + ) - -def test_bound_session_inbound_context_rejects_invalid_key() -> None: - with pytest.raises(ValueError): - bound_session_inbound_context("unified") + with pytest.raises(ValueError, match="missing origin delivery context"): + origin_delivery_context(job) diff --git a/tests/test_tool_contextvars.py b/tests/test_tool_contextvars.py index ea9b4753e..4300ff901 100644 --- a/tests/test_tool_contextvars.py +++ b/tests/test_tool_contextvars.py @@ -123,6 +123,10 @@ async def test_cron_tool_keeps_task_local_context(tmp_path) -> None: jobs = tool._cron.list_jobs() 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 --- @@ -243,6 +247,8 @@ async def test_cron_tool_basic_set_context_and_execute(tmp_path) -> None: jobs = tool._cron.list_jobs() assert len(jobs) == 1 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 @@ -272,6 +278,9 @@ async def test_webui_cron_tool_uses_origin_session_when_unified_enabled(tmp_path 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 @@ -293,6 +302,9 @@ async def test_cron_tool_preserves_thread_scoped_session_key(tmp_path) -> None: 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 From af8192dc387b5faa1b53fe904ae8fb809518c70d Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 12 Jun 2026 15:50:36 +0800 Subject: [PATCH 19/24] refactor: move bound cron execution out of gateway --- nanobot/agent/cron_turns.py | 24 +++++ nanobot/agent/loop.py | 27 ++---- nanobot/cli/commands.py | 156 ++------------------------------- nanobot/cron/bound_runner.py | 151 +++++++++++++++++++++++++++++++ nanobot/cron/session_turns.py | 20 +++++ nanobot/cron/webui_metadata.py | 27 ++++++ tests/cli/test_commands.py | 10 ++- 7 files changed, 242 insertions(+), 173 deletions(-) create mode 100644 nanobot/cron/bound_runner.py create mode 100644 nanobot/cron/webui_metadata.py diff --git a/nanobot/agent/cron_turns.py b/nanobot/agent/cron_turns.py index 54c34095e..da456ba48 100644 --- a/nanobot/agent/cron_turns.py +++ b/nanobot/agent/cron_turns.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import dataclasses from collections.abc import Awaitable, Callable, Iterable from nanobot.bus.events import InboundMessage, OutboundMessage @@ -57,6 +58,29 @@ class CronTurnCoordinator: and session_key in active_session_keys ) + def defer_if_active( + self, + msg: InboundMessage, + *, + session_key: str, + active_session_keys: Iterable[str], + ) -> bool: + """Defer a cron turn when its target session is already active.""" + if not self.should_defer( + msg, + session_key=session_key, + active_session_keys=active_session_keys, + ): + return False + pending_msg = msg + if session_key != msg.session_key: + pending_msg = dataclasses.replace( + msg, + session_key_override=session_key, + ) + self.defer(session_key, pending_msg) + return True + def complete( self, msg: InboundMessage, diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index daf5dd35e..576848139 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -41,8 +41,7 @@ from nanobot.bus.runtime_events import ( from nanobot.command import CommandContext, CommandRouter, register_builtin_commands from nanobot.config.schema import AgentDefaults, ModelPresetConfig from nanobot.cron.session_turns import ( - CRON_HISTORY_META, - cron_trigger, + cron_history_overrides, ) from nanobot.providers.base import LLMProvider from nanobot.providers.factory import ProviderSnapshot @@ -593,17 +592,10 @@ class AgentLoop: extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata) extra.update(kwargs) text = msg.content if isinstance(msg.content, str) else "" - if trigger := cron_trigger(msg.metadata): - persist_content = trigger.get("persist_content") - if isinstance(persist_content, str) and persist_content.strip(): - text = persist_content - extra.update({ - CRON_HISTORY_META: True, - "cron_job_id": trigger.get("job_id"), - "cron_job_name": trigger.get("job_name"), - "cron_run_id": trigger.get("run_id"), - "cron_prompt_ref": trigger.get("prompt_ref"), - }) + text_override, cron_extra = cron_history_overrides(msg.metadata) + if text_override is not None: + text = text_override + extra.update(cron_extra) session.add_message("user", text, **extra) self._mark_pending_user_turn(session) self.sessions.save(session) @@ -904,18 +896,11 @@ class AgentLoop: self.commands.dispatch_priority, ) continue - if self._cron_turns.should_defer( + if self._cron_turns.defer_if_active( msg, session_key=effective_key, active_session_keys=self._pending_queues.keys(), ): - pending_msg = msg - if effective_key != msg.session_key: - pending_msg = dataclasses.replace( - msg, - session_key_override=effective_key, - ) - self._cron_turns.defer(effective_key, pending_msg) logger.info( "Deferred cron turn for active session {}", effective_key, diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 99107e67a..ba3f7f89b 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1,13 +1,10 @@ """CLI commands for nanobot.""" import asyncio -import hashlib import os import select import signal import sys -import time -import uuid from collections.abc import Callable from contextlib import nullcontext, suppress from contextvars import ContextVar @@ -57,6 +54,7 @@ from nanobot.agent.loop import AgentLoop # noqa: E402 from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402 from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402 from nanobot.config.schema import Config # noqa: E402 +from nanobot.cron.webui_metadata import cron_proactive_delivery_metadata # noqa: E402 from nanobot.utils.evaluator import evaluate_response # noqa: E402 from nanobot.utils.helpers import sync_workspace_templates # noqa: E402 from nanobot.utils.restart import ( # noqa: E402 @@ -64,10 +62,6 @@ from nanobot.utils.restart import ( # noqa: E402 format_restart_completed_message, should_show_cli_restart_notice, ) -from nanobot.webui.metadata import ( # noqa: E402 - WEBUI_MESSAGE_SOURCE_METADATA_KEY, - WEBUI_TURN_METADATA_KEY, -) def _sanitize_surrogates(text: str) -> str: @@ -99,24 +93,6 @@ _PROACTIVE_WEBUI_METADATA: ContextVar[dict[str, Any] | None] = ContextVar( ) -def _proactive_delivery_metadata( - channel: str, - metadata: dict[str, Any] | None, - *, - turn_seed: str, - source_label: str | None = None, -) -> dict[str, Any]: - """Return channel metadata for a fresh proactive delivery turn.""" - out = dict(metadata or {}) - out.pop(WEBUI_TURN_METADATA_KEY, None) - if channel == "websocket": - out[WEBUI_TURN_METADATA_KEY] = f"{turn_seed}:{uuid.uuid4().hex}" - source: dict[str, str] = {"kind": "cron"} - if source_label: - source["label"] = source_label - out[WEBUI_MESSAGE_SOURCE_METADATA_KEY] = source - return out - app = typer.Typer( name="nanobot", context_settings={"help_option_names": ["-h", "--help"]}, @@ -979,19 +955,14 @@ def _run_gateway( from nanobot.bus.queue import MessageBus from nanobot.bus.runtime_events import RuntimeEventBus from nanobot.channels.manager import ChannelManager + from nanobot.cron.bound_runner import run_bound_cron_job from nanobot.cron.service import CronService - from nanobot.cron.session_delivery import origin_delivery_context - from nanobot.cron.session_turns import ( - CRON_DEFER_UNTIL_IDLE_META, - CRON_TRIGGER_META, - is_bound_cron_job, - ) + from nanobot.cron.session_turns import is_bound_cron_job from nanobot.cron.types import CronJob from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.session.manager import SessionManager from nanobot.session.webui_turns import WebuiTurnCoordinator - from nanobot.utils.prompt_templates import render_template from nanobot.webui.token_usage import TokenUsageHook port = port if port is not None else config.gateway.port @@ -1035,7 +1006,7 @@ def _run_gateway( schedule_background=lambda coro: agent._schedule_background(coro), ).subscribe(runtime_events) - from nanobot.bus.events import InboundMessage, OutboundMessage + from nanobot.bus.events import OutboundMessage from nanobot.session.keys import session_key_for_channel def _channel_session_key(channel: str, chat_id: str) -> str: @@ -1045,119 +1016,6 @@ def _run_gateway( unified_session=config.agents.defaults.unified_session, ) - def _bound_session_delivery_context( - job: CronJob, - *, - turn_seed: str, - source_label: str | None, - ) -> tuple[str, str, dict[str, Any]]: - channel, chat_id, metadata = origin_delivery_context(job) - - if channel == "websocket": - metadata["webui"] = True - metadata.update( - _proactive_delivery_metadata( - "websocket", - metadata, - turn_seed=turn_seed, - source_label=source_label, - ) - ) - - return channel, chat_id, metadata - - def _cron_prompt_ref(prompt: str) -> dict[str, Any]: - return { - "id": "cron.agent_turn.reminder", - "version": 1, - "sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(), - } - - async def _run_bound_cron_job(job: CronJob) -> str | None: - session_key = job.payload.session_key - if not session_key: - raise ValueError(f"cron job {job.id} is missing payload.session_key") - - prompt = render_template( - "agent/cron_reminder.md", - strip=True, - message=job.payload.message, - ) - prompt_ref = _cron_prompt_ref(prompt) - run_id = f"{job.id}:{int(time.time() * 1000)}:{uuid.uuid4().hex[:8]}" - channel, chat_id, metadata = _bound_session_delivery_context( - job, - turn_seed=f"cron:{job.id}", - source_label=job.name, - ) - metadata[CRON_TRIGGER_META] = { - "job_id": job.id, - "job_name": job.name, - "run_id": run_id, - "prompt_ref": prompt_ref, - "persist_content": ( - f"Scheduled cron job triggered: {job.name}\n\n{job.payload.message}" - ), - } - metadata[CRON_DEFER_UNTIL_IDLE_META] = True - run_record_base: dict[str, Any] = { - "job_id": job.id, - "job_name": job.name, - "session_key": session_key, - "prompt_ref": prompt_ref, - "prompt_vars": {"message": job.payload.message}, - "rendered_prompt": prompt, - } - - cron.write_run_record( - run_id, - { - **run_record_base, - "status": "queued", - }, - ) - - cron_tool = agent.tools.get("cron") - cron_token = None - if isinstance(cron_tool, CronTool): - cron_token = cron_tool.set_cron_context(True) - try: - resp = await agent.submit_cron_turn( - InboundMessage( - channel=channel, - sender_id="cron", - chat_id=chat_id, - content=prompt, - metadata=metadata, - session_key_override=session_key, - ) - ) - except (Exception, asyncio.CancelledError) as exc: - error_text = str(exc) or exc.__class__.__name__ - cron.write_run_record( - run_id, - { - **run_record_base, - "status": "error", - "error": error_text, - }, - ) - raise - finally: - if isinstance(cron_tool, CronTool) and cron_token is not None: - cron_tool.reset_cron_context(cron_token) - - response = resp.content if resp else "" - cron.write_run_record( - run_id, - { - **run_record_base, - "status": "ok", - "response": response, - }, - ) - return response - async def _deliver_to_channel( msg: OutboundMessage, *, record: bool = False, session_key: str | None = None, ) -> None: @@ -1319,7 +1177,7 @@ def _run_gateway( return response if is_bound_cron_job(job): - return await _run_bound_cron_job(job) + return await run_bound_cron_job(job, agent=agent, cron=cron) reminder_note = ( "The scheduled time has arrived. Deliver this reminder to the user now, " @@ -1338,7 +1196,7 @@ def _run_gateway( if isinstance(message_tool, MessageTool): message_record_token = message_tool.set_record_channel_delivery(True) - proactive_webui_metadata = _proactive_delivery_metadata( + proactive_webui_metadata = cron_proactive_delivery_metadata( "websocket", None, turn_seed=f"cron:{job.id}", @@ -1371,7 +1229,7 @@ def _run_gateway( response, reminder_note, agent.provider, agent.model, ) if should_notify: - proactive_metadata = _proactive_delivery_metadata( + proactive_metadata = cron_proactive_delivery_metadata( job.payload.channel or "cli", job.payload.channel_meta, turn_seed=f"cron:{job.id}", diff --git a/nanobot/cron/bound_runner.py b/nanobot/cron/bound_runner.py new file mode 100644 index 000000000..0dfd901ae --- /dev/null +++ b/nanobot/cron/bound_runner.py @@ -0,0 +1,151 @@ +"""Execution helpers for session-bound cron jobs.""" + +from __future__ import annotations + +import asyncio +import hashlib +import time +import uuid +from typing import Any, Protocol + +from nanobot.agent.tools.cron import CronTool +from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.cron.session_delivery import origin_delivery_context +from nanobot.cron.session_turns import CRON_DEFER_UNTIL_IDLE_META, CRON_TRIGGER_META +from nanobot.cron.types import CronJob +from nanobot.cron.webui_metadata import cron_proactive_delivery_metadata +from nanobot.utils.prompt_templates import render_template + + +class BoundCronAgent(Protocol): + tools: Any + + async def submit_cron_turn(self, msg: InboundMessage) -> OutboundMessage | None: + ... + + +class CronRunRecorder(Protocol): + def write_run_record(self, run_id: str, record: dict[str, Any]) -> None: + ... + + +def _cron_prompt_ref(prompt: str) -> dict[str, Any]: + return { + "id": "cron.agent_turn.reminder", + "version": 1, + "sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(), + } + + +def _bound_session_delivery_context( + job: CronJob, + *, + turn_seed: str, + source_label: str | None, +) -> tuple[str, str, dict[str, Any]]: + channel, chat_id, metadata = origin_delivery_context(job) + + if channel == "websocket": + metadata["webui"] = True + metadata.update( + cron_proactive_delivery_metadata( + "websocket", + metadata, + turn_seed=turn_seed, + source_label=source_label, + ) + ) + + return channel, chat_id, metadata + + +async def run_bound_cron_job( + job: CronJob, + *, + agent: BoundCronAgent, + cron: CronRunRecorder, +) -> str | None: + """Execute a session-bound cron job as a normal agent session turn.""" + session_key = job.payload.session_key + if not session_key: + raise ValueError(f"cron job {job.id} is missing payload.session_key") + + prompt = render_template( + "agent/cron_reminder.md", + strip=True, + message=job.payload.message, + ) + prompt_ref = _cron_prompt_ref(prompt) + run_id = f"{job.id}:{int(time.time() * 1000)}:{uuid.uuid4().hex[:8]}" + channel, chat_id, metadata = _bound_session_delivery_context( + job, + turn_seed=f"cron:{job.id}", + source_label=job.name, + ) + metadata[CRON_TRIGGER_META] = { + "job_id": job.id, + "job_name": job.name, + "run_id": run_id, + "prompt_ref": prompt_ref, + "persist_content": ( + f"Scheduled cron job triggered: {job.name}\n\n{job.payload.message}" + ), + } + metadata[CRON_DEFER_UNTIL_IDLE_META] = True + run_record_base: dict[str, Any] = { + "job_id": job.id, + "job_name": job.name, + "session_key": session_key, + "prompt_ref": prompt_ref, + "prompt_vars": {"message": job.payload.message}, + "rendered_prompt": prompt, + } + + cron.write_run_record( + run_id, + { + **run_record_base, + "status": "queued", + }, + ) + + cron_tool = agent.tools.get("cron") + cron_token = None + if isinstance(cron_tool, CronTool): + cron_token = cron_tool.set_cron_context(True) + try: + resp = await agent.submit_cron_turn( + InboundMessage( + channel=channel, + sender_id="cron", + chat_id=chat_id, + content=prompt, + metadata=metadata, + session_key_override=session_key, + ) + ) + except (Exception, asyncio.CancelledError) as exc: + error_text = str(exc) or exc.__class__.__name__ + cron.write_run_record( + run_id, + { + **run_record_base, + "status": "error", + "error": error_text, + }, + ) + raise + finally: + if isinstance(cron_tool, CronTool) and cron_token is not None: + cron_tool.reset_cron_context(cron_token) + + response = resp.content if resp else "" + cron.write_run_record( + run_id, + { + **run_record_base, + "status": "ok", + "response": response, + }, + ) + return response diff --git a/nanobot/cron/session_turns.py b/nanobot/cron/session_turns.py index 7a55e36ef..49662e1e8 100644 --- a/nanobot/cron/session_turns.py +++ b/nanobot/cron/session_turns.py @@ -36,6 +36,26 @@ def cron_run_id(metadata: Mapping[str, Any] | None) -> str | None: return value if isinstance(value, str) and value else None +def cron_history_overrides(metadata: Mapping[str, Any] | None) -> tuple[str | None, dict[str, Any]]: + """Return session-history text/metadata overrides for a cron turn.""" + trigger = cron_trigger(metadata) + if not trigger: + return None, {} + persist_content = trigger.get("persist_content") + text = ( + persist_content + if isinstance(persist_content, str) and persist_content.strip() + else None + ) + return text, { + CRON_HISTORY_META: True, + "cron_job_id": trigger.get("job_id"), + "cron_job_name": trigger.get("job_name"), + "cron_run_id": trigger.get("run_id"), + "cron_prompt_ref": trigger.get("prompt_ref"), + } + + def is_bound_cron_job(job: CronJob) -> bool: """True for new session-bound cron jobs, excluding legacy delivery payloads.""" payload = job.payload diff --git a/nanobot/cron/webui_metadata.py b/nanobot/cron/webui_metadata.py new file mode 100644 index 000000000..7dc1d7631 --- /dev/null +++ b/nanobot/cron/webui_metadata.py @@ -0,0 +1,27 @@ +"""WebUI metadata helpers for cron deliveries.""" + +from __future__ import annotations + +import uuid +from typing import Any + +from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY + + +def cron_proactive_delivery_metadata( + channel: str, + metadata: dict[str, Any] | None, + *, + turn_seed: str, + source_label: str | None = None, +) -> dict[str, Any]: + """Return channel metadata for a fresh proactive cron delivery turn.""" + out = dict(metadata or {}) + out.pop(WEBUI_TURN_METADATA_KEY, None) + if channel == "websocket": + out[WEBUI_TURN_METADATA_KEY] = f"{turn_seed}:{uuid.uuid4().hex}" + source: dict[str, str] = {"kind": "cron"} + if source_label: + source["label"] = source_label + out[WEBUI_MESSAGE_SOURCE_METADATA_KEY] = source + return out diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index b204c1a36..52095cb46 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -9,14 +9,18 @@ import pytest from typer.testing import CliRunner from nanobot.bus.events import InboundMessage, OutboundMessage -from nanobot.cli.commands import _proactive_delivery_metadata, app +from nanobot.cli.commands import app from nanobot.config.schema import Config 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 +from nanobot.webui.metadata import ( + WEBUI_MESSAGE_SOURCE_METADATA_KEY, + WEBUI_TURN_METADATA_KEY, +) runner = CliRunner() @@ -28,7 +32,7 @@ def test_proactive_websocket_delivery_gets_fresh_turn_id() -> None: "workspace_scope": {"mode": "default"}, } - out = _proactive_delivery_metadata( + out = cron_proactive_delivery_metadata( "websocket", metadata, turn_seed="cron:drink-water", From 8335554894f8167e80821c13ca1e15f7c0efffa7 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 12 Jun 2026 16:51:20 +0800 Subject: [PATCH 20/24] refactor: migrate legacy cron payloads to bound sessions --- nanobot/cli/commands.py | 83 +---- nanobot/cron/service.py | 65 +++- nanobot/cron/session_turns.py | 9 +- tests/channels/test_websocket_http_routes.py | 20 +- tests/cli/test_commands.py | 333 +------------------ tests/cron/test_cron_service.py | 131 ++++++-- 6 files changed, 210 insertions(+), 431 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index ba3f7f89b..bd40dd2aa 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -7,7 +7,6 @@ import signal import sys from collections.abc import Callable from contextlib import nullcontext, suppress -from contextvars import ContextVar from pathlib import Path from typing import Any @@ -54,7 +53,6 @@ from nanobot.agent.loop import AgentLoop # noqa: E402 from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402 from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402 from nanobot.config.schema import Config # noqa: E402 -from nanobot.cron.webui_metadata import cron_proactive_delivery_metadata # noqa: E402 from nanobot.utils.evaluator import evaluate_response # noqa: E402 from nanobot.utils.helpers import sync_workspace_templates # noqa: E402 from nanobot.utils.restart import ( # noqa: E402 @@ -87,12 +85,6 @@ class SafeFileHistory(FileHistory): super().store_string(_sanitize_surrogates(string)) -_PROACTIVE_WEBUI_METADATA: ContextVar[dict[str, Any] | None] = ContextVar( - "proactive_webui_metadata", - default=None, -) - - app = typer.Typer( name="nanobot", context_settings={"help_option_names": ["-h", "--help"]}, @@ -950,7 +942,6 @@ def _run_gateway( health_server_enabled: bool = True, ) -> None: """Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up.""" - from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.message import MessageTool from nanobot.bus.queue import MessageBus from nanobot.bus.runtime_events import RuntimeEventBus @@ -1022,9 +1013,6 @@ def _run_gateway( """Publish a user-visible message and mirror it into that channel's session.""" metadata = dict(msg.metadata or {}) record = record or bool(metadata.pop("_record_channel_delivery", False)) - proactive_webui_metadata = _PROACTIVE_WEBUI_METADATA.get() - if record and msg.channel == "websocket" and proactive_webui_metadata: - metadata = {**metadata, **proactive_webui_metadata} if metadata != (msg.metadata or {}): msg = OutboundMessage( channel=msg.channel, @@ -1179,73 +1167,12 @@ def _run_gateway( if is_bound_cron_job(job): return await run_bound_cron_job(job, agent=agent, cron=cron) - reminder_note = ( - "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" - f"Reminder: {job.payload.message}" + logger.warning( + "Cron: skipped unbound agent job '{}' ({}); recreate it from a chat session", + job.name, + job.id, ) - - cron_tool = agent.tools.get("cron") - cron_token = None - if isinstance(cron_tool, CronTool): - cron_token = cron_tool.set_cron_context(True) - - message_record_token = None - if isinstance(message_tool, MessageTool): - message_record_token = message_tool.set_record_channel_delivery(True) - - proactive_webui_metadata = cron_proactive_delivery_metadata( - "websocket", - None, - turn_seed=f"cron:{job.id}", - source_label=job.name, - ) - proactive_token = _PROACTIVE_WEBUI_METADATA.set(proactive_webui_metadata) - - try: - resp = await agent.process_direct( - reminder_note, - session_key=f"cron:{job.id}", - channel=job.payload.channel or "cli", - chat_id=job.payload.to or "direct", - on_progress=_silent, - ) - finally: - _PROACTIVE_WEBUI_METADATA.reset(proactive_token) - if isinstance(cron_tool, CronTool) and cron_token is not None: - cron_tool.reset_cron_context(cron_token) - if isinstance(message_tool, MessageTool) and message_record_token is not None: - message_tool.reset_record_channel_delivery(message_record_token) - - response = resp.content if resp else "" - - if job.payload.deliver and isinstance(message_tool, MessageTool) and message_tool._sent_in_turn: - return response - - if job.payload.deliver and job.payload.to and response: - should_notify = await evaluate_response( - response, reminder_note, agent.provider, agent.model, - ) - if should_notify: - proactive_metadata = cron_proactive_delivery_metadata( - job.payload.channel or "cli", - job.payload.channel_meta, - turn_seed=f"cron:{job.id}", - source_label=job.name, - ) - await _deliver_to_channel( - OutboundMessage( - channel=job.payload.channel or "cli", - chat_id=job.payload.to, - content=response, - metadata=proactive_metadata, - ), - record=True, - session_key=job.payload.session_key, - ) - return response + return None cron.on_job = on_cron_job diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 68145893e..1a66af007 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -72,6 +72,62 @@ def _validate_schedule_for_add(schedule: CronSchedule) -> None: raise ValueError(f"unknown timezone '{schedule.tz}'") from None +def _has_legacy_delivery_context(payload: CronPayload) -> bool: + return bool(payload.deliver or payload.channel or payload.to or payload.channel_meta) + + +def _legacy_session_key(payload: CronPayload) -> str | None: + if payload.session_key: + return payload.session_key + if payload.channel and payload.to: + return f"{payload.channel}:{payload.to}" + return None + + +def _disable_malformed_legacy_job(job: CronJob) -> None: + reason = "legacy cron payload is missing channel/to; recreate it from a chat session" + job.payload.deliver = False + job.payload.channel = None + job.payload.to = None + job.payload.channel_meta = {} + job.enabled = False + job.state.next_run_at_ms = None + job.state.last_status = "error" + job.state.last_error = reason + logger.warning("Cron: disabled malformed legacy job '{}' ({}): {}", job.name, job.id, reason) + + +def _normalize_agent_turn_job(job: CronJob) -> bool: + """Migrate legacy user cron payloads into session-bound payloads. + + Pre-bound user cron jobs stored their delivery target in ``channel``/``to``. + Normal user-created legacy jobs always have those fields; if they are + missing, keep the record for inspection but disable it instead of preserving + a runtime legacy execution path. + """ + payload = job.payload + if payload.kind != "agent_turn" or not _has_legacy_delivery_context(payload): + return False + + if not payload.channel or not payload.to: + _disable_malformed_legacy_job(job) + return True + + payload.session_key = _legacy_session_key(payload) + payload.origin_channel = payload.origin_channel or payload.channel + payload.origin_chat_id = payload.origin_chat_id or payload.to + if not payload.origin_metadata: + payload.origin_metadata = dict(payload.channel_meta or {}) + + payload.deliver = False + payload.channel = None + payload.to = None + payload.channel_meta = {} + job.updated_at_ms = max(job.updated_at_ms, _now_ms()) + logger.info("Cron: migrated legacy job '{}' ({}) to session-bound payload", job.name, job.id) + return True + + class CronService: """Service for managing and executing scheduled jobs.""" @@ -115,7 +171,7 @@ class CronService: jobs = [] version = data.get("version", 1) for j in data.get("jobs", []): - jobs.append(CronJob( + job = CronJob( id=j["id"], name=j["name"], enabled=j.get("enabled", True), @@ -170,7 +226,9 @@ class CronService: created_at_ms=j.get("createdAtMs", 0), updated_at_ms=j.get("updatedAtMs", 0), delete_after_run=j.get("deleteAfterRun", False), - )) + ) + _normalize_agent_turn_job(job) + jobs.append(job) except Exception: # Preserve the corrupt file for forensic recovery instead of # letting the next save overwrite it with an empty job list. @@ -196,6 +254,7 @@ class CronService: jobs_map = {j.id: j for j in self._store.jobs} def _update(params: dict): j = CronJob.from_dict(params) + _normalize_agent_turn_job(j) jobs_map[j.id] = j def _del(params: dict): @@ -570,6 +629,7 @@ class CronService: updated_at_ms=now, delete_after_run=delete_after_run, ) + _normalize_agent_turn_job(job) if self._running: store = self._load_store() store.jobs.append(job) @@ -678,6 +738,7 @@ class CronService: job.payload.to = to if delete_after_run is not None: job.delete_after_run = delete_after_run + _normalize_agent_turn_job(job) job.updated_at_ms = _now_ms() if job.enabled: diff --git a/nanobot/cron/session_turns.py b/nanobot/cron/session_turns.py index 49662e1e8..a85c47937 100644 --- a/nanobot/cron/session_turns.py +++ b/nanobot/cron/session_turns.py @@ -57,9 +57,14 @@ def cron_history_overrides(metadata: Mapping[str, Any] | None) -> tuple[str | No def is_bound_cron_job(job: CronJob) -> bool: - """True for new session-bound cron jobs, excluding legacy delivery payloads.""" + """True for session-bound cron jobs with complete delivery context.""" payload = job.payload - if payload.kind != "agent_turn" or not payload.session_key: + if ( + payload.kind != "agent_turn" + or not payload.session_key + or not payload.origin_channel + or not payload.origin_chat_id + ): return False return not ( payload.deliver diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index d8c137630..c364f5a36 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -186,11 +186,13 @@ async def test_session_automations_route_filters_by_webui_session( schedule=hourly, message=message, session_key=f"websocket:{to}", + origin_channel="websocket", + origin_chat_id=to, ) cron.add_job( name="Legacy same target", schedule=hourly, - message="Legacy job should not be treated as bound", + message="Legacy job should be migrated", deliver=True, channel="websocket", to="abc", @@ -228,7 +230,7 @@ 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 @@ -249,12 +251,16 @@ async def test_session_automations_route_ignores_unified_owner( 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, @@ -728,6 +734,8 @@ async def test_session_delete_blocks_when_bound_automation_exists( 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()) @@ -767,6 +775,8 @@ async def test_session_delete_can_cascade_bound_automations( 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", @@ -793,9 +803,7 @@ async def test_session_delete_can_cascade_bound_automations( assert resp.json()["deleted"] is True assert not path.exists() 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" - ] + assert cron.list_jobs(include_disabled=True) == [] finally: await channel.stop() await server_task @@ -813,6 +821,8 @@ async def test_session_delete_blocks_origin_automation_when_unified_enabled( 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, diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 52095cb46..abbd28427 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -1185,7 +1185,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" @@ -1250,11 +1250,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 @@ -1270,16 +1269,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) @@ -1314,214 +1307,9 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context( response = 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_METADATA_KEY: old_turn_id, - "workspace_scope": {"mode": "default"}, - }, - ), - ) - - 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_METADATA_KEY].startswith("cron:drink-water:") - assert delivered.metadata[WEBUI_TURN_METADATA_KEY] != old_turn_id - assert delivered.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == { - "kind": "cron", - "label": "drink water", - } - - -def test_gateway_legacy_cron_payloads_with_session_key_stay_legacy( - monkeypatch, tmp_path: Path -) -> None: - config_file = _write_instance_config(tmp_path) - config = Config() - config.agents.defaults.workspace = str(tmp_path / "config-workspace") - bus = MagicMock() - bus.publish_outbound = AsyncMock() - seen: dict[str, object] = {"process_calls": [], "evaluations": [], "saved_keys": []} - - class _FakeSession: - def __init__(self) -> None: - self.messages = [] - - def add_message(self, role: str, content: str, **kwargs) -> None: - self.messages.append({"role": role, "content": content, **kwargs}) - - class _FakeSessionManager: - def __init__(self, _workspace: Path) -> None: - self.session = _FakeSession() - seen["session_manager"] = self - - def read_session_file(self, _key: str) -> dict[str, object]: - return {"metadata": {}} - - def get_or_create(self, key: str) -> _FakeSession: - seen["saved_keys"].append(key) - return self.session - - def save(self, session: _FakeSession) -> None: - seen["saved_session"] = session - - class _FakeCron: - def __init__(self, _store_path: Path) -> None: - self.on_job = None - seen["cron"] = self - - 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 = kwargs.get("provider", object()) - self.tools = {} - - async def process_direct(self, prompt: str, **kwargs): - seen["process_calls"].append((prompt, kwargs)) - return OutboundMessage( - channel=kwargs["channel"], - chat_id=kwargs["chat_id"], - content="Legacy response.", - ) - - 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 - - async def run(self) -> None: - return None - - def stop(self) -> None: - return None - - class _StopAfterCronSetup: - def __init__(self, *_args, **_kwargs) -> None: - raise _StopGatewayError("stop") - - async def _capture_evaluate_response(*args, **_kwargs) -> bool: - seen["evaluations"].append(args) - return True - - _patch_cli_command_runtime( - monkeypatch, - config, - message_bus=lambda: bus, - session_manager=_FakeSessionManager, - cron_service=_FakeCron, - ) - monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop) - monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup) - monkeypatch.setattr( - "nanobot.cli.commands.evaluate_response", - _capture_evaluate_response, - ) - - result = runner.invoke(app, ["gateway", "--config", str(config_file)]) - assert isinstance(result.exception, _StopGatewayError) - cron = seen["cron"] - - silent_job = CronJob( - id="silent-legacy", - name="Silent legacy", - payload=CronPayload( - message="Run silently.", - deliver=False, - channel="telegram", - to="user-1", - session_key="telegram:user-1", - ), - ) - - response = asyncio.run(cron.on_job(silent_job)) - - assert response == "Legacy response." - prompt, kwargs = seen["process_calls"][-1] - assert "Reminder: Run silently." in prompt - assert kwargs["session_key"] == "cron:silent-legacy" - assert kwargs["channel"] == "telegram" - assert kwargs["chat_id"] == "user-1" - assert seen["evaluations"] == [] + assert response is None bus.publish_outbound.assert_not_awaited() - topic_job = CronJob( - id="topic-legacy", - name="Topic legacy", - payload=CronPayload( - message="Ping the topic.", - deliver=True, - channel="telegram", - to="-100123", - channel_meta={"message_thread_id": 42}, - session_key="telegram:-100123:topic:42", - ), - ) - - response = asyncio.run(cron.on_job(topic_job)) - - assert response == "Legacy response." - _prompt, kwargs = seen["process_calls"][-1] - assert kwargs["session_key"] == "cron:topic-legacy" - assert kwargs["channel"] == "telegram" - assert kwargs["chat_id"] == "-100123" - assert len(seen["evaluations"]) == 1 - bus.publish_outbound.assert_awaited_once() - delivered = bus.publish_outbound.await_args.args[0] - assert delivered.channel == "telegram" - assert delivered.chat_id == "-100123" - assert delivered.metadata["message_thread_id"] == 42 - assert seen["saved_keys"] == ["telegram:-100123:topic:42"] - def test_gateway_bound_cron_runs_as_session_turn( monkeypatch, tmp_path: Path @@ -1724,109 +1512,6 @@ def test_gateway_bound_cron_runs_as_session_turn( assert msg.metadata["thread_id"] == "om_root123" -def test_gateway_cron_job_suppresses_intermediate_progress( - 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") - bus = MagicMock() - bus.publish_outbound = AsyncMock() - seen: dict[str, object] = {} - - 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.build_provider_snapshot", - lambda _config: _test_provider_snapshot(object(), _config), - ) - monkeypatch.setattr( - "nanobot.providers.factory.load_provider_snapshot", - lambda _config_path=None: _test_provider_snapshot(object(), config), - ) - monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus) - monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object()) - - class _FakeCron: - def __init__(self, _store_path: Path) -> None: - self.on_job = None - seen["cron"] = self - - 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.tools = {} - - async def process_direct(self, *_args, on_progress=None, **_kwargs): - seen["on_progress"] = on_progress - return OutboundMessage( - channel="telegram", - chat_id="user-1", - content="Done.", - ) - - async def close_mcp(self) -> None: - return None - - async def run(self) -> None: - return None - - def stop(self) -> None: - return None - - class _StopAfterCronSetup: - def __init__(self, *_args, **_kwargs) -> None: - raise _StopGatewayError("stop") - - async def _always_reject(*_args, **_kwargs) -> bool: - return False - - 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, - ) - - 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", - payload=CronPayload( - message="Run something.", - deliver=True, - channel="telegram", - to="user-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() - - def test_gateway_workspace_override_does_not_migrate_legacy_cron( monkeypatch, tmp_path: Path ) -> None: diff --git a/tests/cron/test_cron_service.py b/tests/cron/test_cron_service.py index fc5194f22..7a0db0dd8 100644 --- a/tests/cron/test_cron_service.py +++ b/tests/cron/test_cron_service.py @@ -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,16 +56,108 @@ 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_list_bound_agent_jobs_excludes_legacy_delivery_payloads(tmp_path) -> None: +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( @@ -73,8 +165,10 @@ def test_list_bound_agent_jobs_excludes_legacy_delivery_payloads(tmp_path) -> No schedule=schedule, message="new bound job", session_key="websocket:chat-1", + origin_channel="websocket", + origin_chat_id="chat-1", ) - service.add_job( + migrated = service.add_job( name="Legacy same session", schedule=schedule, message="legacy job", @@ -84,7 +178,7 @@ def test_list_bound_agent_jobs_excludes_legacy_delivery_payloads(tmp_path) -> No session_key="websocket:chat-1", ) - assert service.list_bound_cron_jobs_for_session("websocket:chat-1") == [bound] + assert service.list_bound_cron_jobs_for_session("websocket:chat-1") == [bound, migrated] def test_add_job_preserves_origin_delivery_context(tmp_path) -> None: @@ -143,7 +237,10 @@ async def test_channel_meta_and_session_key_survive_store_reload(tmp_path) -> No 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" @@ -151,7 +248,7 @@ async def test_channel_meta_and_session_key_survive_store_reload(tmp_path) -> No 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" @@ -648,28 +745,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 From 224852797190ee8c10694daa6cb6e147acb0032e Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 12 Jun 2026 17:02:29 +0800 Subject: [PATCH 21/24] fix: show cron bindings before deleting sessions --- webui/src/App.tsx | 22 ++++++--- webui/src/tests/app-layout.test.tsx | 72 ++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 55f94fb7c..9746e73f6 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -43,7 +43,7 @@ import type { } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { fetchSettings, fetchWorkspaces } from "@/lib/api"; +import { fetchSessionAutomations, fetchSettings, fetchWorkspaces } from "@/lib/api"; import { createRuntimeHost, getHostApi, @@ -548,7 +548,6 @@ function Shell({ key: string; label: string; automations?: SessionAutomationJob[]; - confirmAutomations?: boolean; } | null>(null); const [pendingRename, setPendingRename] = useState<{ key: string; @@ -1273,6 +1272,7 @@ function Shell({ const onConfirmDelete = useCallback(async () => { if (!pendingDelete) return; const key = pendingDelete.key; + const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0; const deletingActive = activeKey === key; const currentIndex = sessions.findIndex((s) => s.key === key); const fallbackKey = deletingActive @@ -1281,13 +1281,12 @@ function Shell({ try { const result = await deleteChat( key, - pendingDelete.confirmAutomations ? { deleteAutomations: true } : undefined, + hasAutomations ? { deleteAutomations: true } : undefined, ); if (result.blocked_by_automations) { setPendingDelete({ ...pendingDelete, automations: result.automations ?? [], - confirmAutomations: true, }); return; } @@ -1304,6 +1303,16 @@ function Shell({ } }, [pendingDelete, deleteChat, activeKey, navigate, sessions]); + const onRequestDelete = useCallback(async (key: string, label: string) => { + let automations: SessionAutomationJob[] = []; + try { + automations = (await fetchSessionAutomations(token, key)).jobs; + } catch { + // Delete remains protected by the backend block; prefetch only improves the first prompt. + } + setPendingDelete({ key, label, automations }); + }, [token]); + const headerTitle = activeSession ? sidebarState.title_overrides[activeSession.key] || activeSession.title || @@ -1340,8 +1349,7 @@ function Shell({ loading, onNewChat, onSelect: onSelectChat, - onRequestDelete: (key: string, label: string) => - setPendingDelete({ key, label }), + onRequestDelete, onTogglePin, onRequestRename, onToggleArchive, @@ -1566,7 +1574,7 @@ function Shell({ setPendingDelete(null)} onConfirm={onConfirmDelete} /> diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 8735fa00b..9e39688ba 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -146,8 +146,9 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => { refresh: refreshSpy, createChat: createChatSpy, forkChat: async () => "fork-chat", - deleteChat: async (key: string) => { - await deleteChatSpy(key); + deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => { + if (options === undefined) await deleteChatSpy(key); + else await deleteChatSpy(key, options); setSessions((prev: ChatSummary[]) => prev.filter((s) => s.key !== key)); return { deleted: true }; }, @@ -434,6 +435,73 @@ describe("App layout", () => { expect(document.body.style.pointerEvents).not.toBe("none"); }, 15_000); + it("shows bound automations in the first delete confirmation", async () => { + mockSessions = [ + { + key: "websocket:chat-a", + channel: "websocket", + chatId: "chat-a", + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:00:00Z", + preview: "First chat", + }, + { + key: "websocket:chat-b", + channel: "websocket", + chatId: "chat-b", + createdAt: "2026-04-16T11:00:00Z", + updatedAt: "2026-04-16T11:00:00Z", + preview: "Second chat", + }, + ]; + mockFetchRoutes({ + "/api/sessions/websocket%3Achat-a/automations": { + jobs: [ + { + id: "job-1", + name: "Daily repo check", + enabled: true, + schedule: { kind: "every", every_ms: 86_400_000 }, + payload: { message: "Check the repo" }, + state: { next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0) }, + }, + ], + }, + }); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + await waitFor(() => + expect( + within(sidebar).getByRole("button", { name: /^First chat$/ }), + ).toBeInTheDocument(), + ); + + fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), { + button: 0, + }); + fireEvent.click(await screen.findByRole("menuitem", { name: "Delete" })); + + await waitFor(() => + expect(screen.getByText("Daily repo check")).toBeInTheDocument(), + ); + expect( + screen.getByText("This chat has scheduled automations. Deleting it will also delete them."), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Delete chat and automations" })); + + await waitFor(() => + expect(deleteChatSpy).toHaveBeenCalledWith("websocket:chat-a", { + deleteAutomations: true, + }), + ); + expect(deleteChatSpy).toHaveBeenCalledTimes(1); + expect(screen.queryByText("Daily repo check")).not.toBeInTheDocument(); + }, 15_000); + it("keeps the mobile session action menu inside the sidebar sheet", async () => { mockSessions = [ { From b5f9d51b5b9b354b11edc0697d5f00da135cf5dd Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 12 Jun 2026 17:07:40 +0800 Subject: [PATCH 22/24] chore: drop unrelated chat apps docs change --- docs/chat-apps.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/chat-apps.md b/docs/chat-apps.md index f23ed7b91..068e7edfc 100644 --- a/docs/chat-apps.md +++ b/docs/chat-apps.md @@ -572,9 +572,7 @@ nanobot gateway DM the bot directly or @mention it in a channel — it should respond! > [!TIP] -> - `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all channel messages), or `"allowlist"` (restrict to specific channels via `groupAllowFrom`). -> - `groupAllowFrom`: channel IDs the bot may respond in when `groupPolicy` is `"allowlist"`. -> - `groupRequireMention`: when `true` and `groupPolicy` is `"allowlist"`, the bot only replies to channels in `groupAllowFrom` **and** only when @mentioned (instead of every message). No effect for `"mention"`/`"open"`. Use this to scope the bot to approved channels while keeping mention-only behavior. +> - `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all channel messages), or `"allowlist"` (restrict to specific channels). > - DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs. From a50b3ac0f2d5668c718fab280ffd85546bf3cf95 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 12 Jun 2026 18:13:25 +0800 Subject: [PATCH 23/24] fix: harden cron session automation flows --- nanobot/agent/cron_turns.py | 32 ++++++++++++- nanobot/agent/loop.py | 3 ++ nanobot/channels/manager.py | 3 ++ nanobot/cli/commands.py | 9 ++-- nanobot/cron/service.py | 8 ++++ nanobot/utils/evaluator.py | 10 ++-- nanobot/webui/gateway_services.py | 6 ++- nanobot/webui/session_automations.py | 19 ++++++-- nanobot/webui/ws_http.py | 11 ++++- tests/agent/test_evaluator.py | 2 +- tests/agent/test_runner_injections.py | 40 +++++++++++++++- tests/channels/test_websocket_http_routes.py | 12 ++++- tests/cli/test_commands.py | 5 +- tests/cron/test_cron_service.py | 26 +++++++++- webui/src/App.tsx | 16 +++++-- webui/src/components/DeleteConfirm.tsx | 8 +--- .../components/thread/SessionInfoPopover.tsx | 3 ++ webui/src/hooks/useSessions.ts | 19 +++++++- webui/src/i18n/locales/en/common.json | 1 + webui/src/i18n/locales/es/common.json | 1 + webui/src/i18n/locales/fr/common.json | 1 + webui/src/i18n/locales/id/common.json | 1 + webui/src/i18n/locales/ja/common.json | 1 + webui/src/i18n/locales/ko/common.json | 1 + webui/src/i18n/locales/vi/common.json | 1 + webui/src/i18n/locales/zh-CN/common.json | 1 + webui/src/i18n/locales/zh-TW/common.json | 1 + webui/src/lib/types.ts | 1 + webui/src/tests/app-layout.test.tsx | 48 +++++++++++-------- webui/src/tests/session-info-popover.test.tsx | 33 +++++++++++-- 30 files changed, 267 insertions(+), 56 deletions(-) diff --git a/nanobot/agent/cron_turns.py b/nanobot/agent/cron_turns.py index da456ba48..a589338c3 100644 --- a/nanobot/agent/cron_turns.py +++ b/nanobot/agent/cron_turns.py @@ -7,7 +7,11 @@ import dataclasses from collections.abc import Awaitable, Callable, Iterable from nanobot.bus.events import InboundMessage, OutboundMessage -from nanobot.cron.session_turns import cron_run_id, defer_cron_until_session_idle +from nanobot.cron.session_turns import ( + cron_run_id, + cron_trigger, + defer_cron_until_session_idle, +) class CronTurnCoordinator: @@ -25,6 +29,7 @@ class CronTurnCoordinator: self._is_running = is_running self.deferred_queues: dict[str, list[InboundMessage]] = {} self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {} + self._pending_messages_by_run_id: dict[str, InboundMessage] = {} async def submit(self, msg: InboundMessage) -> OutboundMessage | None: """Submit a scheduled cron turn and wait for its session response.""" @@ -37,6 +42,7 @@ class CronTurnCoordinator: loop = asyncio.get_running_loop() future: asyncio.Future[OutboundMessage | None] = loop.create_future() self._waiters[run_id] = future + self._pending_messages_by_run_id[run_id] = msg try: if self._is_running(): await self._publish_inbound(msg) @@ -45,6 +51,7 @@ class CronTurnCoordinator: return await future finally: self._waiters.pop(run_id, None) + self._pending_messages_by_run_id.pop(run_id, None) def should_defer( self, @@ -102,6 +109,21 @@ class CronTurnCoordinator: def defer(self, session_key: str, msg: InboundMessage) -> None: self.deferred_queues.setdefault(session_key, []).append(msg) + def pending_job_ids_for_session(self, session_key: str) -> set[str]: + """Return cron jobs that are waiting for or running in *session_key*.""" + job_ids: set[str] = set() + for msg in self.deferred_queues.get(session_key, []): + job_id = _cron_job_id(msg) + if job_id: + job_ids.add(job_id) + for msg in self._pending_messages_by_run_id.values(): + if msg.session_key != session_key: + continue + job_id = _cron_job_id(msg) + if job_id: + job_ids.add(job_id) + return job_ids + async def publish_next_deferred(self, session_key: str) -> None: queue = self.deferred_queues.get(session_key) if not queue: @@ -110,3 +132,11 @@ class CronTurnCoordinator: if not queue: self.deferred_queues.pop(session_key, None) await self._publish_inbound(msg) + + +def _cron_job_id(msg: InboundMessage) -> str | None: + trigger = cron_trigger(msg.metadata) + if not trigger: + return None + value = trigger.get("job_id") + return value if isinstance(value, str) and value else None diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 576848139..ad57dc875 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -574,6 +574,9 @@ class AgentLoop: async def submit_cron_turn(self, msg: InboundMessage) -> OutboundMessage | None: return await self._cron_turns.submit(msg) + def pending_cron_job_ids_for_session(self, session_key: str) -> set[str]: + return self._cron_turns.pending_job_ids_for_session(session_key) + def _persist_user_message_early( self, msg: InboundMessage, diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index b59925232..a28ebdcba 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -58,6 +58,7 @@ class ChannelManager: session_manager: "SessionManager | None" = None, cron_service: Any | None = None, webui_runtime_model_name: Callable[[], str | None] | None = None, + webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None, webui_static_dist: bool = True, webui_runtime_surface: str = "browser", webui_runtime_capabilities: dict[str, Any] | None = None, @@ -67,6 +68,7 @@ class ChannelManager: self._session_manager = session_manager self._cron_service = cron_service self._webui_runtime_model_name = webui_runtime_model_name + self._webui_cron_pending_job_ids = webui_cron_pending_job_ids self._webui_static_dist = webui_static_dist self._webui_runtime_surface = webui_runtime_surface self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {}) @@ -126,6 +128,7 @@ class ChannelManager: runtime_surface=self._webui_runtime_surface, runtime_capabilities_overrides=self._webui_runtime_capabilities, cron_service=self._cron_service, + cron_pending_job_ids=self._webui_cron_pending_job_ids, logger=logger, ) kwargs["gateway"] = gateway diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index bd40dd2aa..a1baf101b 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -947,7 +947,7 @@ def _run_gateway( from nanobot.bus.runtime_events import RuntimeEventBus from nanobot.channels.manager import ChannelManager from nanobot.cron.bound_runner import run_bound_cron_job - from nanobot.cron.service import CronService + from nanobot.cron.service import CronJobSkippedError, CronService from nanobot.cron.session_turns import is_bound_cron_job from nanobot.cron.types import CronJob from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot @@ -1167,12 +1167,14 @@ def _run_gateway( if is_bound_cron_job(job): return await run_bound_cron_job(job, agent=agent, cron=cron) + reason = "unbound agent cron job must be recreated from a chat session" logger.warning( - "Cron: skipped unbound agent job '{}' ({}); recreate it from a chat session", + "Cron: skipped unbound agent job '{}' ({}): {}", job.name, job.id, + reason, ) - return None + raise CronJobSkippedError(reason) cron.on_job = on_cron_job @@ -1191,6 +1193,7 @@ def _run_gateway( session_manager=session_manager, cron_service=cron, webui_runtime_model_name=_webui_runtime_model_name, + webui_cron_pending_job_ids=getattr(agent, "pending_cron_job_ids_for_session", None), webui_static_dist=webui_static_dist, webui_runtime_surface=webui_runtime_surface, webui_runtime_capabilities=webui_runtime_capabilities, diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 1a66af007..16fab16b4 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -25,6 +25,10 @@ from nanobot.cron.types import ( ) +class CronJobSkippedError(Exception): + """Raised by cron callbacks when a job was intentionally skipped.""" + + def _now_ms() -> int: return int(time.time() * 1000) @@ -524,6 +528,10 @@ class CronService: job.state.last_error = None logger.info("Cron: job '{}' completed", job.name) + except CronJobSkippedError as e: + job.state.last_status = "skipped" + job.state.last_error = str(e) or None + logger.warning("Cron: job '{}' skipped: {}", job.name, job.state.last_error or "") except asyncio.CancelledError as e: current = asyncio.current_task() if current is not None and current.cancelling(): diff --git a/nanobot/utils/evaluator.py b/nanobot/utils/evaluator.py index 89759523a..07206f991 100644 --- a/nanobot/utils/evaluator.py +++ b/nanobot/utils/evaluator.py @@ -1,6 +1,6 @@ -"""Post-run evaluation for background tasks (heartbeat & cron). +"""Post-run notification evaluation for heartbeat checks. -After the agent executes a background task, this module makes a lightweight +After heartbeat executes an internal check, this module makes a lightweight LLM call to decide whether the result warrants notifying the user. """ @@ -46,10 +46,10 @@ async def evaluate_response( model: str, default_notify: bool = True, ) -> bool: - """Decide whether a background-task result should be delivered to the user. + """Decide whether a heartbeat result should be delivered to the user. - On any failure, falls back to ``default_notify`` (cron reminders fail open; - heartbeat passes ``False`` to fail closed). + On any failure, falls back to ``default_notify``. Heartbeat passes + ``False`` to fail closed. """ try: llm_response = await provider.chat_with_retry( diff --git a/nanobot/webui/gateway_services.py b/nanobot/webui/gateway_services.py index 15649d08d..fa94b8f67 100644 --- a/nanobot/webui/gateway_services.py +++ b/nanobot/webui/gateway_services.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Any, Callable from loguru import logger as default_logger @@ -26,6 +26,7 @@ class GatewayServices: workspaces: WebUIWorkspaceController session_manager: Any | None cron_service: Any | None + cron_pending_job_ids: Callable[[str], set[str]] | None def build_gateway_services( @@ -41,6 +42,7 @@ def build_gateway_services( runtime_capabilities_overrides: dict[str, Any] | None, disabled_skills: set[str] | None = None, cron_service: Any | None = None, + cron_pending_job_ids: Callable[[str], set[str]] | None = None, logger: Any = default_logger, ) -> GatewayServices: tokens = GatewayTokenStore() @@ -68,6 +70,7 @@ def build_gateway_services( skills_workspace_path=workspace_path, disabled_skills=disabled_skills, cron_service=cron_service, + cron_pending_job_ids=cron_pending_job_ids, log=logger, ) return GatewayServices( @@ -78,4 +81,5 @@ def build_gateway_services( workspaces=workspaces, session_manager=session_manager, cron_service=cron_service, + cron_pending_job_ids=cron_pending_job_ids, ) diff --git a/nanobot/webui/session_automations.py b/nanobot/webui/session_automations.py index 3eb56e5b2..c87dfd094 100644 --- a/nanobot/webui/session_automations.py +++ b/nanobot/webui/session_automations.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Collection from typing import Any, Protocol from nanobot.cron.types import CronJob @@ -32,18 +33,27 @@ def session_automation_jobs( def session_automations_payload( cron_service: _CronServiceLike | None, session_key: str, + *, + pending_job_ids: Collection[str] | None = None, ) -> dict[str, Any]: """Return user-created automation jobs attached to a WebUI session.""" return { - "jobs": serialize_automation_jobs(session_automation_jobs(cron_service, session_key)) + "jobs": serialize_automation_jobs( + session_automation_jobs(cron_service, session_key), + pending_job_ids=pending_job_ids, + ) } -def serialize_automation_jobs(jobs: list[CronJob]) -> list[dict[str, Any]]: - return [_serialize_job(job) for job in jobs] +def serialize_automation_jobs( + jobs: list[CronJob], + *, + pending_job_ids: Collection[str] | None = None, +) -> list[dict[str, Any]]: + return [_serialize_job(job, pending=job.id in (pending_job_ids or ())) for job in jobs] -def _serialize_job(job: CronJob) -> dict[str, Any]: +def _serialize_job(job: CronJob, *, pending: bool = False) -> dict[str, Any]: return { "id": job.id, "name": job.name, @@ -61,5 +71,6 @@ def _serialize_job(job: CronJob) -> dict[str, Any]: "state": { "next_run_at_ms": job.state.next_run_at_ms, "last_status": job.state.last_status, + "pending": pending, }, } diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 70e19e01b..2ccdedac4 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -146,6 +146,7 @@ class GatewayHTTPHandler: skills_workspace_path: Path, disabled_skills: set[str] | None = None, cron_service: CronService | None = None, + cron_pending_job_ids: Callable[[str], set[str]] | None = None, log: Any = logger, ) -> None: self.config = config @@ -159,6 +160,7 @@ class GatewayHTTPHandler: self.skills_workspace_path = skills_workspace_path self.disabled_skills = disabled_skills or set() self.cron_service = cron_service + self.cron_pending_job_ids = cron_pending_job_ids self._log = log self._runtime_surface = runtime_surface @@ -436,8 +438,15 @@ class GatewayHTTPHandler: return _http_error(400, "invalid session key") if not _is_websocket_channel_session_key(decoded_key): return _http_error(404, "session not found") + pending_job_ids: set[str] = set() + if self.cron_pending_job_ids is not None: + pending_job_ids = self.cron_pending_job_ids(decoded_key) return _http_json_response( - session_automations_payload(self.cron_service, decoded_key) + session_automations_payload( + self.cron_service, + decoded_key, + pending_job_ids=pending_job_ids, + ) ) def _handle_session_delete(self, request: WsRequest, key: str) -> Response: diff --git a/tests/agent/test_evaluator.py b/tests/agent/test_evaluator.py index 62da88d66..acefe40e1 100644 --- a/tests/agent/test_evaluator.py +++ b/tests/agent/test_evaluator.py @@ -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): diff --git a/tests/agent/test_runner_injections.py b/tests/agent/test_runner_injections.py index 97d653f3a..3b94569d9 100644 --- a/tests/agent/test_runner_injections.py +++ b/tests/agent/test_runner_injections.py @@ -639,7 +639,7 @@ async def test_cron_turn_deferred_while_session_active(tmp_path): chat_id="chat-1", content="scheduled work", metadata={ - CRON_TRIGGER_META: {"run_id": "run-1"}, + CRON_TRIGGER_META: {"job_id": "job-1", "run_id": "run-1"}, CRON_DEFER_UNTIL_IDLE_META: True, }, session_key_override=session_key, @@ -657,11 +657,49 @@ async def test_cron_turn_deferred_while_session_active(tmp_path): 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 diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index c364f5a36..63dba4861 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -30,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() @@ -44,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, ) @@ -56,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] = { @@ -74,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) @@ -177,11 +181,12 @@ 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, @@ -189,6 +194,8 @@ async def test_session_automations_route_filters_by_webui_session( 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, @@ -210,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()) @@ -235,6 +243,8 @@ async def test_session_automations_route_filters_by_webui_session( 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 diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index abbd28427..c4372c44d 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -11,6 +11,7 @@ from typer.testing import CliRunner 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 @@ -1305,9 +1306,9 @@ def test_gateway_unbound_agent_cron_is_skipped( ), ) - response = asyncio.run(cron.on_job(job)) + with pytest.raises(CronJobSkippedError, match="unbound agent cron job"): + asyncio.run(cron.on_job(job)) - assert response is None bus.publish_outbound.assert_not_awaited() diff --git a/tests/cron/test_cron_service.py b/tests/cron/test_cron_service.py index 7a0db0dd8..d81d4121b 100644 --- a/tests/cron/test_cron_service.py +++ b/tests/cron/test_cron_service.py @@ -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 @@ -296,6 +296,30 @@ 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" diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 9746e73f6..fa2976811 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -43,7 +43,7 @@ import type { } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { fetchSessionAutomations, fetchSettings, fetchWorkspaces } from "@/lib/api"; +import { fetchSettings, fetchWorkspaces } from "@/lib/api"; import { createRuntimeHost, getHostApi, @@ -528,7 +528,15 @@ function Shell({ const { t, i18n } = useTranslation(); const { client, token } = useClient(); const { theme, toggle } = useTheme(); - const { sessions, loading, refresh, createChat, forkChat, deleteChat } = useSessions(); + const { + sessions, + loading, + refresh, + createChat, + forkChat, + deleteChat, + getSessionAutomations, + } = useSessions(); const { state: sidebarState, update: updateSidebarState } = useSidebarState(sessions, !loading); const initialRouteRef = useRef(null); @@ -1306,12 +1314,12 @@ function Shell({ const onRequestDelete = useCallback(async (key: string, label: string) => { let automations: SessionAutomationJob[] = []; try { - automations = (await fetchSessionAutomations(token, key)).jobs; + automations = await getSessionAutomations(key); } catch { // Delete remains protected by the backend block; prefetch only improves the first prompt. } setPendingDelete({ key, label, automations }); - }, [token]); + }, [getSessionAutomations]); const headerTitle = activeSession ? sidebarState.title_overrides[activeSession.key] || diff --git a/webui/src/components/DeleteConfirm.tsx b/webui/src/components/DeleteConfirm.tsx index 0f5e7f04c..fc73edd9c 100644 --- a/webui/src/components/DeleteConfirm.tsx +++ b/webui/src/components/DeleteConfirm.tsx @@ -51,9 +51,7 @@ export function DeleteConfirm({ {hasAutomations - ? t("deleteConfirm.automationsDescription", { - count: automations.length, - }) + ? t("deleteConfirm.automationsDescription") : t("deleteConfirm.description")} {hasAutomations ? ( @@ -94,9 +92,7 @@ export function DeleteConfirm({ className="h-11 rounded-full bg-destructive px-5 text-[15px] font-semibold text-destructive-foreground shadow-[0_10px_25px_rgba(239,68,68,0.28)] hover:bg-destructive/90" > {hasAutomations - ? t("deleteConfirm.confirmWithAutomations", { - count: automations.length, - }) + ? t("deleteConfirm.confirmWithAutomations") : t("deleteConfirm.confirm")} diff --git a/webui/src/components/thread/SessionInfoPopover.tsx b/webui/src/components/thread/SessionInfoPopover.tsx index 31df132bb..d69755dc2 100644 --- a/webui/src/components/thread/SessionInfoPopover.tsx +++ b/webui/src/components/thread/SessionInfoPopover.tsx @@ -176,6 +176,9 @@ function formatNextRun(job: SessionAutomationJob, t: TFunction, now: number) { if (!job.enabled) { return { label: t("thread.sessionInfo.next.disabled"), title: "" }; } + if (job.state.pending) { + return { label: t("thread.sessionInfo.next.pending"), title: "" }; + } const next = job.state.next_run_at_ms; if (!next) { return { label: t("thread.sessionInfo.next.none"), title: "" }; diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index 43adaadd6..f1faea403 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -5,6 +5,7 @@ import i18n from "@/i18n"; import { ApiError, deleteSession as apiDeleteSession, + fetchSessionAutomations, fetchWebuiThread, listSessions, } from "@/lib/api"; @@ -12,6 +13,7 @@ import { hasPendingAgentActivity } from "@/lib/activity-timeline"; import { deriveTitle } from "@/lib/format"; import type { ChatSummary, + SessionAutomationJob, SessionDeleteResult, UIMessage, WorkspaceScopePayload, @@ -52,6 +54,7 @@ export function useSessions(): { key: string, options?: { deleteAutomations?: boolean }, ) => Promise; + getSessionAutomations: (key: string) => Promise; } { const { client, token } = useClient(); const [sessions, setSessions] = useState([]); @@ -159,7 +162,21 @@ export function useSessions(): { [], ); - return { sessions, loading, error, refresh, createChat, forkChat, deleteChat }; + const getSessionAutomations = useCallback(async (key: string) => { + const result = await fetchSessionAutomations(tokenRef.current, key); + return result.jobs; + }, []); + + return { + sessions, + loading, + error, + refresh, + createChat, + forkChat, + deleteChat, + getSessionAutomations, + }; } /** Lazy-load a session's on-disk messages the first time the UI displays it. */ diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 3729d9e13..8020d3485 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -663,6 +663,7 @@ }, "next": { "label": "{{time}}", + "pending": "Runs shortly", "disabled": "Paused", "none": "No next run" } diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 1cf97f7bd..46a6c3ab0 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -663,6 +663,7 @@ }, "next": { "label": "Siguiente {{time}}", + "pending": "Se ejecutará pronto", "disabled": "En pausa", "none": "Sin próxima ejecución" } diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index ed37dcb30..61182951c 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -663,6 +663,7 @@ }, "next": { "label": "Prochaine {{time}}", + "pending": "Exécution imminente", "disabled": "En pause", "none": "Aucune prochaine exécution" } diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 2aae43477..12b8f1af3 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -663,6 +663,7 @@ }, "next": { "label": "Berikutnya {{time}}", + "pending": "Segera berjalan", "disabled": "Dijeda", "none": "Tidak ada jadwal berikutnya" } diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 3100e98ba..1c2a19628 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -663,6 +663,7 @@ }, "next": { "label": "次回 {{time}}", + "pending": "まもなく実行", "disabled": "一時停止", "none": "次回実行なし" } diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 952e48f68..e59c78cb8 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -663,6 +663,7 @@ }, "next": { "label": "다음 {{time}}", + "pending": "곧 실행됨", "disabled": "일시 중지됨", "none": "다음 실행 없음" } diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 21faca3d7..22faba4e6 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -663,6 +663,7 @@ }, "next": { "label": "Tiếp theo {{time}}", + "pending": "Sắp chạy", "disabled": "Đã tạm dừng", "none": "Không có lần chạy tiếp theo" } diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 29de0f71e..3ec1c3ac4 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -663,6 +663,7 @@ }, "next": { "label": "下次 {{time}}", + "pending": "即将执行", "disabled": "已暂停", "none": "没有下次运行" } diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 9143f8047..70f2a6242 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -663,6 +663,7 @@ }, "next": { "label": "下次 {{time}}", + "pending": "即將執行", "disabled": "已暫停", "none": "沒有下次執行" } diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index df51f0887..aa7461f3e 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -113,6 +113,7 @@ export interface SessionAutomationJob { state: { next_run_at_ms?: number | null; last_status?: "ok" | "error" | "skipped" | string | null; + pending?: boolean; }; } diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 9e39688ba..d83d09965 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -1,12 +1,14 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ChatSummary } from "@/lib/types"; +import i18n from "@/i18n"; +import type { ChatSummary, SessionAutomationJob } from "@/lib/types"; const connectSpy = vi.fn(); const refreshSpy = vi.fn(); const createChatSpy = vi.fn().mockResolvedValue("chat-1"); const deleteChatSpy = vi.fn(); +const getSessionAutomationsSpy = vi.fn<(key: string) => Promise>(); const toggleThemeSpy = vi.fn(); const updateUrlSpy = vi.fn(); const attachSpy = vi.fn(); @@ -146,6 +148,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => { refresh: refreshSpy, createChat: createChatSpy, forkChat: async () => "fork-chat", + getSessionAutomations: getSessionAutomationsSpy, deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => { if (options === undefined) await deleteChatSpy(key); else await deleteChatSpy(key, options); @@ -212,13 +215,15 @@ import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap"; import App from "@/App"; describe("App layout", () => { - beforeEach(() => { + beforeEach(async () => { + await i18n.changeLanguage("en"); mockSessions = []; connectSpy.mockClear(); updateUrlSpy.mockClear(); refreshSpy.mockReset(); createChatSpy.mockClear(); deleteChatSpy.mockReset(); + getSessionAutomationsSpy.mockReset().mockResolvedValue([]); toggleThemeSpy.mockReset(); attachSpy.mockReset(); runStatusHandlers.clear(); @@ -435,7 +440,7 @@ describe("App layout", () => { expect(document.body.style.pointerEvents).not.toBe("none"); }, 15_000); - it("shows bound automations in the first delete confirmation", async () => { + it("shows localized bound automations in the first delete confirmation", async () => { mockSessions = [ { key: "websocket:chat-a", @@ -454,44 +459,45 @@ describe("App layout", () => { preview: "Second chat", }, ]; - mockFetchRoutes({ - "/api/sessions/websocket%3Achat-a/automations": { - jobs: [ - { - id: "job-1", - name: "Daily repo check", - enabled: true, - schedule: { kind: "every", every_ms: 86_400_000 }, - payload: { message: "Check the repo" }, - state: { next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0) }, - }, - ], + getSessionAutomationsSpy.mockResolvedValue([ + { + id: "job-1", + name: "Daily repo check", + enabled: true, + schedule: { kind: "every", every_ms: 86_400_000 }, + payload: { message: "Check the repo" }, + state: { next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0) }, }, - }); + ]); + await i18n.changeLanguage("zh-CN"); render(); await waitFor(() => expect(connectSpy).toHaveBeenCalled()); - const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + const sidebar = screen.getByRole("navigation", { name: "侧边栏导航" }); await waitFor(() => expect( within(sidebar).getByRole("button", { name: /^First chat$/ }), ).toBeInTheDocument(), ); - fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), { + fireEvent.pointerDown(screen.getByLabelText(/First chat.*会话操作/), { button: 0, }); - fireEvent.click(await screen.findByRole("menuitem", { name: "Delete" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "删除" })); await waitFor(() => expect(screen.getByText("Daily repo check")).toBeInTheDocument(), ); + expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-a"); expect( - screen.getByText("This chat has scheduled automations. Deleting it will also delete them."), + screen.getByText("这个对话有关联的自动任务。删除对话也会删除这些自动任务。"), ).toBeInTheDocument(); + expect( + screen.queryByText("This chat has scheduled automations. Deleting it will also delete them."), + ).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Delete chat and automations" })); + fireEvent.click(screen.getByRole("button", { name: "删除对话和自动任务" })); await waitFor(() => expect(deleteChatSpy).toHaveBeenCalledWith("websocket:chat-a", { diff --git a/webui/src/tests/session-info-popover.test.tsx b/webui/src/tests/session-info-popover.test.tsx index 15da6986d..34fbc2e78 100644 --- a/webui/src/tests/session-info-popover.test.tsx +++ b/webui/src/tests/session-info-popover.test.tsx @@ -5,14 +5,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover"; import { setAppLanguage } from "@/i18n"; -function automationJob(nextRunAt = Date.now() + 3_600_000) { +function automationJob( + nextRunAt = Date.now() + 3_600_000, + state: Record = {}, +) { return { id: "job-1", name: "Morning check", enabled: true, schedule: { kind: "every", every_ms: 3_600_000 }, payload: { message: "Check the project status" }, - state: { next_run_at_ms: nextRunAt }, + state: { next_run_at_ms: nextRunAt, ...state }, }; } @@ -27,7 +30,8 @@ function automationsResponse(jobs: unknown[]) { } describe("SessionInfoPopover", () => { - beforeEach(() => { + beforeEach(async () => { + await setAppLanguage("en"); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue(automationsResponse([automationJob()])), @@ -86,6 +90,29 @@ describe("SessionInfoPopover", () => { expect(screen.queryByText("Automations")).not.toBeInTheDocument(); }); + it("shows a short pending label for deferred automations", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + automationsResponse([automationJob(Date.now() - 1000, { pending: true })]), + ), + ); + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Session details" })); + + expect(await screen.findByText("Runs shortly")).toBeInTheDocument(); + expect(screen.queryByText(/ago/i)).not.toBeInTheDocument(); + }); + it("refreshes while open so completed one-shot automations disappear", async () => { vi.stubGlobal( "fetch", From 32d8a1dd7b25d08f06a4b1e3de55c5c50cb3f88b Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 12 Jun 2026 18:17:28 +0800 Subject: [PATCH 24/24] fix: hide internal cron prompts from webui --- nanobot/session/webui_turns.py | 3 ++ nanobot/webui/session_list_index.py | 5 +++ nanobot/webui/transcript.py | 3 ++ tests/agent/test_loop_save_turn.py | 25 +++++++++++++ tests/channels/test_websocket_channel.py | 46 ++++++++++++++++++++++++ tests/webui/test_session_list_index.py | 15 ++++++++ 6 files changed, 97 insertions(+) diff --git a/nanobot/session/webui_turns.py b/nanobot/session/webui_turns.py index 8d4163f32..8c6072bb9 100644 --- a/nanobot/session/webui_turns.py +++ b/nanobot/session/webui_turns.py @@ -22,6 +22,7 @@ from nanobot.bus.runtime_events import ( TurnCompleted, TurnRunStatusChanged, ) +from nanobot.cron.session_turns import CRON_HISTORY_META from nanobot.providers.base import LLMProvider from nanobot.session.goal_state import goal_state_ws_blob from nanobot.session.manager import Session, SessionManager @@ -68,6 +69,8 @@ def _title_inputs(session: Session) -> tuple[str, str]: for message in session.messages: if message.get("_command") is True: continue + if message.get(CRON_HISTORY_META) is True: + continue role = message.get("role") content = message.get("content") if not isinstance(content, str) or not content.strip(): diff --git a/nanobot/webui/session_list_index.py b/nanobot/webui/session_list_index.py index 082ce5300..4fba2ca14 100644 --- a/nanobot/webui/session_list_index.py +++ b/nanobot/webui/session_list_index.py @@ -14,6 +14,7 @@ from typing import Any from loguru import logger +from nanobot.cron.session_turns import CRON_HISTORY_META from nanobot.session.manager import ( _SESSION_LIST_PREVIEW_MAX_CHARS, _SESSION_LIST_PREVIEW_MAX_RECORDS, @@ -142,6 +143,8 @@ def _preview_from_messages(messages: list[dict[str, Any]]) -> str: or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS ): break + if item.get(CRON_HISTORY_META) is True: + continue text = _message_preview_text(item) if not text: continue @@ -193,6 +196,8 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, item = json.loads(line) if item.get("_type") == "metadata": continue + if item.get(CRON_HISTORY_META) is True: + continue text = _message_preview_text(item) if not text: continue diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index e3a8f1dfc..3c433e20f 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -17,6 +17,7 @@ from urllib.parse import unquote, urlparse from loguru import logger from nanobot.config.paths import get_webui_dir +from nanobot.cron.session_turns import CRON_HISTORY_META from nanobot.session.manager import SessionManager from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY @@ -854,6 +855,8 @@ def _session_user_event( ) -> dict[str, Any] | None: if message.get("role") != "user": return None + if message.get(CRON_HISTORY_META) is True: + return None content = message.get("content") text = content if isinstance(content, str) else "" media = message.get("media") diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index f5862cc86..a5dd40426 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -181,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, diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index b8ee27a76..44eae486d 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -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"]] == ["提醒已经到期。"] diff --git a/tests/webui/test_session_list_index.py b/tests/webui/test_session_list_index.py index aea32b3e7..c8b0adf6c 100644 --- a/tests/webui/test_session_list_index.py +++ b/tests/webui/test_session_list_index.py @@ -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)