diff --git a/nanobot/agent/cron_turns.py b/nanobot/agent/cron_turns.py new file mode 100644 index 000000000..a589338c3 --- /dev/null +++ b/nanobot/agent/cron_turns.py @@ -0,0 +1,142 @@ +"""Coordination for scheduled cron turns.""" + +from __future__ import annotations + +import asyncio +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, + cron_trigger, + 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]] = {} + 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.""" + 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 + self._pending_messages_by_run_id[run_id] = msg + 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) + self._pending_messages_by_run_id.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 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, + *, + 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) + + 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: + return + msg = queue.pop(0) + 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 f50ac8268..de6496017 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,6 +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.session_turns import ( + cron_history_overrides, +) from nanobot.providers.base import LLMProvider from nanobot.providers.factory import ProviderSnapshot from nanobot.security.workspace_access import ( @@ -52,6 +56,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.utils.document import extract_documents, reference_non_image_attachments from nanobot.utils.helpers import image_placeholder_text @@ -72,8 +77,6 @@ if TYPE_CHECKING: from nanobot.cron.service import CronService -UNIFIED_SESSION_KEY = "unified:default" - class TurnState(Enum): RESTORE = auto() COMPACT = auto() @@ -300,6 +303,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] = {} + 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 = ( @@ -512,13 +520,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, @@ -565,6 +571,12 @@ class AgentLoop: def _runtime_events(self) -> RuntimeEventPublisher: return ensure_runtime_event_publisher(self) + 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, @@ -583,6 +595,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 "" + 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) @@ -883,6 +899,16 @@ class AgentLoop: self.commands.dispatch_priority, ) continue + if self._cron_turns.defer_if_active( + msg, + session_key=effective_key, + active_session_keys=self._pending_queues.keys(), + ): + logger.info( + "Deferred cron 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 +1022,12 @@ class AgentLoop: session_key=session_key, metadata=msg.metadata, ) + self._cron_turns.complete(msg, response=response) except asyncio.CancelledError: + self._cron_turns.complete( + 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 +1053,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 +1066,7 @@ class AgentLoop: session_key=session_key, metadata=msg.metadata, ) + 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 @@ -1065,12 +1097,14 @@ class AgentLoop: msg, session_key, "idle" ) self._runtime_events().clear_turn(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._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 ff376a87b..29ac52a9f 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -9,13 +9,13 @@ 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, ) 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"]), @@ -38,10 +38,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=( @@ -61,10 +57,13 @@ 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._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 @@ -76,11 +75,14 @@ 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.""" - 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}") + """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 == 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.""" @@ -147,7 +149,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": @@ -162,7 +164,6 @@ class CronTool(Tool, ContextAware): cron_expr: str | None, tz: str | None, at: str | None, - deliver: bool = True, ) -> str: if not message: return ( @@ -170,10 +171,13 @@ 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 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: @@ -210,12 +214,11 @@ 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, + 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/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 8f60fd9ed..a1baf101b 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -5,10 +5,8 @@ import os import select import signal import sys -import uuid from collections.abc import Callable from contextlib import nullcontext, suppress -from contextvars import ContextVar from pathlib import Path from typing import Any @@ -87,32 +85,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, -) - - -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_META_KEY, None) - if channel == "websocket": - out[_WEBUI_TURN_META_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 - return out - app = typer.Typer( name="nanobot", context_settings={"help_option_names": ["-h", "--help"]}, @@ -970,12 +942,13 @@ 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 from nanobot.channels.manager import ChannelManager - from nanobot.cron.service import CronService + from nanobot.cron.bound_runner import run_bound_cron_job + 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 from nanobot.providers.image_generation import image_gen_provider_configs @@ -1024,14 +997,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 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, ) async def _deliver_to_channel( @@ -1040,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, @@ -1194,73 +1164,17 @@ def _run_gateway( logger.info("Heartbeat: silenced by post-run evaluation") return response - 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}" + 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 '{}' ({}): {}", + job.name, + job.id, + reason, ) - - 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 = _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 = _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 + raise CronJobSkippedError(reason) cron.on_job = on_cron_job @@ -1279,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/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/service.py b/nanobot/cron/service.py index 31c5b50a7..16fab16b4 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.session_turns import is_bound_cron_job from nanobot.cron.types import ( CronJob, CronJobState, @@ -24,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) @@ -71,6 +76,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.""" @@ -84,6 +145,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 @@ -113,7 +175,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), @@ -136,6 +198,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"), @@ -155,7 +230,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. @@ -181,6 +258,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): @@ -266,6 +344,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, @@ -325,6 +406,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 @@ -430,6 +528,17 @@ 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(): + 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) @@ -473,6 +582,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_cron_jobs_for_session( + self, + session_key: str, + *, + include_disabled: bool = True, + ) -> list[CronJob]: + """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_cron_job(job) + and job.payload.session_key == session_key + ] + def add_job( self, name: str, @@ -484,6 +607,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) @@ -502,12 +628,16 @@ 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, 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) @@ -616,6 +746,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_delivery.py b/nanobot/cron/session_delivery.py new file mode 100644 index 000000000..15a65c41a --- /dev/null +++ b/nanobot/cron/session_delivery.py @@ -0,0 +1,15 @@ +"""Helpers for routing bound cron turns back through their origin session.""" + +from __future__ import annotations + +from typing import Any + +from nanobot.cron.types import CronJob + + +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/session_turns.py b/nanobot/cron/session_turns.py new file mode 100644 index 000000000..a85c47937 --- /dev/null +++ b/nanobot/cron/session_turns.py @@ -0,0 +1,74 @@ +"""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 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 session-bound cron jobs with complete delivery context.""" + payload = job.payload + 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 + or payload.channel + or payload.to + or payload.channel_meta + ) 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/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/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/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/templates/agent/cron_reminder.md b/nanobot/templates/agent/cron_reminder.md new file mode 100644 index 000000000..64f94f21b --- /dev/null +++ b/nanobot/templates/agent/cron_reminder.md @@ -0,0 +1,9 @@ +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. +- 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. + +Cron job: {{ message }} 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/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/session_automations.py b/nanobot/webui/session_automations.py index 52d503f54..c87dfd094 100644 --- a/nanobot/webui/session_automations.py +++ b/nanobot/webui/session_automations.py @@ -2,39 +2,58 @@ from __future__ import annotations +from collections.abc import Collection from typing import Any, Protocol from nanobot.cron.types import CronJob class _CronServiceLike(Protocol): - def list_jobs(self, *, include_disabled: bool = False) -> list[CronJob]: ... + def list_bound_cron_jobs_for_session( + self, + session_key: str, + *, + include_disabled: bool = True, + ) -> list[CronJob]: ... + + +def session_automation_jobs( + cron_service: _CronServiceLike | None, + session_key: str, +) -> list[CronJob]: + """Return user automations attached to the WebUI session.""" + if cron_service is None: + return [] + return cron_service.list_bound_cron_jobs_for_session( + session_key, + include_disabled=True, + ) 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.""" - 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( + session_automation_jobs(cron_service, session_key), + pending_job_ids=pending_job_ids, + ) + } -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], + *, + 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, @@ -52,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/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 ee2734283..3c433e20f 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -17,7 +17,9 @@ 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 WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3 WEBUI_FORK_MARKER_EVENT = "fork_marker" @@ -29,8 +31,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+(?:\"[^\"]*\"|'[^']*'))?\)" ) @@ -855,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") @@ -1823,6 +1825,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 +1880,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/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 101b309fe..2ccdedac4 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 ( + serialize_automation_jobs, + session_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, @@ -142,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 @@ -155,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 @@ -432,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: @@ -446,6 +459,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() + 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(automation_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) return _http_json_response({"deleted": bool(deleted)}) 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_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index 2df5f0910..f314fea4b 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.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META from nanobot.providers.base import LLMResponse from nanobot.session.goal_state import GOAL_STATE_KEY from nanobot.session.manager import Session, SessionManager @@ -64,6 +65,41 @@ def test_agent_loop_llm_runtime_reflects_current_provider_and_model(tmp_path: Pa assert runtime.model == "next-model" +def test_persist_cron_turn_uses_distinct_history_marker(tmp_path: Path) -> None: + loop = _make_full_loop(tmp_path) + session = loop.sessions.get_or_create("websocket:auto") + prompt_ref = {"id": "cron.agent_turn.reminder", "version": 1, "sha256": "abc"} + + persisted = loop._persist_user_message_early( + InboundMessage( + channel="websocket", + sender_id="cron", + chat_id="auto", + content="Cron job: internal prompt", + metadata={ + CRON_TRIGGER_META: { + "job_id": "job-1", + "job_name": "Daily check", + "run_id": "job-1:1", + "prompt_ref": prompt_ref, + "persist_content": "Scheduled cron job triggered: Daily check", + } + }, + ), + session, + ) + + assert persisted is True + message = session.messages[-1] + assert message["content"] == "Scheduled cron job triggered: Daily check" + assert message[CRON_HISTORY_META] is True + assert CRON_TRIGGER_META not in message + assert message["cron_job_id"] == "job-1" + assert message["cron_job_name"] == "Daily check" + assert message["cron_run_id"] == "job-1:1" + assert message["cron_prompt_ref"] == prompt_ref + + def test_clean_generated_title_strips_reasoning_tags() -> None: assert clean_generated_title("reasoning WebUI polish") == "WebUI polish" assert clean_generated_title("Title: The user said hello") == "" @@ -145,6 +181,31 @@ async def test_generate_webui_title_ignores_command_only_sessions(tmp_path: Path loop.provider.chat_with_retry.assert_not_awaited() +@pytest.mark.asyncio +async def test_generate_webui_title_ignores_cron_internal_turns(tmp_path: Path) -> None: + loop = _make_full_loop(tmp_path) + session = loop.sessions.get_or_create("websocket:cron-title") + session.metadata[WEBUI_SESSION_METADATA_KEY] = True + session.add_message( + "user", + "Scheduled cron job triggered: 30s-test\n\nInternal reminder prompt", + **{CRON_HISTORY_META: True}, + ) + session.add_message("assistant", "提醒已经到期。") + loop.sessions.save(session) + + generated = await maybe_generate_webui_title( + sessions=loop.sessions, + session_key="websocket:cron-title", + provider=loop.provider, + model=loop.model, + ) + + assert generated is False + assert WEBUI_TITLE_METADATA_KEY not in session.metadata + loop.provider.chat_with_retry.assert_not_awaited() + + def test_webui_title_update_uses_captured_llm_runtime( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/agent/test_runner_injections.py b/tests/agent/test_runner_injections.py index 3686574c8..3b94569d9 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 @@ -616,6 +616,92 @@ async def test_followup_routed_to_pending_queue(tmp_path): assert queued_msg.session_key == UNIFIED_SESSION_KEY +@pytest.mark.asyncio +async def test_cron_turn_deferred_while_session_active(tmp_path): + """Cron turns wait for the active session instead of becoming injections.""" + from nanobot.bus.events import InboundMessage + from nanobot.cron.session_turns import ( + CRON_DEFER_UNTIL_IDLE_META, + CRON_TRIGGER_META, + ) + + loop = _make_loop(tmp_path) + loop._dispatch = AsyncMock() # type: ignore[method-assign] + + session_key = "websocket:chat-1" + pending = asyncio.Queue(maxsize=20) + loop._pending_queues[session_key] = pending + + run_task = asyncio.create_task(loop.run()) + msg = InboundMessage( + channel="websocket", + sender_id="cron", + chat_id="chat-1", + content="scheduled work", + metadata={ + CRON_TRIGGER_META: {"job_id": "job-1", "run_id": "run-1"}, + CRON_DEFER_UNTIL_IDLE_META: True, + }, + session_key_override=session_key, + ) + await loop.bus.publish_inbound(msg) + + for _ in range(20): + if loop._cron_turns.deferred_queues.get(session_key): + break + await asyncio.sleep(0.05) + + loop.stop() + await asyncio.wait_for(run_task, timeout=2) + + assert pending.empty() + assert loop._dispatch.await_count == 0 + assert loop._cron_turns.deferred_queues[session_key] == [msg] + assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"} + + await loop._cron_turns.publish_next_deferred(session_key) + queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5) + assert queued is msg + assert session_key not in loop._cron_turns.deferred_queues + assert loop.pending_cron_job_ids_for_session(session_key) == set() + + +@pytest.mark.asyncio +async def test_submitted_cron_turn_reports_pending_until_completed(tmp_path): + """Bound cron jobs remain marked pending while their session turn is in flight.""" + from nanobot.bus.events import InboundMessage, OutboundMessage + from nanobot.cron.session_turns import CRON_TRIGGER_META + + loop = _make_loop(tmp_path) + loop._running = True + + session_key = "websocket:chat-1" + msg = InboundMessage( + channel="websocket", + sender_id="cron", + chat_id="chat-1", + content="scheduled work", + metadata={CRON_TRIGGER_META: {"job_id": "job-1", "run_id": "run-1"}}, + session_key_override=session_key, + ) + + submit_task = asyncio.create_task(loop.submit_cron_turn(msg)) + queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5) + + assert queued is msg + assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"} + + response = OutboundMessage( + channel="websocket", + chat_id="chat-1", + content="done", + ) + loop._cron_turns.complete(msg, response=response) + + assert await asyncio.wait_for(submit_task, timeout=0.5) is response + assert loop.pending_cron_job_ids_for_session(session_key) == set() + + @pytest.mark.asyncio async def test_pending_queue_preserves_overflow_for_next_injection_cycle(tmp_path): """Pending queue should leave overflow messages queued for later drains.""" 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_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/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 8eba67588..63dba4861 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -14,6 +14,7 @@ import pytest from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig from nanobot.cron.service import CronService from nanobot.cron.types import CronJob, CronPayload, CronSchedule +from nanobot.session.keys import UNIFIED_SESSION_KEY from nanobot.session.manager import Session, SessionManager from nanobot.webui.gateway_services import GatewayServices, build_gateway_services @@ -29,6 +30,7 @@ def _make_handler( workspace_path: Path | None = None, runtime_model_name: Any | None = None, cron_service: CronService | None = None, + cron_pending_job_ids: Any | None = None, ) -> GatewayServices: config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg workspace = workspace_path or Path.cwd() @@ -43,6 +45,7 @@ def _make_handler( runtime_surface="browser", runtime_capabilities_overrides=None, cron_service=cron_service, + cron_pending_job_ids=cron_pending_job_ids, ) @@ -55,6 +58,7 @@ def _ch( port: int = _PORT, runtime_model_name: Any | None = None, cron_service: CronService | None = None, + cron_pending_job_ids: Any | None = None, **extra: Any, ) -> WebSocketChannel: cfg: dict[str, Any] = { @@ -73,6 +77,7 @@ def _ch( workspace_path=workspace_path, runtime_model_name=runtime_model_name, cron_service=cron_service, + cron_pending_job_ids=cron_pending_job_ids, ) return WebSocketChannel(cfg, bus, gateway=gateway) @@ -176,18 +181,30 @@ async def test_session_automations_route_filters_by_webui_session( ) -> None: cron = CronService(tmp_path / "cron" / "jobs.json") hourly = CronSchedule(kind="every", every_ms=3_600_000) + pending_job_id = "" for name, message, to in ( ("Morning check", "Check the project status", "abc"), ("Other session", "Do not show", "other"), ): - cron.add_job( + job = cron.add_job( name=name, schedule=hourly, message=message, - channel="websocket", - to=to, session_key=f"websocket:{to}", + origin_channel="websocket", + origin_chat_id=to, ) + if name == "Morning check": + pending_job_id = job.id + cron.add_job( + name="Legacy same target", + schedule=hourly, + message="Legacy job should be migrated", + deliver=True, + channel="websocket", + to="abc", + session_key="websocket:abc", + ) cron.register_system_job( CronJob( id="heartbeat", @@ -200,6 +217,7 @@ async def test_session_automations_route_filters_by_webui_session( bus, session_manager=_seed_session(tmp_path, key="websocket:abc"), cron_service=cron, + cron_pending_job_ids=lambda key: {pending_job_id} if key == "websocket:abc" else set(), port=29914, ) server_task = asyncio.create_task(channel.start()) @@ -220,11 +238,66 @@ async def test_session_automations_route_filters_by_webui_session( assert resp.status_code == 200 body = resp.json() - assert [job["name"] for job in body["jobs"]] == ["Morning check"] + assert [job["name"] for job in body["jobs"]] == ["Morning check", "Legacy same target"] job = body["jobs"][0] assert job["schedule"]["kind"] == "every" assert job["schedule"]["every_ms"] == 3_600_000 assert job["payload"]["message"] == "Check the project status" + assert job["state"]["pending"] is True + assert body["jobs"][1]["state"]["pending"] is False + finally: + await channel.stop() + await server_task + + +@pytest.mark.asyncio +async def test_session_automations_route_ignores_unified_owner( + bus: MagicMock, tmp_path: Path +) -> None: + cron = CronService(tmp_path / "cron" / "jobs.json") + hourly = CronSchedule(kind="every", every_ms=3_600_000) + cron.add_job( + name="Unified check", + schedule=hourly, + message="Check the shared session", + session_key=UNIFIED_SESSION_KEY, + origin_channel="websocket", + origin_chat_id="abc", + ) + cron.add_job( + name="Visible chat job", + schedule=hourly, + message="Show for this chat", + session_key="websocket:abc", + origin_channel="websocket", + origin_chat_id="abc", + ) + channel = _ch( + bus, + session_manager=_seed_session(tmp_path, key="websocket:abc"), + cron_service=cron, + port=29917, + ) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + try: + boot = await _http_get("http://127.0.0.1:29917/webui/bootstrap") + token = boot.json()["token"] + auth = {"Authorization": f"Bearer {token}"} + + resp = await _http_get( + "http://127.0.0.1:29917/api/sessions/websocket%3Aabc/automations", + headers=auth, + ) + assert resp.status_code == 200 + assert [job["name"] for job in resp.json()["jobs"]] == ["Visible chat job"] + + resp = await _http_get( + "http://127.0.0.1:29917/api/sessions/websocket%3Aother/automations", + headers=auth, + ) + assert resp.status_code == 200 + assert resp.json()["jobs"] == [] finally: await channel.stop() await server_task @@ -659,6 +732,141 @@ async def test_session_delete_removes_file( await server_task +@pytest.mark.asyncio +async def test_session_delete_blocks_when_bound_automation_exists( + bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + sm = _seed_session(tmp_path, key="websocket:doomed") + cron = CronService(tmp_path / "cron" / "jobs.json") + cron.add_job( + name="Daily check", + schedule=CronSchedule(kind="every", every_ms=86_400_000), + message="Check the repo", + session_key="websocket:doomed", + origin_channel="websocket", + origin_chat_id="doomed", + ) + channel = _ch(bus, session_manager=sm, cron_service=cron, port=29915) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + try: + boot = await _http_get("http://127.0.0.1:29915/webui/bootstrap") + token = boot.json()["token"] + auth = {"Authorization": f"Bearer {token}"} + + path = sm._get_session_path("websocket:doomed") + resp = await _http_get( + "http://127.0.0.1:29915/api/sessions/websocket:doomed/delete", + headers=auth, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["deleted"] is False + assert body["blocked_by_automations"] is True + assert [job["name"] for job in body["automations"]] == ["Daily check"] + assert path.exists() + assert cron.list_bound_cron_jobs_for_session("websocket:doomed") + finally: + await channel.stop() + await server_task + + +@pytest.mark.asyncio +async def test_session_delete_can_cascade_bound_automations( + bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + sm = _seed_session(tmp_path, key="websocket:doomed") + cron = CronService(tmp_path / "cron" / "jobs.json") + cron.add_job( + name="Daily check", + schedule=CronSchedule(kind="every", every_ms=86_400_000), + message="Check the repo", + session_key="websocket:doomed", + origin_channel="websocket", + origin_chat_id="doomed", + ) + cron.add_job( + name="Legacy same target", + schedule=CronSchedule(kind="every", every_ms=86_400_000), + message="Legacy job remains", + channel="websocket", + to="doomed", + ) + channel = _ch(bus, session_manager=sm, cron_service=cron, port=29916) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + try: + boot = await _http_get("http://127.0.0.1:29916/webui/bootstrap") + token = boot.json()["token"] + auth = {"Authorization": f"Bearer {token}"} + + path = sm._get_session_path("websocket:doomed") + resp = await _http_get( + "http://127.0.0.1:29916/api/sessions/websocket:doomed/delete?delete_automations=true", + headers=auth, + ) + + assert resp.status_code == 200 + assert resp.json()["deleted"] is True + assert not path.exists() + assert cron.list_bound_cron_jobs_for_session("websocket:doomed") == [] + assert cron.list_jobs(include_disabled=True) == [] + finally: + await channel.stop() + await server_task + + +@pytest.mark.asyncio +async def test_session_delete_blocks_origin_automation_when_unified_enabled( + bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + sm = _seed_session(tmp_path, key="websocket:doomed") + cron = CronService(tmp_path / "cron" / "jobs.json") + cron.add_job( + name="Chat daily check", + schedule=CronSchedule(kind="every", every_ms=86_400_000), + message="Check this chat", + session_key="websocket:doomed", + origin_channel="websocket", + origin_chat_id="doomed", + ) + channel = _ch( + bus, + session_manager=sm, + cron_service=cron, + port=29918, + ) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + try: + boot = await _http_get("http://127.0.0.1:29918/webui/bootstrap") + token = boot.json()["token"] + auth = {"Authorization": f"Bearer {token}"} + + path = sm._get_session_path("websocket:doomed") + resp = await _http_get( + "http://127.0.0.1:29918/api/sessions/websocket:doomed/delete", + headers=auth, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["deleted"] is False + assert body["blocked_by_automations"] is True + assert [job["name"] for job in body["automations"]] == ["Chat daily check"] + assert path.exists() + assert [job.name for job in cron.list_bound_cron_jobs_for_session("websocket:doomed")] == [ + "Chat daily check" + ] + finally: + await channel.stop() + await server_task + + @pytest.mark.asyncio async def test_session_routes_accept_percent_encoded_websocket_keys( bus: MagicMock, tmp_path: Path diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 114b90df3..953880bb2 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -8,13 +8,20 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from typer.testing import CliRunner -from nanobot.bus.events import OutboundMessage -from nanobot.cli.commands import _proactive_delivery_metadata, app +from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.cli.commands import app from nanobot.config.schema import Config +from nanobot.cron.service import CronJobSkippedError +from nanobot.cron.session_turns import CRON_DEFER_UNTIL_IDLE_META, CRON_TRIGGER_META from nanobot.cron.types import CronJob, CronPayload +from nanobot.cron.webui_metadata import cron_proactive_delivery_metadata from nanobot.providers.factory import ProviderSnapshot, make_provider from nanobot.providers.openai_codex_provider import _strip_model_prefix from nanobot.providers.registry import find_by_name +from nanobot.webui.metadata import ( + WEBUI_MESSAGE_SOURCE_METADATA_KEY, + WEBUI_TURN_METADATA_KEY, +) runner = CliRunner() @@ -22,11 +29,11 @@ runner = CliRunner() def test_proactive_websocket_delivery_gets_fresh_turn_id() -> None: metadata = { "webui": True, - "webui_turn_id": "turn-that-created-the-reminder", + WEBUI_TURN_METADATA_KEY: "turn-that-created-the-reminder", "workspace_scope": {"mode": "default"}, } - out = _proactive_delivery_metadata( + out = cron_proactive_delivery_metadata( "websocket", metadata, turn_seed="cron:drink-water", @@ -35,9 +42,9 @@ def test_proactive_websocket_delivery_gets_fresh_turn_id() -> None: assert out["webui"] is True assert out["workspace_scope"] == {"mode": "default"} - assert out["webui_turn_id"].startswith("cron:drink-water:") - assert out["webui_turn_id"] != metadata["webui_turn_id"] - assert out["_webui_message_source"] == {"kind": "cron", "label": "drink water"} + assert out[WEBUI_TURN_METADATA_KEY].startswith("cron:drink-water:") + assert out[WEBUI_TURN_METADATA_KEY] != metadata[WEBUI_TURN_METADATA_KEY] + assert out[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {"kind": "cron", "label": "drink water"} def _fake_provider(): @@ -1338,7 +1345,7 @@ def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: assert seen["cron_store"] == config.workspace_path / "cron" / "jobs.json" -def test_gateway_cron_evaluator_receives_scheduled_reminder_context( +def test_gateway_unbound_agent_cron_is_skipped( monkeypatch, tmp_path: Path ) -> None: config_file = tmp_path / "instance" / "config.json" @@ -1403,11 +1410,10 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context( seen["agent"] = self async def process_direct(self, *_args, **_kwargs): - return OutboundMessage( - channel="telegram", - chat_id="user-1", - content="Time to stretch.", - ) + raise AssertionError("unbound cron job must not use process_direct") + + async def submit_cron_turn(self, _msg: InboundMessage): + raise AssertionError("unbound cron job must not run as a bound cron turn") async def close_mcp(self) -> None: return None @@ -1423,16 +1429,10 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context( raise _StopGatewayError("stop") async def _capture_evaluate_response( - response: str, - task_context: str, - provider_arg: object, - model: str, + *_args, + **_kwargs, ) -> bool: - seen["response"] = response - seen["task_context"] = task_context - seen["provider"] = provider_arg - seen["model"] = model - return True + raise AssertionError("unbound cron job must not be evaluated for delivery") monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop) @@ -1465,124 +1465,71 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context( ), ) - response = asyncio.run(cron.on_job(job)) + with pytest.raises(CronJobSkippedError, match="unbound agent cron job"): + asyncio.run(cron.on_job(job)) - assert response == "Time to stretch." - assert seen["response"] == "Time to stretch." - assert seen["provider"] is runtime_provider - assert seen["model"] == "runtime-model" - assert seen["task_context"] == ( - "The scheduled time has arrived. Deliver this reminder to the user now, " - "as a brief and natural message in their language. Speak directly to them — " - "do not narrate progress, summarize, include user IDs, or add status reports " - "like 'Done' or 'Reminded'.\n\n" - "Reminder: Remind me to stretch." - ) - bus.publish_outbound.assert_awaited_once_with( - OutboundMessage( - channel="telegram", - chat_id="user-1", - content="Time to stretch.", - ) - ) - assert seen["session_key"] == "telegram:user-1" - saved_session = seen["saved_session"] - assert isinstance(saved_session, _FakeSession) - assert saved_session.messages == [ - { - "role": "assistant", - "content": "Time to stretch.", - "_channel_delivery": True, - } - ] - - bus.publish_outbound.reset_mock() - old_turn_id = "turn-that-created-the-reminder" - websocket_job = CronJob( - id="drink-water", - name="drink water", - payload=CronPayload( - message="Remind me to drink water.", - deliver=True, - channel="websocket", - to="chat-1", - channel_meta={ - "webui": True, - "webui_turn_id": old_turn_id, - "workspace_scope": {"mode": "default"}, - }, - session_key="websocket:chat-1", - ), - ) - - response = asyncio.run(cron.on_job(websocket_job)) - - assert response == "Time to stretch." - bus.publish_outbound.assert_awaited_once() - delivered = bus.publish_outbound.await_args.args[0] - assert delivered.channel == "websocket" - assert delivered.chat_id == "chat-1" - assert delivered.metadata["webui"] is True - assert delivered.metadata["workspace_scope"] == {"mode": "default"} - assert delivered.metadata["webui_turn_id"].startswith("cron:drink-water:") - assert delivered.metadata["webui_turn_id"] != old_turn_id - assert delivered.metadata["_webui_message_source"] == { - "kind": "cron", - "label": "drink water", - } + bus.publish_outbound.assert_not_awaited() -def test_gateway_cron_job_suppresses_intermediate_progress( +def test_gateway_bound_cron_runs_as_session_turn( monkeypatch, tmp_path: Path ) -> None: - """Cron jobs must pass on_progress=_silent to process_direct so that - tool hints and streaming deltas are never leaked to the user channel - before evaluate_response decides whether to deliver.""" config_file = tmp_path / "instance" / "config.json" config_file.parent.mkdir(parents=True) config_file.write_text("{}") config = Config() config.agents.defaults.workspace = str(tmp_path / "config-workspace") + provider = _fake_provider() bus = MagicMock() bus.publish_outbound = AsyncMock() - seen: dict[str, object] = {} + seen: dict[str, object] = {"run_records": []} monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) - monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider()) + monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider) monkeypatch.setattr( "nanobot.providers.factory.build_provider_snapshot", - lambda _config: _test_provider_snapshot(object(), _config), + lambda _config: _test_provider_snapshot(provider, _config), ) monkeypatch.setattr( "nanobot.providers.factory.load_provider_snapshot", - lambda _config_path=None: _test_provider_snapshot(object(), config), + lambda _config_path=None: _test_provider_snapshot(provider, config), ) monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus) - monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object()) + + class _FakeSessionManager: + def __init__(self, _workspace: Path) -> None: + pass + + monkeypatch.setattr("nanobot.session.manager.SessionManager", _FakeSessionManager) class _FakeCron: def __init__(self, _store_path: Path) -> None: self.on_job = None seen["cron"] = self + def write_run_record(self, run_id: str, record: dict[str, object]) -> None: + seen["run_records"].append((run_id, record)) + class _FakeAgentLoop: @classmethod def from_config(cls, config, bus=None, **extra): return cls(**extra) + def __init__(self, *args, **kwargs) -> None: self.model = "test-model" - self.provider = object() + self.provider = kwargs.get("provider", object()) self.tools = {} + seen["agent"] = self - async def process_direct(self, *_args, on_progress=None, **_kwargs): - seen["on_progress"] = on_progress + async def submit_cron_turn(self, msg: InboundMessage): + seen["cron_msg"] = msg return OutboundMessage( - channel="telegram", - chat_id="user-1", - content="Done.", + channel=msg.channel, + chat_id=msg.chat_id, + content="Checked the repo.", ) async def close_mcp(self) -> None: @@ -1598,41 +1545,131 @@ def test_gateway_cron_job_suppresses_intermediate_progress( def __init__(self, *_args, **_kwargs) -> None: raise _StopGatewayError("stop") - async def _always_reject(*_args, **_kwargs) -> bool: - return False + async def _unexpected_evaluator(*_args, **_kwargs) -> bool: + raise AssertionError("bound cron must not use legacy response evaluator") monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup) - monkeypatch.setattr( - "nanobot.cli.commands.evaluate_response", - _always_reject, - ) + monkeypatch.setattr("nanobot.cli.commands.evaluate_response", _unexpected_evaluator) result = runner.invoke(app, ["gateway", "--config", str(config_file)]) assert isinstance(result.exception, _StopGatewayError) cron = seen["cron"] job = CronJob( - id="cron-silent-test", - name="test-silent", + id="repo-check", + name="Repo check", payload=CronPayload( - message="Run something.", - deliver=True, - channel="telegram", - to="user-1", + message="Check repository health.", + session_key="websocket:chat-1", + origin_channel="websocket", + origin_chat_id="chat-1", ), ) + response = asyncio.run(cron.on_job(job)) - assert response == "Done." - # on_progress must be a callable (the _silent noop), not None and not bus_progress - assert seen["on_progress"] is not None - assert callable(seen["on_progress"]) - # Verify it actually swallows calls (no side effects) - asyncio.run(seen["on_progress"]("tool_hint", "🔧 $ echo test")) - # Nothing published to bus since evaluator rejected - bus.publish_outbound.assert_not_awaited() + assert response == "Checked the repo." + msg = seen["cron_msg"] + assert isinstance(msg, InboundMessage) + assert msg.channel == "websocket" + assert msg.chat_id == "chat-1" + assert msg.sender_id == "cron" + assert msg.session_key_override == "websocket:chat-1" + assert "Cron job: Check repository health." in msg.content + assert msg.metadata["webui"] is True + assert msg.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == { + "kind": "cron", + "label": "Repo check", + } + trigger = msg.metadata[CRON_TRIGGER_META] + assert trigger["job_id"] == "repo-check" + assert trigger["job_name"] == "Repo check" + assert trigger["persist_content"] == ( + "Scheduled cron job triggered: Repo check\n\nCheck repository health." + ) + assert msg.metadata[CRON_DEFER_UNTIL_IDLE_META] is True + statuses = [record["status"] for _run_id, record in seen["run_records"]] + assert statuses == ["queued", "ok"] + assert seen["run_records"][0][0] == seen["run_records"][1][0] + + discord_job = CronJob( + id="thread-check", + name="Thread check", + payload=CronPayload( + message="Check the Discord thread.", + session_key="discord:456:thread:777", + origin_channel="discord", + origin_chat_id="777", + origin_metadata={ + "context_chat_id": "456", + "parent_channel_id": "456", + "thread_id": "777", + }, + ), + ) + + response = asyncio.run(cron.on_job(discord_job)) + + assert response == "Checked the repo." + msg = seen["cron_msg"] + assert isinstance(msg, InboundMessage) + assert msg.channel == "discord" + assert msg.chat_id == "777" + assert msg.session_key_override == "discord:456:thread:777" + assert msg.metadata["context_chat_id"] == "456" + assert msg.metadata["parent_channel_id"] == "456" + assert msg.metadata["thread_id"] == "777" + + telegram_job = CronJob( + id="telegram-topic", + name="Telegram topic", + payload=CronPayload( + message="Check the Telegram topic.", + session_key="telegram:-100123:topic:42", + origin_channel="telegram", + origin_chat_id="-100123", + origin_metadata={"message_thread_id": 42}, + ), + ) + + response = asyncio.run(cron.on_job(telegram_job)) + + assert response == "Checked the repo." + msg = seen["cron_msg"] + assert isinstance(msg, InboundMessage) + assert msg.channel == "telegram" + assert msg.chat_id == "-100123" + assert msg.session_key_override == "telegram:-100123:topic:42" + assert msg.metadata["message_thread_id"] == 42 + + feishu_job = CronJob( + id="feishu-topic", + name="Feishu topic", + payload=CronPayload( + message="Check the Feishu topic.", + session_key="feishu:oc_abc:om_root123", + origin_channel="feishu", + origin_chat_id="oc_abc", + origin_metadata={ + "chat_type": "group", + "message_id": "om_root123", + "thread_id": "om_root123", + }, + ), + ) + + response = asyncio.run(cron.on_job(feishu_job)) + + assert response == "Checked the repo." + msg = seen["cron_msg"] + assert isinstance(msg, InboundMessage) + assert msg.channel == "feishu" + assert msg.chat_id == "oc_abc" + assert msg.session_key_override == "feishu:oc_abc:om_root123" + assert msg.metadata["message_id"] == "om_root123" + assert msg.metadata["thread_id"] == "om_root123" def test_gateway_workspace_override_does_not_migrate_legacy_cron( diff --git a/tests/cron/test_cron_service.py b/tests/cron/test_cron_service.py index fa304e06e..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 @@ -43,7 +43,7 @@ def test_add_job_accepts_valid_timezone(tmp_path) -> None: assert job.state.next_run_at_ms is not None -def test_add_job_preserves_channel_meta_and_session_key(tmp_path) -> None: +def test_add_job_migrates_legacy_delivery_context(tmp_path) -> None: service = CronService(tmp_path / "cron" / "jobs.json") meta = {"slack": {"thread_ts": "1234567890.123456", "channel_type": "channel"}} job = service.add_job( @@ -56,13 +56,160 @@ def test_add_job_preserves_channel_meta_and_session_key(tmp_path) -> None: channel_meta=meta, session_key="slack:C123:1234567890.123456", ) - assert job.payload.channel_meta == meta + assert job.payload.deliver is False + assert job.payload.channel is None + assert job.payload.to is None + assert job.payload.channel_meta == {} assert job.payload.session_key == "slack:C123:1234567890.123456" + assert job.payload.origin_channel == "slack" + assert job.payload.origin_chat_id == "C123" + assert job.payload.origin_metadata == meta reloaded = service.get_job(job.id) assert reloaded is not None - assert reloaded.payload.channel_meta == meta + assert reloaded.payload.channel_meta == {} assert reloaded.payload.session_key == "slack:C123:1234567890.123456" + assert reloaded.payload.origin_channel == "slack" + assert reloaded.payload.origin_chat_id == "C123" + assert reloaded.payload.origin_metadata == meta + + +def test_load_store_migrates_legacy_delivery_context(tmp_path) -> None: + store_path = tmp_path / "cron" / "jobs.json" + store_path.parent.mkdir(parents=True) + store_path.write_text( + json.dumps( + { + "version": 1, + "jobs": [ + { + "id": "legacy-1", + "name": "Legacy reminder", + "enabled": True, + "schedule": {"kind": "every", "everyMs": 60_000}, + "payload": { + "kind": "agent_turn", + "message": "check status", + "deliver": True, + "channel": "telegram", + "to": "user-1", + "channelMeta": {"message_thread_id": 42}, + "sessionKey": "telegram:user-1:topic:42", + }, + "state": {}, + "createdAtMs": 1, + "updatedAtMs": 1, + } + ], + } + ), + encoding="utf-8", + ) + + job = CronService(store_path).get_job("legacy-1") + + assert job is not None + assert job.payload.session_key == "telegram:user-1:topic:42" + assert job.payload.origin_channel == "telegram" + assert job.payload.origin_chat_id == "user-1" + assert job.payload.origin_metadata == {"message_thread_id": 42} + assert job.payload.deliver is False + assert job.payload.channel is None + assert job.payload.to is None + assert job.payload.channel_meta == {} + + +def test_load_store_disables_malformed_legacy_payload(tmp_path) -> None: + store_path = tmp_path / "cron" / "jobs.json" + store_path.parent.mkdir(parents=True) + store_path.write_text( + json.dumps( + { + "version": 1, + "jobs": [ + { + "id": "legacy-bad", + "name": "Broken legacy", + "enabled": True, + "schedule": {"kind": "every", "everyMs": 60_000}, + "payload": { + "kind": "agent_turn", + "message": "check status", + "deliver": True, + }, + "state": {"nextRunAtMs": 123}, + "createdAtMs": 1, + "updatedAtMs": 1, + } + ], + } + ), + encoding="utf-8", + ) + + job = CronService(store_path).get_job("legacy-bad") + + assert job is not None + assert job.enabled is False + assert job.state.next_run_at_ms is None + assert job.state.last_status == "error" + assert "missing channel/to" in (job.state.last_error or "") + assert job.payload.deliver is False + + +def test_list_bound_agent_jobs_includes_migrated_legacy_delivery_payloads(tmp_path) -> None: + service = CronService(tmp_path / "cron" / "jobs.json") + schedule = CronSchedule(kind="every", every_ms=60_000) + bound = service.add_job( + name="Bound", + schedule=schedule, + message="new bound job", + session_key="websocket:chat-1", + origin_channel="websocket", + origin_chat_id="chat-1", + ) + migrated = service.add_job( + name="Legacy same session", + schedule=schedule, + message="legacy job", + deliver=True, + channel="websocket", + to="chat-1", + session_key="websocket:chat-1", + ) + + assert service.list_bound_cron_jobs_for_session("websocket:chat-1") == [bound, migrated] + + +def test_add_job_preserves_origin_delivery_context(tmp_path) -> None: + service = CronService(tmp_path / "cron" / "jobs.json") + metadata = {"slack": {"thread_ts": "1234567890.123456", "channel_type": "channel"}} + + job = service.add_job( + name="bound thread", + schedule=CronSchedule(kind="every", every_ms=60_000), + message="hello", + session_key="slack:C123:1234567890.123456", + origin_channel="slack", + origin_chat_id="C123", + origin_metadata=metadata, + ) + + assert job.payload.origin_channel == "slack" + assert job.payload.origin_chat_id == "C123" + assert job.payload.origin_metadata == metadata + + raw = json.loads((tmp_path / "cron" / "action.jsonl").read_text(encoding="utf-8")) + payload = raw["params"]["payload"] + assert payload["origin_channel"] == "slack" + assert payload["origin_chat_id"] == "C123" + assert payload["origin_metadata"] == metadata + + reloaded = service.get_job(job.id) + assert reloaded is not None + assert reloaded.payload.origin_channel == "slack" + assert reloaded.payload.origin_chat_id == "C123" + assert reloaded.payload.origin_metadata == metadata @pytest.mark.asyncio @@ -81,19 +228,31 @@ async def test_channel_meta_and_session_key_survive_store_reload(tmp_path) -> No to="C123", channel_meta=meta, session_key="slack:C123:1234567890.123456", + origin_channel="slack", + origin_chat_id="C123", + origin_metadata=meta, ) finally: service.stop() raw = json.loads(store_path.read_text(encoding="utf-8")) payload = raw["jobs"][0]["payload"] - assert payload["channelMeta"] == meta + assert payload["deliver"] is False + assert payload["channel"] is None + assert payload["to"] is None + assert payload["channelMeta"] == {} assert payload["sessionKey"] == "slack:C123:1234567890.123456" + assert payload["originChannel"] == "slack" + assert payload["originChatId"] == "C123" + assert payload["originMetadata"] == meta reloaded = CronService(store_path).get_job(job.id) assert reloaded is not None - assert reloaded.payload.channel_meta == meta + assert reloaded.payload.channel_meta == {} assert reloaded.payload.session_key == "slack:C123:1234567890.123456" + assert reloaded.payload.origin_channel == "slack" + assert reloaded.payload.origin_chat_id == "C123" + assert reloaded.payload.origin_metadata == meta @pytest.mark.asyncio @@ -137,6 +296,57 @@ async def test_run_history_records_errors(tmp_path) -> None: assert loaded.state.run_history[0].error == "boom" +@pytest.mark.asyncio +async def test_run_history_records_skipped_jobs(tmp_path) -> None: + store_path = tmp_path / "cron" / "jobs.json" + + async def skip(_): + raise CronJobSkippedError("missing session binding") + + service = CronService(store_path, on_job=skip) + job = service.add_job( + name="skip", + schedule=CronSchedule(kind="every", every_ms=60_000), + message="hello", + ) + await service.run_job(job.id) + + loaded = service.get_job(job.id) + assert loaded is not None + assert loaded.state.last_status == "skipped" + assert loaded.state.last_error == "missing session binding" + assert len(loaded.state.run_history) == 1 + assert loaded.state.run_history[0].status == "skipped" + assert loaded.state.run_history[0].error == "missing session binding" + + +@pytest.mark.asyncio +async def test_run_history_records_job_cancellation(tmp_path) -> None: + store_path = tmp_path / "cron" / "jobs.json" + + async def cancel(_): + raise asyncio.CancelledError("turn cancelled") + + service = CronService(store_path, on_job=cancel) + job = service.add_job( + name="cancel", + schedule=CronSchedule(kind="every", every_ms=60_000), + message="hello", + session_key="websocket:chat-1", + ) + + assert await service.run_job(job.id) is True + + loaded = service.get_job(job.id) + assert loaded is not None + assert loaded.state.last_status == "error" + assert loaded.state.last_error == "turn cancelled" + assert len(loaded.state.run_history) == 1 + assert loaded.state.run_history[0].status == "error" + assert loaded.state.run_history[0].error == "turn cancelled" + assert loaded.state.next_run_at_ms is not None + + @pytest.mark.asyncio async def test_run_history_trimmed_to_max(tmp_path) -> None: store_path = tmp_path / "cron" / "jobs.json" @@ -559,28 +769,22 @@ def test_update_job_offline_writes_action(tmp_path) -> None: assert last["params"]["name"] == "updated-offline" -def test_update_job_sentinel_channel_and_to(tmp_path) -> None: - """Passing None clears channel/to; omitting leaves them unchanged.""" +def test_update_job_migrates_legacy_delivery_target(tmp_path) -> None: service = CronService(tmp_path / "cron" / "jobs.json") job = service.add_job( name="sentinel", schedule=CronSchedule(kind="every", every_ms=60_000), message="hello", - channel="telegram", - to="user123", ) - assert job.payload.channel == "telegram" - assert job.payload.to == "user123" - result = service.update_job(job.id, name="renamed") - assert isinstance(result, CronJob) - assert result.payload.channel == "telegram" - assert result.payload.to == "user123" - - result = service.update_job(job.id, channel=None, to=None) + result = service.update_job(job.id, channel="telegram", to="user123") assert isinstance(result, CronJob) + assert result.payload.session_key == "telegram:user123" + assert result.payload.origin_channel == "telegram" + assert result.payload.origin_chat_id == "user123" assert result.payload.channel is None assert result.payload.to is None + assert result.payload.channel_meta == {} @pytest.mark.asyncio diff --git a/tests/cron/test_cron_tool_list.py b/tests/cron/test_cron_tool_list.py index b67879715..bcf518ed6 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,32 @@ def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None: assert job.schedule.at_ms == expected -def test_add_job_delivers_by_default(tmp_path) -> None: +def test_add_job_binds_current_session_key(tmp_path) -> None: tool = _make_tool(tmp_path) - tool.set_context(RequestContext(channel="telegram", chat_id="chat-1")) + tool.set_context( + RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1") + ) result = tool._add_job(None, "Morning standup", 60, None, None, None) assert result.startswith("Created job") job = tool._cron.list_jobs()[0] - assert job.payload.deliver is True + assert job.payload.session_key == "telegram:chat-1" + assert job.payload.origin_channel == "telegram" + assert job.payload.origin_chat_id == "chat-1" + assert job.payload.origin_metadata == {} + assert job.payload.channel is None + assert job.payload.to is None -def test_add_job_can_disable_delivery(tmp_path) -> None: +def test_add_job_requires_session_key(tmp_path) -> None: tool = _make_tool(tmp_path) tool.set_context(RequestContext(channel="telegram", chat_id="chat-1")) - result = tool._add_job(None, "Background refresh", 60, None, None, None, deliver=False) + result = tool._add_job(None, "Background refresh", 60, None, None, None) - assert result.startswith("Created job") - job = tool._cron.list_jobs()[0] - assert job.payload.deliver is False + assert result == "Error: scheduled cron jobs must be created from a chat session" + assert tool._cron.list_jobs() == [] def test_cron_schema_advertises_action_specific_requirements(tmp_path) -> None: @@ -375,7 +385,9 @@ def test_validate_params_requires_message_only_for_add(tmp_path) -> None: def test_add_job_empty_message_returns_actionable_error(tmp_path) -> None: tool = _make_tool(tmp_path) - tool.set_context(RequestContext(channel="telegram", chat_id="chat-1")) + tool.set_context( + RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1") + ) result = tool._add_job(None, "", 60, None, None, None) @@ -383,8 +395,8 @@ def test_add_job_empty_message_returns_actionable_error(tmp_path) -> None: assert "Retry including message=" in result -def test_add_job_captures_metadata_and_session_key(tmp_path) -> None: - """CronTool stores channel metadata and session_key when adding a job.""" +def test_add_job_captures_owner_and_origin_without_legacy_delivery_fields(tmp_path) -> None: + """CronTool stores owner/session identity separately from origin delivery context.""" tool = _make_tool(tmp_path) meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}} tool.set_context(RequestContext( @@ -396,8 +408,13 @@ def test_add_job_captures_metadata_and_session_key(tmp_path) -> None: jobs = tool._cron.list_jobs() assert len(jobs) == 1 - assert jobs[0].payload.channel_meta == meta assert jobs[0].payload.session_key == "slack:C99:111.222" + assert jobs[0].payload.origin_channel == "slack" + assert jobs[0].payload.origin_chat_id == "C99" + assert jobs[0].payload.origin_metadata == meta + assert jobs[0].payload.channel is None + assert jobs[0].payload.to is None + assert jobs[0].payload.channel_meta == {} def test_list_excludes_disabled_jobs(tmp_path) -> None: 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/cron/test_session_delivery.py b/tests/cron/test_session_delivery.py new file mode 100644 index 000000000..f20207a4b --- /dev/null +++ b/tests/cron/test_session_delivery.py @@ -0,0 +1,44 @@ +import pytest + +from nanobot.cron.session_delivery import origin_delivery_context +from nanobot.cron.types import CronJob, CronPayload + + +def test_origin_delivery_context_uses_explicit_origin_fields() -> None: + metadata = { + "context_chat_id": "456", + "parent_channel_id": "456", + "thread_id": "777", + } + job = CronJob( + id="thread-check", + name="Thread check", + payload=CronPayload( + message="check", + session_key="discord:456:thread:777", + origin_channel="discord", + origin_chat_id="777", + origin_metadata=metadata, + ), + ) + + channel, chat_id, returned_metadata = origin_delivery_context(job) + + assert channel == "discord" + assert chat_id == "777" + assert returned_metadata == metadata + assert returned_metadata is not metadata + + +def test_origin_delivery_context_rejects_missing_origin_fields() -> None: + job = CronJob( + id="old-bound", + name="Old bound job", + payload=CronPayload( + message="check", + session_key="websocket:chat-1", + ), + ) + + with pytest.raises(ValueError, match="missing origin delivery context"): + origin_delivery_context(job) diff --git a/tests/test_tool_contextvars.py b/tests/test_tool_contextvars.py index e2b7f66ab..4300ff901 100644 --- a/tests/test_tool_contextvars.py +++ b/tests/test_tool_contextvars.py @@ -4,11 +4,13 @@ import asyncio import pytest +from nanobot.agent.loop import AgentLoop from nanobot.agent.tools.context import RequestContext from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.spawn import SpawnTool from nanobot.cron.service import CronService +from nanobot.session.keys import UNIFIED_SESSION_KEY @pytest.mark.asyncio @@ -99,14 +101,18 @@ async def test_cron_tool_keeps_task_local_context(tmp_path) -> None: release = asyncio.Event() async def task_one() -> str: - tool.set_context(RequestContext(channel="feishu", chat_id="chat-a")) + tool.set_context( + RequestContext(channel="feishu", chat_id="chat-a", session_key="feishu:chat-a") + ) entered.set() await release.wait() return await tool.execute(action="add", message="first", every_seconds=60) async def task_two() -> str: await entered.wait() - tool.set_context(RequestContext(channel="email", chat_id="chat-b")) + tool.set_context( + RequestContext(channel="email", chat_id="chat-b", session_key="email:chat-b") + ) release.set() return await tool.execute(action="add", message="second", every_seconds=60) @@ -116,8 +122,11 @@ async def test_cron_tool_keeps_task_local_context(tmp_path) -> None: assert result_two.startswith("Created job") jobs = tool._cron.list_jobs() - assert {job.payload.channel for job in jobs} == {"feishu", "email"} - assert {job.payload.to for job in jobs} == {"chat-a", "chat-b"} + assert {job.payload.session_key for job in jobs} == {"feishu:chat-a", "email:chat-b"} + assert {(job.payload.origin_channel, job.payload.origin_chat_id) for job in jobs} == { + ("feishu", "chat-a"), + ("email", "chat-b"), + } # --- Basic single-task regression tests --- @@ -228,15 +237,74 @@ async def test_spawn_tool_default_values_without_set_context() -> None: async def test_cron_tool_basic_set_context_and_execute(tmp_path) -> None: """Single task: set_context then add job should use correct target.""" tool = CronTool(CronService(tmp_path / "jobs.json")) - tool.set_context(RequestContext(channel="wechat", chat_id="user-789")) + tool.set_context( + RequestContext(channel="wechat", chat_id="user-789", session_key="wechat:user-789") + ) result = await tool.execute(action="add", message="standup", every_seconds=300) assert result.startswith("Created job") jobs = tool._cron.list_jobs() assert len(jobs) == 1 - assert jobs[0].payload.channel == "wechat" - assert jobs[0].payload.to == "user-789" + assert jobs[0].payload.session_key == "wechat:user-789" + assert jobs[0].payload.origin_channel == "wechat" + assert jobs[0].payload.origin_chat_id == "user-789" + + +@pytest.mark.asyncio +async def test_webui_cron_tool_uses_origin_session_when_unified_enabled(tmp_path) -> None: + """WebUI-created cron jobs stay attached to the creating chat.""" + tool = CronTool(CronService(tmp_path / "jobs.json")) + + class _Tools: + tool_names = ["cron"] + + def get(self, name: str): + return tool if name == "cron" else None + + loop = object.__new__(AgentLoop) + loop._unified_session = True + loop.tools = _Tools() + loop._set_tool_context( + "websocket", + "chat-123", + metadata={"webui": True}, + session_key=UNIFIED_SESSION_KEY, + ) + + result = await tool.execute(action="add", message="standup", every_seconds=300) + assert result.startswith("Created job") + + jobs = tool._cron.list_jobs() + assert len(jobs) == 1 + assert jobs[0].payload.session_key == "websocket:chat-123" + assert jobs[0].payload.origin_channel == "websocket" + assert jobs[0].payload.origin_chat_id == "chat-123" + assert jobs[0].payload.origin_metadata == {"webui": True} + + +@pytest.mark.asyncio +async def test_cron_tool_preserves_thread_scoped_session_key(tmp_path) -> None: + """Channel-provided thread session keys should remain the cron owner.""" + tool = CronTool(CronService(tmp_path / "jobs.json")) + tool.set_context( + RequestContext( + channel="slack", + chat_id="C123", + metadata={"slack": {"thread_ts": "1700.42"}}, + session_key="slack:C123:1700.42", + ) + ) + + result = await tool.execute(action="add", message="check thread", every_seconds=300) + assert result.startswith("Created job") + + jobs = tool._cron.list_jobs() + assert len(jobs) == 1 + assert jobs[0].payload.session_key == "slack:C123:1700.42" + assert jobs[0].payload.origin_channel == "slack" + assert jobs[0].payload.origin_chat_id == "C123" + assert jobs[0].payload.origin_metadata == {"slack": {"thread_ts": "1700.42"}} @pytest.mark.asyncio @@ -245,4 +313,4 @@ async def test_cron_tool_no_context_returns_error(tmp_path) -> None: tool = CronTool(CronService(tmp_path / "jobs.json")) result = await tool.execute(action="add", message="test", every_seconds=60) - assert result == "Error: no session context (channel/chat_id)" + assert result == "Error: scheduled cron jobs must be created from a chat session" 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/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) diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 70c6ef6cf..fa2976811 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, @@ -527,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); @@ -546,6 +555,7 @@ function Shell({ const [pendingDelete, setPendingDelete] = useState<{ key: string; label: string; + automations?: SessionAutomationJob[]; } | null>(null); const [pendingRename, setPendingRename] = useState<{ key: string; @@ -1270,33 +1280,47 @@ 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 ? (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, + hasAutomations ? { deleteAutomations: true } : undefined, + ); + if (result.blocked_by_automations) { + setPendingDelete({ + ...pendingDelete, + automations: result.automations ?? [], + }); + 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]); + const onRequestDelete = useCallback(async (key: string, label: string) => { + let automations: SessionAutomationJob[] = []; + try { + automations = await getSessionAutomations(key); + } catch { + // Delete remains protected by the backend block; prefetch only improves the first prompt. + } + setPendingDelete({ key, label, automations }); + }, [getSessionAutomations]); + const headerTitle = activeSession ? sidebarState.title_overrides[activeSession.key] || activeSession.title || @@ -1333,8 +1357,7 @@ function Shell({ loading, onNewChat, onSelect: onSelectChat, - onRequestDelete: (key: string, label: string) => - setPendingDelete({ key, label }), + onRequestDelete, onTogglePin, onRequestRename, onToggleArchive, @@ -1559,6 +1582,7 @@ function Shell({ setPendingDelete(null)} onConfirm={onConfirmDelete} /> diff --git a/webui/src/components/DeleteConfirm.tsx b/webui/src/components/DeleteConfirm.tsx index fdad9e3ac..fc73edd9c 100644 --- a/webui/src/components/DeleteConfirm.tsx +++ b/webui/src/components/DeleteConfirm.tsx @@ -8,12 +8,17 @@ 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 { open: boolean; title: string; + automations?: SessionAutomationJob[]; onCancel: () => void; onConfirm: () => void; } @@ -21,14 +26,19 @@ interface DeleteConfirmProps { export function DeleteConfirm({ open, title, + automations = [], onCancel, 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); return ( (!o ? onCancel() : undefined)}>
@@ -40,8 +50,35 @@ export function DeleteConfirm({ {t("deleteConfirm.title", { title })} - {t("deleteConfirm.description")} + {hasAutomations + ? t("deleteConfirm.automationsDescription") + : t("deleteConfirm.description")} + {hasAutomations ? ( +
+ {visibleAutomations.map((job) => ( +
+
+ {job.name || job.id} +
+
+ + {formatAutomationSchedule(job, t, locale)} + + · + {formatAutomationNextRun(job, t, locale)} +
+
+ ))} + {hiddenCount > 0 ? ( +
+ {t("deleteConfirm.moreAutomations", { + count: hiddenCount, + })} +
+ ) : null} +
+ ) : null} - {t("deleteConfirm.confirm")} + {hasAutomations + ? t("deleteConfirm.confirmWithAutomations") + : t("deleteConfirm.confirm")} ); } + +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/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/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 ab2aed727..f1faea403 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -5,15 +5,24 @@ import i18n from "@/i18n"; import { ApiError, deleteSession as apiDeleteSession, + fetchSessionAutomations, fetchWebuiThread, listSessions, } from "@/lib/api"; +import { hasPendingAgentActivity } from "@/lib/activity-timeline"; import { deriveTitle } from "@/lib/format"; -import type { ChatSummary, UIMessage, WorkspaceScopePayload } from "@/lib/types"; +import type { + ChatSummary, + SessionAutomationJob, + SessionDeleteResult, + UIMessage, + WorkspaceScopePayload, +} from "@/lib/types"; 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) => ({ @@ -23,6 +32,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[]; @@ -31,7 +50,11 @@ 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; + getSessionAutomations: (key: string) => Promise; } { const { client, token } = useClient(); const [sessions, setSessions] = useState([]); @@ -78,7 +101,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 @@ -104,7 +127,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) => [ @@ -124,15 +152,31 @@ 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; }, [], ); - 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. */ @@ -241,8 +285,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; @@ -326,13 +369,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/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 605e66f01..8020d3485 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", @@ -648,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 165940215..46a6c3ab0 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", @@ -648,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 2f87ec4b9..61182951c 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", @@ -648,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 72770eaea..12b8f1af3 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", @@ -648,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 fef2bf225..1c2a19628 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": "待機中", @@ -648,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 dcc0b2172..e59c78cb8 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": "대기 중", @@ -648,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 ee2e8463c..22faba4e6 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", @@ -648,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 29aa44baa..3ec1c3ac4 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": "空闲", @@ -648,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 b5b382fac..70f2a6242 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": "閒置", @@ -648,6 +663,7 @@ }, "next": { "label": "下次 {{time}}", + "pending": "即將執行", "disabled": "已暫停", "none": "沒有下次執行" } 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/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..aa7461f3e 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -113,11 +113,18 @@ export interface SessionAutomationJob { state: { next_run_at_ms?: number | null; last_status?: "ok" | "error" | "skipped" | string | null; + pending?: boolean; }; } export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; } +export interface SessionDeleteResult { + deleted: boolean; + blocked_by_automations?: boolean; + automations?: SessionAutomationJob[]; +} + export interface SkillSummary { name: string; description: string; @@ -875,6 +882,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/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..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,9 +148,12 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => { refresh: refreshSpy, createChat: createChatSpy, forkChat: async () => "fork-chat", - deleteChat: async (key: string) => { - await deleteChatSpy(key); + getSessionAutomations: getSessionAutomationsSpy, + 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 }; }, }; }, @@ -210,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(); @@ -433,6 +440,74 @@ describe("App layout", () => { expect(document.body.style.pointerEvents).not.toBe("none"); }, 15_000); + it("shows localized 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", + }, + ]; + 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: "侧边栏导航" }); + await waitFor(() => + expect( + within(sidebar).getByRole("button", { name: /^First chat$/ }), + ).toBeInTheDocument(), + ); + + fireEvent.pointerDown(screen.getByLabelText(/First chat.*会话操作/), { + button: 0, + }); + 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("这个对话有关联的自动任务。删除对话也会删除这些自动任务。"), + ).toBeInTheDocument(); + expect( + screen.queryByText("This chat has scheduled automations. Deleting it will also delete them."), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "删除对话和自动任务" })); + + 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 = [ { 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", 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 a606b249a..148bf4628 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([ @@ -187,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 () => { @@ -226,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); }); @@ -384,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,