Merge PR #4299: feat(cron): bind scheduled automations to sessions

feat(cron): bind scheduled automations to sessions
This commit is contained in:
Xubin Ren 2026-06-13 00:07:55 +08:00 committed by GitHub
commit dac4e39bcf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 2451 additions and 412 deletions

142
nanobot/agent/cron_turns.py Normal file
View File

@ -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

View File

@ -19,6 +19,7 @@ from nanobot.agent import context as agent_context
from nanobot.agent import model_presets as preset_helpers from nanobot.agent import model_presets as preset_helpers
from nanobot.agent.autocompact import AutoCompact from nanobot.agent.autocompact import AutoCompact
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.agent.cron_turns import CronTurnCoordinator
from nanobot.agent.hook import AgentHook, CompositeHook from nanobot.agent.hook import AgentHook, CompositeHook
from nanobot.agent.memory import Consolidator from nanobot.agent.memory import Consolidator
from nanobot.agent.progress_hook import AgentProgressHook 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.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.config.schema import AgentDefaults, ModelPresetConfig 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.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot from nanobot.providers.factory import ProviderSnapshot
from nanobot.security.workspace_access import ( from nanobot.security.workspace_access import (
@ -52,6 +56,7 @@ from nanobot.session.goal_state import (
runner_wall_llm_timeout_s, runner_wall_llm_timeout_s,
sustained_goal_active, sustained_goal_active,
) )
from nanobot.session.keys import UNIFIED_SESSION_KEY, session_key_for_channel
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
from nanobot.utils.document import extract_documents, reference_non_image_attachments from nanobot.utils.document import extract_documents, reference_non_image_attachments
from nanobot.utils.helpers import image_placeholder_text from nanobot.utils.helpers import image_placeholder_text
@ -72,8 +77,6 @@ if TYPE_CHECKING:
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
UNIFIED_SESSION_KEY = "unified:default"
class TurnState(Enum): class TurnState(Enum):
RESTORE = auto() RESTORE = auto()
COMPACT = auto() COMPACT = auto()
@ -300,6 +303,11 @@ class AgentLoop:
# When a session has an active task, new messages for that session # When a session has an active task, new messages for that session
# are routed here instead of creating a new task. # are routed here instead of creating a new task.
self._pending_queues: dict[str, asyncio.Queue] = {} 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. # NANOBOT_MAX_CONCURRENT_REQUESTS: <=0 means unlimited; default 3.
_max = int(os.environ.get("NANOBOT_MAX_CONCURRENT_REQUESTS", "3")) _max = int(os.environ.get("NANOBOT_MAX_CONCURRENT_REQUESTS", "3"))
self._concurrency_gate: asyncio.Semaphore | None = ( self._concurrency_gate: asyncio.Semaphore | None = (
@ -512,13 +520,11 @@ class AgentLoop:
"""Update context for all tools that need routing info.""" """Update context for all tools that need routing info."""
from nanobot.agent.tools.context import ContextAware from nanobot.agent.tools.context import ContextAware
if session_key is not None: effective_key = session_key or session_key_for_channel(
effective_key = session_key channel,
elif self._unified_session: chat_id,
effective_key = UNIFIED_SESSION_KEY unified_session=self._unified_session,
else: )
effective_key = f"{channel}:{chat_id}"
request_ctx = RequestContext( request_ctx = RequestContext(
channel=channel, channel=channel,
chat_id=chat_id, chat_id=chat_id,
@ -565,6 +571,12 @@ class AgentLoop:
def _runtime_events(self) -> RuntimeEventPublisher: def _runtime_events(self) -> RuntimeEventPublisher:
return ensure_runtime_event_publisher(self) 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( def _persist_user_message_early(
self, self,
msg: InboundMessage, 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: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
extra.update(kwargs) extra.update(kwargs)
text = msg.content if isinstance(msg.content, str) else "" 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) session.add_message("user", text, **extra)
self._mark_pending_user_turn(session) self._mark_pending_user_turn(session)
self.sessions.save(session) self.sessions.save(session)
@ -883,6 +899,16 @@ class AgentLoop:
self.commands.dispatch_priority, self.commands.dispatch_priority,
) )
continue 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 # If this session already has an active pending queue (i.e. a task
# is processing this session), route the message there for mid-turn # is processing this session), route the message there for mid-turn
# injection instead of creating a competing task. # injection instead of creating a competing task.
@ -996,7 +1022,12 @@ class AgentLoop:
session_key=session_key, session_key=session_key,
metadata=msg.metadata, metadata=msg.metadata,
) )
self._cron_turns.complete(msg, response=response)
except asyncio.CancelledError: except asyncio.CancelledError:
self._cron_turns.complete(
msg,
error=asyncio.CancelledError(),
)
logger.info("Task cancelled for session {}", session_key) logger.info("Task cancelled for session {}", session_key)
# Preserve partial context from the interrupted turn so # Preserve partial context from the interrupted turn so
# the user does not lose tool results and assistant # the user does not lose tool results and assistant
@ -1022,7 +1053,7 @@ class AgentLoop:
exc_info=True, exc_info=True,
) )
raise raise
except Exception: except Exception as exc:
logger.exception("Error processing message for session {}", session_key) logger.exception("Error processing message for session {}", session_key)
await self.bus.publish_outbound(OutboundMessage( await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, channel=msg.channel, chat_id=msg.chat_id,
@ -1035,6 +1066,7 @@ class AgentLoop:
session_key=session_key, session_key=session_key,
metadata=msg.metadata, metadata=msg.metadata,
) )
self._cron_turns.complete(msg, error=exc)
finally: finally:
# Drain any messages still in the pending queue and re-publish # Drain any messages still in the pending queue and re-publish
# them to the bus so they are processed as fresh inbound messages # them to the bus so they are processed as fresh inbound messages
@ -1065,12 +1097,14 @@ class AgentLoop:
msg, session_key, "idle" msg, session_key, "idle"
) )
self._runtime_events().clear_turn(session_key) self._runtime_events().clear_turn(session_key)
await self._cron_turns.publish_next_deferred(session_key)
finally: finally:
if pending is None: if pending is None:
await self._runtime_events().run_status_changed( await self._runtime_events().run_status_changed(
msg, session_key, "idle" msg, session_key, "idle"
) )
self._runtime_events().clear_turn(session_key) self._runtime_events().clear_turn(session_key)
await self._cron_turns.publish_next_deferred(session_key)
async def close_mcp(self) -> None: async def close_mcp(self) -> None:
"""Drain pending background archives, then close MCP connections.""" """Drain pending background archives, then close MCP connections."""

View File

@ -9,13 +9,13 @@ from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema, IntegerSchema,
StringSchema, StringSchema,
tool_parameters_schema, tool_parameters_schema,
) )
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronJobState, CronSchedule from nanobot.cron.types import CronJob, CronJobState, CronSchedule
from nanobot.session.keys import UNIFIED_SESSION_KEY
_CRON_PARAMETERS = tool_parameters_schema( _CRON_PARAMETERS = tool_parameters_schema(
action=StringSchema("Action to perform", enum=["add", "list", "remove"]), 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'). " "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00'). "
"Naive values use the tool's default timezone." "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')."), job_id=StringSchema("REQUIRED when action='remove'. Job ID to remove (obtain via action='list')."),
required=["action"], required=["action"],
description=( description=(
@ -61,10 +57,13 @@ class CronTool(Tool, ContextAware):
def __init__(self, cron_service: CronService, default_timezone: str = "UTC"): def __init__(self, cron_service: CronService, default_timezone: str = "UTC"):
self._cron = cron_service self._cron = cron_service
self._default_timezone = default_timezone 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._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) self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False)
@classmethod @classmethod
@ -76,11 +75,14 @@ class CronTool(Tool, ContextAware):
return cls(cron_service=ctx.cron_service, default_timezone=ctx.timezone) return cls(cron_service=ctx.cron_service, default_timezone=ctx.timezone)
def set_context(self, ctx: RequestContext) -> None: def set_context(self, ctx: RequestContext) -> None:
"""Set the current session context for delivery.""" """Set the current session context for scheduled cron job ownership."""
self._channel.set(ctx.channel) raw_key = f"{ctx.channel}:{ctx.chat_id}" if ctx.channel and ctx.chat_id else ""
self._chat_id.set(ctx.chat_id) self._session_key.set(
self._metadata.set(ctx.metadata) raw_key if ctx.session_key == UNIFIED_SESSION_KEY else (ctx.session_key or "")
self._session_key.set(ctx.session_key or f"{ctx.channel}:{ctx.chat_id}") )
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): def set_cron_context(self, active: bool):
"""Mark whether the tool is executing inside a cron job callback.""" """Mark whether the tool is executing inside a cron job callback."""
@ -147,7 +149,7 @@ class CronTool(Tool, ContextAware):
if action == "add": if action == "add":
if self._in_cron_context.get(): if self._in_cron_context.get():
return "Error: cannot schedule new jobs from within a cron job execution" 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": elif action == "list":
return self._list_jobs() return self._list_jobs()
elif action == "remove": elif action == "remove":
@ -162,7 +164,6 @@ class CronTool(Tool, ContextAware):
cron_expr: str | None, cron_expr: str | None,
tz: str | None, tz: str | None,
at: str | None, at: str | None,
deliver: bool = True,
) -> str: ) -> str:
if not message: if not message:
return ( return (
@ -170,10 +171,13 @@ class CronTool(Tool, ContextAware):
"describing what to do when the job triggers " "describing what to do when the job triggers "
"(e.g. the reminder text). Retry including message=\"...\"." "(e.g. the reminder text). Retry including message=\"...\"."
) )
channel = self._channel.get() session_key = self._session_key.get()
chat_id = self._chat_id.get() if not session_key:
if not channel or not chat_id: return "Error: scheduled cron jobs must be created from a chat session"
return "Error: no session context (channel/chat_id)" 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: if tz and not cron_expr:
return "Error: tz can only be used with cron_expr" return "Error: tz can only be used with cron_expr"
if tz: if tz:
@ -210,12 +214,11 @@ class CronTool(Tool, ContextAware):
name=name or message[:30], name=name or message[:30],
schedule=schedule, schedule=schedule,
message=message, message=message,
deliver=deliver,
channel=channel,
to=chat_id,
delete_after_run=delete_after, delete_after_run=delete_after,
channel_meta=self._metadata.get(), session_key=session_key,
session_key=self._session_key.get() or None, 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})" return f"Created job '{job.name}' (id: {job.id})"

View File

@ -58,6 +58,7 @@ class ChannelManager:
session_manager: "SessionManager | None" = None, session_manager: "SessionManager | None" = None,
cron_service: Any | None = None, cron_service: Any | None = None,
webui_runtime_model_name: Callable[[], str | None] | 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_static_dist: bool = True,
webui_runtime_surface: str = "browser", webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None, webui_runtime_capabilities: dict[str, Any] | None = None,
@ -67,6 +68,7 @@ class ChannelManager:
self._session_manager = session_manager self._session_manager = session_manager
self._cron_service = cron_service self._cron_service = cron_service
self._webui_runtime_model_name = webui_runtime_model_name 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_static_dist = webui_static_dist
self._webui_runtime_surface = webui_runtime_surface self._webui_runtime_surface = webui_runtime_surface
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {}) self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
@ -126,6 +128,7 @@ class ChannelManager:
runtime_surface=self._webui_runtime_surface, runtime_surface=self._webui_runtime_surface,
runtime_capabilities_overrides=self._webui_runtime_capabilities, runtime_capabilities_overrides=self._webui_runtime_capabilities,
cron_service=self._cron_service, cron_service=self._cron_service,
cron_pending_job_ids=self._webui_cron_pending_job_ids,
logger=logger, logger=logger,
) )
kwargs["gateway"] = gateway kwargs["gateway"] = gateway

View File

@ -5,10 +5,8 @@ import os
import select import select
import signal import signal
import sys import sys
import uuid
from collections.abc import Callable from collections.abc import Callable
from contextlib import nullcontext, suppress from contextlib import nullcontext, suppress
from contextvars import ContextVar
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -87,32 +85,6 @@ class SafeFileHistory(FileHistory):
super().store_string(_sanitize_surrogates(string)) 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( app = typer.Typer(
name="nanobot", name="nanobot",
context_settings={"help_option_names": ["-h", "--help"]}, context_settings={"help_option_names": ["-h", "--help"]},
@ -970,12 +942,13 @@ def _run_gateway(
health_server_enabled: bool = True, health_server_enabled: bool = True,
) -> None: ) -> None:
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up.""" """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.agent.tools.message import MessageTool
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.channels.manager import ChannelManager 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.cron.types import CronJob
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.providers.image_generation import image_gen_provider_configs
@ -1024,14 +997,14 @@ def _run_gateway(
schedule_background=lambda coro: agent._schedule_background(coro), schedule_background=lambda coro: agent._schedule_background(coro),
).subscribe(runtime_events) ).subscribe(runtime_events)
from nanobot.agent.loop import UNIFIED_SESSION_KEY
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.session.keys import session_key_for_channel
def _channel_session_key(channel: str, chat_id: str) -> str: def _channel_session_key(channel: str, chat_id: str) -> str:
return ( return session_key_for_channel(
UNIFIED_SESSION_KEY channel,
if config.agents.defaults.unified_session chat_id,
else f"{channel}:{chat_id}" unified_session=config.agents.defaults.unified_session,
) )
async def _deliver_to_channel( 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.""" """Publish a user-visible message and mirror it into that channel's session."""
metadata = dict(msg.metadata or {}) metadata = dict(msg.metadata or {})
record = record or bool(metadata.pop("_record_channel_delivery", False)) 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 {}): if metadata != (msg.metadata or {}):
msg = OutboundMessage( msg = OutboundMessage(
channel=msg.channel, channel=msg.channel,
@ -1194,73 +1164,17 @@ def _run_gateway(
logger.info("Heartbeat: silenced by post-run evaluation") logger.info("Heartbeat: silenced by post-run evaluation")
return response return response
reminder_note = ( if is_bound_cron_job(job):
"The scheduled time has arrived. Deliver this reminder to the user now, " return await run_bound_cron_job(job, agent=agent, cron=cron)
"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 " reason = "unbound agent cron job must be recreated from a chat session"
"like 'Done' or 'Reminded'.\n\n" logger.warning(
f"Reminder: {job.payload.message}" "Cron: skipped unbound agent job '{}' ({}): {}",
job.name,
job.id,
reason,
) )
raise CronJobSkippedError(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
cron.on_job = on_cron_job cron.on_job = on_cron_job
@ -1279,6 +1193,7 @@ def _run_gateway(
session_manager=session_manager, session_manager=session_manager,
cron_service=cron, cron_service=cron,
webui_runtime_model_name=_webui_runtime_model_name, 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_static_dist=webui_static_dist,
webui_runtime_surface=webui_runtime_surface, webui_runtime_surface=webui_runtime_surface,
webui_runtime_capabilities=webui_runtime_capabilities, webui_runtime_capabilities=webui_runtime_capabilities,

View File

@ -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

View File

@ -14,6 +14,7 @@ from typing import Any, Callable, Coroutine, Literal
from filelock import FileLock from filelock import FileLock
from loguru import logger from loguru import logger
from nanobot.cron.session_turns import is_bound_cron_job
from nanobot.cron.types import ( from nanobot.cron.types import (
CronJob, CronJob,
CronJobState, 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: def _now_ms() -> int:
return int(time.time() * 1000) 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 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: class CronService:
"""Service for managing and executing scheduled jobs.""" """Service for managing and executing scheduled jobs."""
@ -84,6 +145,7 @@ class CronService:
): ):
self.store_path = store_path self.store_path = store_path
self._action_path = store_path.parent / "action.jsonl" 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._lock = FileLock(str(self._action_path.parent) + ".lock")
self.on_job = on_job self.on_job = on_job
self._store: CronStore | None = None self._store: CronStore | None = None
@ -113,7 +175,7 @@ class CronService:
jobs = [] jobs = []
version = data.get("version", 1) version = data.get("version", 1)
for j in data.get("jobs", []): for j in data.get("jobs", []):
jobs.append(CronJob( job = CronJob(
id=j["id"], id=j["id"],
name=j["name"], name=j["name"],
enabled=j.get("enabled", True), enabled=j.get("enabled", True),
@ -136,6 +198,19 @@ class CronService:
or {} or {}
), ),
session_key=j["payload"].get("sessionKey") or j["payload"].get("session_key"), 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( state=CronJobState(
next_run_at_ms=j.get("state", {}).get("nextRunAtMs"), next_run_at_ms=j.get("state", {}).get("nextRunAtMs"),
@ -155,7 +230,9 @@ class CronService:
created_at_ms=j.get("createdAtMs", 0), created_at_ms=j.get("createdAtMs", 0),
updated_at_ms=j.get("updatedAtMs", 0), updated_at_ms=j.get("updatedAtMs", 0),
delete_after_run=j.get("deleteAfterRun", False), delete_after_run=j.get("deleteAfterRun", False),
)) )
_normalize_agent_turn_job(job)
jobs.append(job)
except Exception: except Exception:
# Preserve the corrupt file for forensic recovery instead of # Preserve the corrupt file for forensic recovery instead of
# letting the next save overwrite it with an empty job list. # 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} jobs_map = {j.id: j for j in self._store.jobs}
def _update(params: dict): def _update(params: dict):
j = CronJob.from_dict(params) j = CronJob.from_dict(params)
_normalize_agent_turn_job(j)
jobs_map[j.id] = j jobs_map[j.id] = j
def _del(params: dict): def _del(params: dict):
@ -266,6 +344,9 @@ class CronService:
"to": j.payload.to, "to": j.payload.to,
"channelMeta": j.payload.channel_meta, "channelMeta": j.payload.channel_meta,
"sessionKey": j.payload.session_key, "sessionKey": j.payload.session_key,
"originChannel": j.payload.origin_channel,
"originChatId": j.payload.origin_chat_id,
"originMetadata": j.payload.origin_metadata,
}, },
"state": { "state": {
"nextRunAtMs": j.state.next_run_at_ms, "nextRunAtMs": j.state.next_run_at_ms,
@ -325,6 +406,23 @@ class CronService:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
raise 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: async def start(self) -> None:
"""Start the cron service.""" """Start the cron service."""
self._running = True self._running = True
@ -430,6 +528,17 @@ class CronService:
job.state.last_error = None job.state.last_error = None
logger.info("Cron: job '{}' completed", job.name) 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: except Exception as e:
job.state.last_status = "error" job.state.last_status = "error"
job.state.last_error = str(e) 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] 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')) 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( def add_job(
self, self,
name: str, name: str,
@ -484,6 +607,9 @@ class CronService:
delete_after_run: bool = False, delete_after_run: bool = False,
channel_meta: dict | None = None, channel_meta: dict | None = None,
session_key: str | None = None, session_key: str | None = None,
origin_channel: str | None = None,
origin_chat_id: str | None = None,
origin_metadata: dict | None = None,
) -> CronJob: ) -> CronJob:
"""Add a new job.""" """Add a new job."""
_validate_schedule_for_add(schedule) _validate_schedule_for_add(schedule)
@ -502,12 +628,16 @@ class CronService:
to=to, to=to,
channel_meta=channel_meta or {}, channel_meta=channel_meta or {},
session_key=session_key, 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)), state=CronJobState(next_run_at_ms=_compute_next_run(schedule, now)),
created_at_ms=now, created_at_ms=now,
updated_at_ms=now, updated_at_ms=now,
delete_after_run=delete_after_run, delete_after_run=delete_after_run,
) )
_normalize_agent_turn_job(job)
if self._running: if self._running:
store = self._load_store() store = self._load_store()
store.jobs.append(job) store.jobs.append(job)
@ -616,6 +746,7 @@ class CronService:
job.payload.to = to job.payload.to = to
if delete_after_run is not None: if delete_after_run is not None:
job.delete_after_run = delete_after_run job.delete_after_run = delete_after_run
_normalize_agent_turn_job(job)
job.updated_at_ms = _now_ms() job.updated_at_ms = _now_ms()
if job.enabled: if job.enabled:

View File

@ -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 {})

View File

@ -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
)

View File

@ -1,7 +1,7 @@
"""Cron types.""" """Cron types."""
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Literal from typing import Any, Literal
@dataclass @dataclass
@ -23,12 +23,15 @@ class CronPayload:
"""What to do when the job runs.""" """What to do when the job runs."""
kind: Literal["system_event", "agent_turn"] = "agent_turn" kind: Literal["system_event", "agent_turn"] = "agent_turn"
message: str = "" message: str = ""
# Deliver response to channel # Legacy delivery fields used by pre-session-bound cron jobs.
deliver: bool = False deliver: bool = False
channel: str | None = None # e.g. "whatsapp" channel: str | None = None # e.g. "whatsapp"
to: str | None = None # e.g. phone number 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 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 @dataclass

View File

@ -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

12
nanobot/session/keys.py Normal file
View File

@ -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}"

View File

@ -22,6 +22,7 @@ from nanobot.bus.runtime_events import (
TurnCompleted, TurnCompleted,
TurnRunStatusChanged, TurnRunStatusChanged,
) )
from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.providers.base import LLMProvider from nanobot.providers.base import LLMProvider
from nanobot.session.goal_state import goal_state_ws_blob from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
@ -68,6 +69,8 @@ def _title_inputs(session: Session) -> tuple[str, str]:
for message in session.messages: for message in session.messages:
if message.get("_command") is True: if message.get("_command") is True:
continue continue
if message.get(CRON_HISTORY_META) is True:
continue
role = message.get("role") role = message.get("role")
content = message.get("content") content = message.get("content")
if not isinstance(content, str) or not content.strip(): if not isinstance(content, str) or not content.strip():

View File

@ -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 }}

View File

@ -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. LLM call to decide whether the result warrants notifying the user.
""" """
@ -46,10 +46,10 @@ async def evaluate_response(
model: str, model: str,
default_notify: bool = True, default_notify: bool = True,
) -> bool: ) -> 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; On any failure, falls back to ``default_notify``. Heartbeat passes
heartbeat passes ``False`` to fail closed). ``False`` to fail closed.
""" """
try: try:
llm_response = await provider.chat_with_retry( llm_response = await provider.chat_with_retry(

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Callable
from loguru import logger as default_logger from loguru import logger as default_logger
@ -26,6 +26,7 @@ class GatewayServices:
workspaces: WebUIWorkspaceController workspaces: WebUIWorkspaceController
session_manager: Any | None session_manager: Any | None
cron_service: Any | None cron_service: Any | None
cron_pending_job_ids: Callable[[str], set[str]] | None
def build_gateway_services( def build_gateway_services(
@ -41,6 +42,7 @@ def build_gateway_services(
runtime_capabilities_overrides: dict[str, Any] | None, runtime_capabilities_overrides: dict[str, Any] | None,
disabled_skills: set[str] | None = None, disabled_skills: set[str] | None = None,
cron_service: Any | None = None, cron_service: Any | None = None,
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
logger: Any = default_logger, logger: Any = default_logger,
) -> GatewayServices: ) -> GatewayServices:
tokens = GatewayTokenStore() tokens = GatewayTokenStore()
@ -68,6 +70,7 @@ def build_gateway_services(
skills_workspace_path=workspace_path, skills_workspace_path=workspace_path,
disabled_skills=disabled_skills, disabled_skills=disabled_skills,
cron_service=cron_service, cron_service=cron_service,
cron_pending_job_ids=cron_pending_job_ids,
log=logger, log=logger,
) )
return GatewayServices( return GatewayServices(
@ -78,4 +81,5 @@ def build_gateway_services(
workspaces=workspaces, workspaces=workspaces,
session_manager=session_manager, session_manager=session_manager,
cron_service=cron_service, cron_service=cron_service,
cron_pending_job_ids=cron_pending_job_ids,
) )

View File

@ -0,0 +1,4 @@
"""Shared WebUI metadata keys."""
WEBUI_TURN_METADATA_KEY = "webui_turn_id"
WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source"

View File

@ -2,39 +2,58 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Collection
from typing import Any, Protocol from typing import Any, Protocol
from nanobot.cron.types import CronJob from nanobot.cron.types import CronJob
class _CronServiceLike(Protocol): 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( def session_automations_payload(
cron_service: _CronServiceLike | None, cron_service: _CronServiceLike | None,
session_key: str, session_key: str,
*,
pending_job_ids: Collection[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return user-created automation jobs attached to a WebUI session.""" """Return user-created automation jobs attached to a WebUI session."""
jobs: list[CronJob] = [] return {
if cron_service is not None: "jobs": serialize_automation_jobs(
all_jobs = cron_service.list_jobs(include_disabled=True) session_automation_jobs(cron_service, session_key),
jobs = [job for job in all_jobs if _job_matches_session(job, session_key)] pending_job_ids=pending_job_ids,
return {"jobs": [_serialize_job(job) for job in jobs]} )
}
def _job_matches_session(job: CronJob, session_key: str) -> bool: def serialize_automation_jobs(
payload = job.payload jobs: list[CronJob],
if payload.kind != "agent_turn": *,
return False pending_job_ids: Collection[str] | None = None,
if payload.session_key: ) -> list[dict[str, Any]]:
return payload.session_key == session_key return [_serialize_job(job, pending=job.id in (pending_job_ids or ())) for job in jobs]
if payload.channel and payload.to:
return f"{payload.channel}:{payload.to}" == session_key
return False
def _serialize_job(job: CronJob) -> dict[str, Any]: def _serialize_job(job: CronJob, *, pending: bool = False) -> dict[str, Any]:
return { return {
"id": job.id, "id": job.id,
"name": job.name, "name": job.name,
@ -52,5 +71,6 @@ def _serialize_job(job: CronJob) -> dict[str, Any]:
"state": { "state": {
"next_run_at_ms": job.state.next_run_at_ms, "next_run_at_ms": job.state.next_run_at_ms,
"last_status": job.state.last_status, "last_status": job.state.last_status,
"pending": pending,
}, },
} }

View File

@ -14,6 +14,7 @@ from typing import Any
from loguru import logger from loguru import logger
from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.session.manager import ( from nanobot.session.manager import (
_SESSION_LIST_PREVIEW_MAX_CHARS, _SESSION_LIST_PREVIEW_MAX_CHARS,
_SESSION_LIST_PREVIEW_MAX_RECORDS, _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 or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
): ):
break break
if item.get(CRON_HISTORY_META) is True:
continue
text = _message_preview_text(item) text = _message_preview_text(item)
if not text: if not text:
continue continue
@ -193,6 +196,8 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
item = json.loads(line) item = json.loads(line)
if item.get("_type") == "metadata": if item.get("_type") == "metadata":
continue continue
if item.get(CRON_HISTORY_META) is True:
continue
text = _message_preview_text(item) text = _message_preview_text(item)
if not text: if not text:
continue continue

View File

@ -17,7 +17,9 @@ from urllib.parse import unquote, urlparse
from loguru import logger from loguru import logger
from nanobot.config.paths import get_webui_dir 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.session.manager import SessionManager
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3 WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
WEBUI_FORK_MARKER_EVENT = "fork_marker" WEBUI_FORK_MARKER_EVENT = "fork_marker"
@ -29,8 +31,6 @@ _TRANSCRIPT_SEGMENT_RE = re.compile(r"^\d{6}\.jsonl$")
_DEFAULT_TRANSCRIPT_PAGE_LIMIT = 160 _DEFAULT_TRANSCRIPT_PAGE_LIMIT = 160
_MAX_TRANSCRIPT_PAGE_LIMIT = 1000 _MAX_TRANSCRIPT_PAGE_LIMIT = 1000
_WEBUI_TURN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") _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( _MARKDOWN_LOCAL_IMAGE_RE = re.compile(
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)" r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
) )
@ -855,6 +855,8 @@ def _session_user_event(
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
if message.get("role") != "user": if message.get("role") != "user":
return None return None
if message.get(CRON_HISTORY_META) is True:
return None
content = message.get("content") content = message.get("content")
text = content if isinstance(content, str) else "" text = content if isinstance(content, str) else ""
media = message.get("media") media = message.get("media")
@ -1823,6 +1825,29 @@ def fork_boundary_message_count(lines: list[dict[str, Any]]) -> int | None:
return 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( def build_webui_thread_response(
session_key: str, session_key: str,
*, *,
@ -1855,6 +1880,7 @@ def build_webui_thread_response(
"schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION, "schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION,
"sessionKey": session_key, "sessionKey": session_key,
"messages": msgs, "messages": msgs,
"has_pending_tool_calls": has_pending_tool_calls(lines),
} }
if page is not None: if page is not None:
page["loaded_message_count"] = len(msgs) page["loaded_message_count"] = len(msgs)

View File

@ -61,7 +61,11 @@ from nanobot.webui.http_utils import (
safe_host_header as _safe_host_header, safe_host_header as _safe_host_header,
) )
from nanobot.webui.media_gateway import WebUIMediaGateway 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.session_list_index import list_webui_sessions
from nanobot.webui.sidebar_state import ( from nanobot.webui.sidebar_state import (
read_webui_sidebar_state, read_webui_sidebar_state,
@ -142,6 +146,7 @@ class GatewayHTTPHandler:
skills_workspace_path: Path, skills_workspace_path: Path,
disabled_skills: set[str] | None = None, disabled_skills: set[str] | None = None,
cron_service: CronService | None = None, cron_service: CronService | None = None,
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
log: Any = logger, log: Any = logger,
) -> None: ) -> None:
self.config = config self.config = config
@ -155,6 +160,7 @@ class GatewayHTTPHandler:
self.skills_workspace_path = skills_workspace_path self.skills_workspace_path = skills_workspace_path
self.disabled_skills = disabled_skills or set() self.disabled_skills = disabled_skills or set()
self.cron_service = cron_service self.cron_service = cron_service
self.cron_pending_job_ids = cron_pending_job_ids
self._log = log self._log = log
self._runtime_surface = runtime_surface self._runtime_surface = runtime_surface
@ -432,8 +438,15 @@ class GatewayHTTPHandler:
return _http_error(400, "invalid session key") return _http_error(400, "invalid session key")
if not _is_websocket_channel_session_key(decoded_key): if not _is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found") 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( 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: def _handle_session_delete(self, request: WsRequest, key: str) -> Response:
@ -446,6 +459,20 @@ class GatewayHTTPHandler:
return _http_error(400, "invalid session key") return _http_error(400, "invalid session key")
if not _is_websocket_channel_session_key(decoded_key): if not _is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found") 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) deleted = self.session_manager.delete_session(decoded_key)
delete_webui_thread(decoded_key) delete_webui_thread(decoded_key)
return _http_json_response({"deleted": bool(deleted)}) return _http_json_response({"deleted": bool(deleted)})

View File

@ -1,7 +1,7 @@
import pytest import pytest
from nanobot.utils.evaluator import evaluate_response
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.utils.evaluator import evaluate_response
class DummyProvider(LLMProvider): class DummyProvider(LLMProvider):

View File

@ -8,6 +8,7 @@ from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus 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.providers.base import LLMResponse
from nanobot.session.goal_state import GOAL_STATE_KEY from nanobot.session.goal_state import GOAL_STATE_KEY
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
@ -64,6 +65,41 @@ def test_agent_loop_llm_runtime_reflects_current_provider_and_model(tmp_path: Pa
assert runtime.model == "next-model" 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: def test_clean_generated_title_strips_reasoning_tags() -> None:
assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish" assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish"
assert clean_generated_title("Title: <think> The user said hello") == "" assert clean_generated_title("Title: <think> 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() 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( def test_webui_title_update_uses_captured_llm_runtime(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,

View File

@ -592,8 +592,8 @@ async def test_waiting_dispatch_does_not_replace_active_pending_queue(tmp_path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_followup_routed_to_pending_queue(tmp_path): async def test_followup_routed_to_pending_queue(tmp_path):
"""Unified-session follow-ups should route into the active pending queue.""" """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.bus.events import InboundMessage
from nanobot.session.keys import UNIFIED_SESSION_KEY
loop = _make_loop(tmp_path) loop = _make_loop(tmp_path)
loop._unified_session = True 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 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 @pytest.mark.asyncio
async def test_pending_queue_preserves_overflow_for_next_injection_cycle(tmp_path): async def test_pending_queue_preserves_overflow_for_next_injection_cycle(tmp_path):
"""Pending queue should leave overflow messages queued for later drains.""" """Pending queue should leave overflow messages queued for later drains."""

View File

@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.session.keys import UNIFIED_SESSION_KEY
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@ -450,12 +451,12 @@ class TestSubagentAnnounceSessionKey:
so the result matches the pending queue key.""" so the result matches the pending queue key."""
mgr, bus = self._make_mgr() 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") await mgr._announce_result("sub-1", "label", "task", "result", origin, "ok")
msg = await bus.consume_inbound() msg = await bus.consume_inbound()
assert msg.session_key_override == "unified:default" assert msg.session_key_override == UNIFIED_SESSION_KEY
assert msg.session_key == "unified:default" assert msg.session_key == UNIFIED_SESSION_KEY
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_announce_uses_raw_key_in_normal_mode(self): async def test_announce_uses_raw_key_in_normal_mode(self):
@ -505,9 +506,9 @@ class TestSubagentAnnounceSessionKey:
) )
await mgr._run_subagent( await mgr._run_subagent(
"sub-4", "task", "label", "sub-4", "task", "label",
{"channel": "telegram", "chat_id": "444", "session_key": "unified:default"}, {"channel": "telegram", "chat_id": "444", "session_key": UNIFIED_SESSION_KEY},
status, status,
) )
msg = await bus.consume_inbound() msg = await bus.consume_inbound()
assert msg.session_key_override == "unified:default" assert msg.session_key_override == UNIFIED_SESSION_KEY

View File

@ -25,9 +25,9 @@ from nanobot.bus.queue import MessageBus
from nanobot.command.builtin import cmd_new, register_builtin_commands from nanobot.command.builtin import cmd_new, register_builtin_commands
from nanobot.command.router import CommandContext, CommandRouter from nanobot.command.router import CommandContext, CommandRouter
from nanobot.config.schema import AgentDefaults, Config from nanobot.config.schema import AgentDefaults, Config
from nanobot.session.keys import UNIFIED_SESSION_KEY
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -39,8 +39,8 @@ def _make_loop(tmp_path: Path, unified_session: bool = False) -> AgentLoop:
provider.get_default_model.return_value = "test-model" provider.get_default_model.return_value = "test-model"
with patch("nanobot.agent.loop.SessionManager"), \ with patch("nanobot.agent.loop.SessionManager"), \
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr: patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0) mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
loop = AgentLoop( loop = AgentLoop(
bus=bus, bus=bus,
provider=provider, provider=provider,
@ -415,8 +415,6 @@ class TestStopCommandWithUnifiedSession:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_active_tasks_use_effective_key_in_unified_mode(self, tmp_path: Path): 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.""" """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) loop = _make_loop(tmp_path, unified_session=True)
# Create a message from telegram channel # Create a message from telegram channel
@ -443,7 +441,6 @@ class TestStopCommandWithUnifiedSession:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stop_command_finds_task_in_unified_mode(self, tmp_path: Path): async def test_stop_command_finds_task_in_unified_mode(self, tmp_path: Path):
"""cmd_stop can cancel tasks when unified_session=True.""" """cmd_stop can cancel tasks when unified_session=True."""
from nanobot.agent.loop import UNIFIED_SESSION_KEY
from nanobot.command.builtin import cmd_stop from nanobot.command.builtin import cmd_stop
loop = _make_loop(tmp_path, unified_session=True) loop = _make_loop(tmp_path, unified_session=True)
@ -476,7 +473,6 @@ class TestStopCommandWithUnifiedSession:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stop_command_uses_effective_key_without_session_override(self, tmp_path: Path): 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.""" """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 from nanobot.command.builtin import cmd_stop
loop = _make_loop(tmp_path, unified_session=True) loop = _make_loop(tmp_path, unified_session=True)
@ -502,7 +498,6 @@ class TestStopCommandWithUnifiedSession:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stop_command_cross_channel_in_unified_mode(self, tmp_path: Path): 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.""" """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 from nanobot.command.builtin import cmd_stop
loop = _make_loop(tmp_path, unified_session=True) loop = _make_loop(tmp_path, unified_session=True)

View File

@ -2866,3 +2866,49 @@ def test_handle_webui_thread_get_backfills_legacy_missing_user_rows(
"legacy question", "legacy question",
"legacy answer", "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"]] == ["提醒已经到期。"]

View File

@ -14,6 +14,7 @@ import pytest
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule 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.session.manager import Session, SessionManager
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
@ -29,6 +30,7 @@ def _make_handler(
workspace_path: Path | None = None, workspace_path: Path | None = None,
runtime_model_name: Any | None = None, runtime_model_name: Any | None = None,
cron_service: CronService | None = None, cron_service: CronService | None = None,
cron_pending_job_ids: Any | None = None,
) -> GatewayServices: ) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
workspace = workspace_path or Path.cwd() workspace = workspace_path or Path.cwd()
@ -43,6 +45,7 @@ def _make_handler(
runtime_surface="browser", runtime_surface="browser",
runtime_capabilities_overrides=None, runtime_capabilities_overrides=None,
cron_service=cron_service, cron_service=cron_service,
cron_pending_job_ids=cron_pending_job_ids,
) )
@ -55,6 +58,7 @@ def _ch(
port: int = _PORT, port: int = _PORT,
runtime_model_name: Any | None = None, runtime_model_name: Any | None = None,
cron_service: CronService | None = None, cron_service: CronService | None = None,
cron_pending_job_ids: Any | None = None,
**extra: Any, **extra: Any,
) -> WebSocketChannel: ) -> WebSocketChannel:
cfg: dict[str, Any] = { cfg: dict[str, Any] = {
@ -73,6 +77,7 @@ def _ch(
workspace_path=workspace_path, workspace_path=workspace_path,
runtime_model_name=runtime_model_name, runtime_model_name=runtime_model_name,
cron_service=cron_service, cron_service=cron_service,
cron_pending_job_ids=cron_pending_job_ids,
) )
return WebSocketChannel(cfg, bus, gateway=gateway) return WebSocketChannel(cfg, bus, gateway=gateway)
@ -176,18 +181,30 @@ async def test_session_automations_route_filters_by_webui_session(
) -> None: ) -> None:
cron = CronService(tmp_path / "cron" / "jobs.json") cron = CronService(tmp_path / "cron" / "jobs.json")
hourly = CronSchedule(kind="every", every_ms=3_600_000) hourly = CronSchedule(kind="every", every_ms=3_600_000)
pending_job_id = ""
for name, message, to in ( for name, message, to in (
("Morning check", "Check the project status", "abc"), ("Morning check", "Check the project status", "abc"),
("Other session", "Do not show", "other"), ("Other session", "Do not show", "other"),
): ):
cron.add_job( job = cron.add_job(
name=name, name=name,
schedule=hourly, schedule=hourly,
message=message, message=message,
channel="websocket",
to=to,
session_key=f"websocket:{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( cron.register_system_job(
CronJob( CronJob(
id="heartbeat", id="heartbeat",
@ -200,6 +217,7 @@ async def test_session_automations_route_filters_by_webui_session(
bus, bus,
session_manager=_seed_session(tmp_path, key="websocket:abc"), session_manager=_seed_session(tmp_path, key="websocket:abc"),
cron_service=cron, cron_service=cron,
cron_pending_job_ids=lambda key: {pending_job_id} if key == "websocket:abc" else set(),
port=29914, port=29914,
) )
server_task = asyncio.create_task(channel.start()) 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 assert resp.status_code == 200
body = resp.json() 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] job = body["jobs"][0]
assert job["schedule"]["kind"] == "every" assert job["schedule"]["kind"] == "every"
assert job["schedule"]["every_ms"] == 3_600_000 assert job["schedule"]["every_ms"] == 3_600_000
assert job["payload"]["message"] == "Check the project status" 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: finally:
await channel.stop() await channel.stop()
await server_task await server_task
@ -659,6 +732,141 @@ async def test_session_delete_removes_file(
await server_task 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 @pytest.mark.asyncio
async def test_session_routes_accept_percent_encoded_websocket_keys( async def test_session_routes_accept_percent_encoded_websocket_keys(
bus: MagicMock, tmp_path: Path bus: MagicMock, tmp_path: Path

View File

@ -8,13 +8,20 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from typer.testing import CliRunner from typer.testing import CliRunner
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.cli.commands import _proactive_delivery_metadata, app from nanobot.cli.commands import app
from nanobot.config.schema import Config 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.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.factory import ProviderSnapshot, make_provider
from nanobot.providers.openai_codex_provider import _strip_model_prefix from nanobot.providers.openai_codex_provider import _strip_model_prefix
from nanobot.providers.registry import find_by_name from nanobot.providers.registry import find_by_name
from nanobot.webui.metadata import (
WEBUI_MESSAGE_SOURCE_METADATA_KEY,
WEBUI_TURN_METADATA_KEY,
)
runner = CliRunner() runner = CliRunner()
@ -22,11 +29,11 @@ runner = CliRunner()
def test_proactive_websocket_delivery_gets_fresh_turn_id() -> None: def test_proactive_websocket_delivery_gets_fresh_turn_id() -> None:
metadata = { metadata = {
"webui": True, "webui": True,
"webui_turn_id": "turn-that-created-the-reminder", WEBUI_TURN_METADATA_KEY: "turn-that-created-the-reminder",
"workspace_scope": {"mode": "default"}, "workspace_scope": {"mode": "default"},
} }
out = _proactive_delivery_metadata( out = cron_proactive_delivery_metadata(
"websocket", "websocket",
metadata, metadata,
turn_seed="cron:drink-water", 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["webui"] is True
assert out["workspace_scope"] == {"mode": "default"} assert out["workspace_scope"] == {"mode": "default"}
assert out["webui_turn_id"].startswith("cron:drink-water:") assert out[WEBUI_TURN_METADATA_KEY].startswith("cron:drink-water:")
assert out["webui_turn_id"] != metadata["webui_turn_id"] assert out[WEBUI_TURN_METADATA_KEY] != metadata[WEBUI_TURN_METADATA_KEY]
assert out["_webui_message_source"] == {"kind": "cron", "label": "drink water"} assert out[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {"kind": "cron", "label": "drink water"}
def _fake_provider(): 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" 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 monkeypatch, tmp_path: Path
) -> None: ) -> None:
config_file = tmp_path / "instance" / "config.json" config_file = tmp_path / "instance" / "config.json"
@ -1403,11 +1410,10 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
seen["agent"] = self seen["agent"] = self
async def process_direct(self, *_args, **_kwargs): async def process_direct(self, *_args, **_kwargs):
return OutboundMessage( raise AssertionError("unbound cron job must not use process_direct")
channel="telegram",
chat_id="user-1", async def submit_cron_turn(self, _msg: InboundMessage):
content="Time to stretch.", raise AssertionError("unbound cron job must not run as a bound cron turn")
)
async def close_mcp(self) -> None: async def close_mcp(self) -> None:
return None return None
@ -1423,16 +1429,10 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
raise _StopGatewayError("stop") raise _StopGatewayError("stop")
async def _capture_evaluate_response( async def _capture_evaluate_response(
response: str, *_args,
task_context: str, **_kwargs,
provider_arg: object,
model: str,
) -> bool: ) -> bool:
seen["response"] = response raise AssertionError("unbound cron job must not be evaluated for delivery")
seen["task_context"] = task_context
seen["provider"] = provider_arg
seen["model"] = model
return True
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop) 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." bus.publish_outbound.assert_not_awaited()
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",
}
def test_gateway_cron_job_suppresses_intermediate_progress( def test_gateway_bound_cron_runs_as_session_turn(
monkeypatch, tmp_path: Path monkeypatch, tmp_path: Path
) -> None: ) -> 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 = tmp_path / "instance" / "config.json"
config_file.parent.mkdir(parents=True) config_file.parent.mkdir(parents=True)
config_file.write_text("{}") config_file.write_text("{}")
config = Config() config = Config()
config.agents.defaults.workspace = str(tmp_path / "config-workspace") config.agents.defaults.workspace = str(tmp_path / "config-workspace")
provider = _fake_provider()
bus = MagicMock() bus = MagicMock()
bus.publish_outbound = AsyncMock() 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.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) 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.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( monkeypatch.setattr(
"nanobot.providers.factory.build_provider_snapshot", "nanobot.providers.factory.build_provider_snapshot",
lambda _config: _test_provider_snapshot(object(), _config), lambda _config: _test_provider_snapshot(provider, _config),
) )
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.providers.factory.load_provider_snapshot", "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.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: class _FakeCron:
def __init__(self, _store_path: Path) -> None: def __init__(self, _store_path: Path) -> None:
self.on_job = None self.on_job = None
seen["cron"] = self 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: class _FakeAgentLoop:
@classmethod @classmethod
def from_config(cls, config, bus=None, **extra): def from_config(cls, config, bus=None, **extra):
return cls(**extra) return cls(**extra)
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
self.model = "test-model" self.model = "test-model"
self.provider = object() self.provider = kwargs.get("provider", object())
self.tools = {} self.tools = {}
seen["agent"] = self
async def process_direct(self, *_args, on_progress=None, **_kwargs): async def submit_cron_turn(self, msg: InboundMessage):
seen["on_progress"] = on_progress seen["cron_msg"] = msg
return OutboundMessage( return OutboundMessage(
channel="telegram", channel=msg.channel,
chat_id="user-1", chat_id=msg.chat_id,
content="Done.", content="Checked the repo.",
) )
async def close_mcp(self) -> None: async def close_mcp(self) -> None:
@ -1598,41 +1545,131 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
def __init__(self, *_args, **_kwargs) -> None: def __init__(self, *_args, **_kwargs) -> None:
raise _StopGatewayError("stop") raise _StopGatewayError("stop")
async def _always_reject(*_args, **_kwargs) -> bool: async def _unexpected_evaluator(*_args, **_kwargs) -> bool:
return False raise AssertionError("bound cron must not use legacy response evaluator")
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup) monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
monkeypatch.setattr( monkeypatch.setattr("nanobot.cli.commands.evaluate_response", _unexpected_evaluator)
"nanobot.cli.commands.evaluate_response",
_always_reject,
)
result = runner.invoke(app, ["gateway", "--config", str(config_file)]) result = runner.invoke(app, ["gateway", "--config", str(config_file)])
assert isinstance(result.exception, _StopGatewayError) assert isinstance(result.exception, _StopGatewayError)
cron = seen["cron"] cron = seen["cron"]
job = CronJob( job = CronJob(
id="cron-silent-test", id="repo-check",
name="test-silent", name="Repo check",
payload=CronPayload( payload=CronPayload(
message="Run something.", message="Check repository health.",
deliver=True, session_key="websocket:chat-1",
channel="telegram", origin_channel="websocket",
to="user-1", origin_chat_id="chat-1",
), ),
) )
response = asyncio.run(cron.on_job(job)) response = asyncio.run(cron.on_job(job))
assert response == "Done." assert response == "Checked the repo."
# on_progress must be a callable (the _silent noop), not None and not bus_progress msg = seen["cron_msg"]
assert seen["on_progress"] is not None assert isinstance(msg, InboundMessage)
assert callable(seen["on_progress"]) assert msg.channel == "websocket"
# Verify it actually swallows calls (no side effects) assert msg.chat_id == "chat-1"
asyncio.run(seen["on_progress"]("tool_hint", "🔧 $ echo test")) assert msg.sender_id == "cron"
# Nothing published to bus since evaluator rejected assert msg.session_key_override == "websocket:chat-1"
bus.publish_outbound.assert_not_awaited() 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( def test_gateway_workspace_override_does_not_migrate_legacy_cron(

View File

@ -4,7 +4,7 @@ import time
import pytest import pytest
from nanobot.cron.service import CronService from nanobot.cron.service import CronJobSkippedError, CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule 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 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") service = CronService(tmp_path / "cron" / "jobs.json")
meta = {"slack": {"thread_ts": "1234567890.123456", "channel_type": "channel"}} meta = {"slack": {"thread_ts": "1234567890.123456", "channel_type": "channel"}}
job = service.add_job( job = service.add_job(
@ -56,13 +56,160 @@ def test_add_job_preserves_channel_meta_and_session_key(tmp_path) -> None:
channel_meta=meta, channel_meta=meta,
session_key="slack:C123:1234567890.123456", 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.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) reloaded = service.get_job(job.id)
assert reloaded is not None 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.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 @pytest.mark.asyncio
@ -81,19 +228,31 @@ async def test_channel_meta_and_session_key_survive_store_reload(tmp_path) -> No
to="C123", to="C123",
channel_meta=meta, channel_meta=meta,
session_key="slack:C123:1234567890.123456", session_key="slack:C123:1234567890.123456",
origin_channel="slack",
origin_chat_id="C123",
origin_metadata=meta,
) )
finally: finally:
service.stop() service.stop()
raw = json.loads(store_path.read_text(encoding="utf-8")) raw = json.loads(store_path.read_text(encoding="utf-8"))
payload = raw["jobs"][0]["payload"] 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["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) reloaded = CronService(store_path).get_job(job.id)
assert reloaded is not None 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.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 @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" 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 @pytest.mark.asyncio
async def test_run_history_trimmed_to_max(tmp_path) -> None: async def test_run_history_trimmed_to_max(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json" 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" assert last["params"]["name"] == "updated-offline"
def test_update_job_sentinel_channel_and_to(tmp_path) -> None: def test_update_job_migrates_legacy_delivery_target(tmp_path) -> None:
"""Passing None clears channel/to; omitting leaves them unchanged."""
service = CronService(tmp_path / "cron" / "jobs.json") service = CronService(tmp_path / "cron" / "jobs.json")
job = service.add_job( job = service.add_job(
name="sentinel", name="sentinel",
schedule=CronSchedule(kind="every", every_ms=60_000), schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello", message="hello",
channel="telegram",
to="user123",
) )
assert job.payload.channel == "telegram"
assert job.payload.to == "user123"
result = service.update_job(job.id, name="renamed") result = service.update_job(job.id, channel="telegram", to="user123")
assert isinstance(result, CronJob)
assert result.payload.channel == "telegram"
assert result.payload.to == "user123"
result = service.update_job(job.id, channel=None, to=None)
assert isinstance(result, CronJob) 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.channel is None
assert result.payload.to is None assert result.payload.to is None
assert result.payload.channel_meta == {}
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@ -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: def test_add_cron_job_defaults_to_tool_timezone(tmp_path) -> None:
tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai") 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) 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: def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None:
tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai") 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") 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 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 = _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) result = tool._add_job(None, "Morning standup", 60, None, None, None)
assert result.startswith("Created job") assert result.startswith("Created job")
job = tool._cron.list_jobs()[0] 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 = _make_tool(tmp_path)
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1")) 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") assert result == "Error: scheduled cron jobs must be created from a chat session"
job = tool._cron.list_jobs()[0] assert tool._cron.list_jobs() == []
assert job.payload.deliver is False
def test_cron_schema_advertises_action_specific_requirements(tmp_path) -> None: 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: def test_add_job_empty_message_returns_actionable_error(tmp_path) -> None:
tool = _make_tool(tmp_path) 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) 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 assert "Retry including message=" in result
def test_add_job_captures_metadata_and_session_key(tmp_path) -> None: def test_add_job_captures_owner_and_origin_without_legacy_delivery_fields(tmp_path) -> None:
"""CronTool stores channel metadata and session_key when adding a job.""" """CronTool stores owner/session identity separately from origin delivery context."""
tool = _make_tool(tmp_path) tool = _make_tool(tmp_path)
meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}} meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}}
tool.set_context(RequestContext( 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() jobs = tool._cron.list_jobs()
assert len(jobs) == 1 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.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: def test_list_excludes_disabled_jobs(tmp_path) -> None:

View File

@ -41,7 +41,9 @@ class _SvcStub:
@pytest.fixture @pytest.fixture
def registry() -> ToolRegistry: def registry() -> ToolRegistry:
tool = CronTool(_SvcStub(), default_timezone="UTC") 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 = ToolRegistry()
reg.register(tool) reg.register(tool)
return reg return reg

View File

@ -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)

View File

@ -4,11 +4,13 @@ import asyncio
import pytest import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.context import RequestContext from nanobot.agent.tools.context import RequestContext
from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.spawn import SpawnTool from nanobot.agent.tools.spawn import SpawnTool
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.session.keys import UNIFIED_SESSION_KEY
@pytest.mark.asyncio @pytest.mark.asyncio
@ -99,14 +101,18 @@ async def test_cron_tool_keeps_task_local_context(tmp_path) -> None:
release = asyncio.Event() release = asyncio.Event()
async def task_one() -> str: 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() entered.set()
await release.wait() await release.wait()
return await tool.execute(action="add", message="first", every_seconds=60) return await tool.execute(action="add", message="first", every_seconds=60)
async def task_two() -> str: async def task_two() -> str:
await entered.wait() 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() release.set()
return await tool.execute(action="add", message="second", every_seconds=60) 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") assert result_two.startswith("Created job")
jobs = tool._cron.list_jobs() jobs = tool._cron.list_jobs()
assert {job.payload.channel for job in jobs} == {"feishu", "email"} assert {job.payload.session_key for job in jobs} == {"feishu:chat-a", "email:chat-b"}
assert {job.payload.to for job in jobs} == {"chat-a", "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 --- # --- 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: async def test_cron_tool_basic_set_context_and_execute(tmp_path) -> None:
"""Single task: set_context then add job should use correct target.""" """Single task: set_context then add job should use correct target."""
tool = CronTool(CronService(tmp_path / "jobs.json")) 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) result = await tool.execute(action="add", message="standup", every_seconds=300)
assert result.startswith("Created job") assert result.startswith("Created job")
jobs = tool._cron.list_jobs() jobs = tool._cron.list_jobs()
assert len(jobs) == 1 assert len(jobs) == 1
assert jobs[0].payload.channel == "wechat" assert jobs[0].payload.session_key == "wechat:user-789"
assert jobs[0].payload.to == "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 @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")) tool = CronTool(CronService(tmp_path / "jobs.json"))
result = await tool.execute(action="add", message="test", every_seconds=60) result = await tool.execute(action="add", message="test", every_seconds=60)
assert result == "Error: no session context (channel/chat_id)" assert result == "Error: scheduled cron jobs must be created from a chat session"

View File

@ -290,6 +290,91 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
assert msgs[1]["latencyMs"] == 42 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: def test_replay_preserves_turn_metadata(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-turn" key = "websocket:t-turn"

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import nanobot.webui.session_list_index as session_list_index 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 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) == [] 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]: def list_webui_sessions(manager: SessionManager) -> list[dict]:
return session_list_index.list_webui_sessions(manager) return session_list_index.list_webui_sessions(manager)

View File

@ -36,6 +36,7 @@ import { ClientProvider, useClient } from "@/providers/ClientProvider";
import type { import type {
ChatSummary, ChatSummary,
RuntimeSurface, RuntimeSurface,
SessionAutomationJob,
SettingsPayload, SettingsPayload,
WorkspaceScopePayload, WorkspaceScopePayload,
WorkspacesPayload, WorkspacesPayload,
@ -527,7 +528,15 @@ function Shell({
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { client, token } = useClient(); const { client, token } = useClient();
const { theme, toggle } = useTheme(); 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 } = const { state: sidebarState, update: updateSidebarState } =
useSidebarState(sessions, !loading); useSidebarState(sessions, !loading);
const initialRouteRef = useRef<ShellRoute | null>(null); const initialRouteRef = useRef<ShellRoute | null>(null);
@ -546,6 +555,7 @@ function Shell({
const [pendingDelete, setPendingDelete] = useState<{ const [pendingDelete, setPendingDelete] = useState<{
key: string; key: string;
label: string; label: string;
automations?: SessionAutomationJob[];
} | null>(null); } | null>(null);
const [pendingRename, setPendingRename] = useState<{ const [pendingRename, setPendingRename] = useState<{
key: string; key: string;
@ -1270,33 +1280,47 @@ function Shell({
const onConfirmDelete = useCallback(async () => { const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return; if (!pendingDelete) return;
const key = pendingDelete.key; const key = pendingDelete.key;
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
const deletingActive = activeKey === key; const deletingActive = activeKey === key;
const currentIndex = sessions.findIndex((s) => s.key === key); const currentIndex = sessions.findIndex((s) => s.key === key);
const fallbackKey = deletingActive const fallbackKey = deletingActive
? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null) ? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null)
: activeKey; : activeKey;
setPendingDelete(null);
if (deletingActive) {
navigate({
view: "chat",
activeKey: fallbackKey,
settingsSection: "overview",
}, { replace: true });
}
try { try {
await deleteChat(key); const result = await deleteChat(
} catch (e) { key,
hasAutomations ? { deleteAutomations: true } : undefined,
);
if (result.blocked_by_automations) {
setPendingDelete({
...pendingDelete,
automations: result.automations ?? [],
});
return;
}
setPendingDelete(null);
if (deletingActive) { if (deletingActive) {
navigate({ navigate({
view: "chat", view: "chat",
activeKey: key, activeKey: fallbackKey,
settingsSection: "overview", settingsSection: "overview",
}, { replace: true }); }, { replace: true });
} }
} catch (e) {
console.error("Failed to delete session", e); console.error("Failed to delete session", e);
} }
}, [pendingDelete, deleteChat, activeKey, navigate, sessions]); }, [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 const headerTitle = activeSession
? sidebarState.title_overrides[activeSession.key] || ? sidebarState.title_overrides[activeSession.key] ||
activeSession.title || activeSession.title ||
@ -1333,8 +1357,7 @@ function Shell({
loading, loading,
onNewChat, onNewChat,
onSelect: onSelectChat, onSelect: onSelectChat,
onRequestDelete: (key: string, label: string) => onRequestDelete,
setPendingDelete({ key, label }),
onTogglePin, onTogglePin,
onRequestRename, onRequestRename,
onToggleArchive, onToggleArchive,
@ -1559,6 +1582,7 @@ function Shell({
<DeleteConfirm <DeleteConfirm
open={!!pendingDelete} open={!!pendingDelete}
title={pendingDelete?.label ?? ""} title={pendingDelete?.label ?? ""}
automations={pendingDelete?.automations}
onCancel={() => setPendingDelete(null)} onCancel={() => setPendingDelete(null)}
onConfirm={onConfirmDelete} onConfirm={onConfirmDelete}
/> />

View File

@ -8,12 +8,17 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import type { TFunction } from "i18next";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { currentLocale } from "@/i18n";
import { fmtDateTime } from "@/lib/format";
import type { SessionAutomationJob } from "@/lib/types";
interface DeleteConfirmProps { interface DeleteConfirmProps {
open: boolean; open: boolean;
title: string; title: string;
automations?: SessionAutomationJob[];
onCancel: () => void; onCancel: () => void;
onConfirm: () => void; onConfirm: () => void;
} }
@ -21,14 +26,19 @@ interface DeleteConfirmProps {
export function DeleteConfirm({ export function DeleteConfirm({
open, open,
title, title,
automations = [],
onCancel, onCancel,
onConfirm, onConfirm,
}: DeleteConfirmProps) { }: DeleteConfirmProps) {
const { t } = useTranslation(); 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 ( return (
<AlertDialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}> <AlertDialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
<AlertDialogContent <AlertDialogContent
className="w-[min(calc(100vw-2rem),22.75rem)] gap-0 rounded-[28px] border border-white/70 bg-card/95 p-5 text-center shadow-[0_24px_80px_rgba(15,23,42,0.20)] backdrop-blur-xl data-[state=open]:zoom-in-95 sm:rounded-[28px]" className="w-[min(calc(100vw-2rem),24rem)] gap-0 rounded-[28px] border border-white/70 bg-card/95 p-5 text-center shadow-[0_24px_80px_rgba(15,23,42,0.20)] backdrop-blur-xl data-[state=open]:zoom-in-95 sm:rounded-[28px]"
> >
<AlertDialogHeader className="items-center space-y-0 text-center"> <AlertDialogHeader className="items-center space-y-0 text-center">
<div className="mb-5 grid h-16 w-16 place-items-center rounded-full bg-destructive/10 text-destructive"> <div className="mb-5 grid h-16 w-16 place-items-center rounded-full bg-destructive/10 text-destructive">
@ -40,8 +50,35 @@ export function DeleteConfirm({
{t("deleteConfirm.title", { title })} {t("deleteConfirm.title", { title })}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground"> <AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
{t("deleteConfirm.description")} {hasAutomations
? t("deleteConfirm.automationsDescription")
: t("deleteConfirm.description")}
</AlertDialogDescription> </AlertDialogDescription>
{hasAutomations ? (
<div className="mt-4 max-h-40 w-full overflow-y-auto rounded-2xl bg-muted/55 px-3 py-2 text-left">
{visibleAutomations.map((job) => (
<div key={job.id} className="min-w-0 py-1.5">
<div className="truncate text-[13px] font-medium leading-5 text-foreground">
{job.name || job.id}
</div>
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-[11.5px] leading-5 text-muted-foreground">
<span className="truncate">
{formatAutomationSchedule(job, t, locale)}
</span>
<span aria-hidden>·</span>
<span className="truncate">{formatAutomationNextRun(job, t, locale)}</span>
</div>
</div>
))}
{hiddenCount > 0 ? (
<div className="text-[13px] leading-6 text-muted-foreground">
{t("deleteConfirm.moreAutomations", {
count: hiddenCount,
})}
</div>
) : null}
</div>
) : null}
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter className="mt-7 grid grid-cols-2 gap-3 space-x-0"> <AlertDialogFooter className="mt-7 grid grid-cols-2 gap-3 space-x-0">
<AlertDialogCancel <AlertDialogCancel
@ -54,10 +91,72 @@ export function DeleteConfirm({
onClick={onConfirm} onClick={onConfirm}
className="h-11 rounded-full bg-destructive px-5 text-[15px] font-semibold text-destructive-foreground shadow-[0_10px_25px_rgba(239,68,68,0.28)] hover:bg-destructive/90" className="h-11 rounded-full bg-destructive px-5 text-[15px] font-semibold text-destructive-foreground shadow-[0_10px_25px_rgba(239,68,68,0.28)] hover:bg-destructive/90"
> >
{t("deleteConfirm.confirm")} {hasAutomations
? t("deleteConfirm.confirmWithAutomations")
: t("deleteConfirm.confirm")}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
); );
} }
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);
}

View File

@ -176,6 +176,9 @@ function formatNextRun(job: SessionAutomationJob, t: TFunction, now: number) {
if (!job.enabled) { if (!job.enabled) {
return { label: t("thread.sessionInfo.next.disabled"), title: "" }; 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; const next = job.state.next_run_at_ms;
if (!next) { if (!next) {
return { label: t("thread.sessionInfo.next.none"), title: "" }; return { label: t("thread.sessionInfo.next.none"), title: "" };

View File

@ -8,6 +8,7 @@ import {
normalizeToolProgressEvents, normalizeToolProgressEvents,
toolTraceLinesFromEvents, toolTraceLinesFromEvents,
} from "@/lib/tool-traces"; } from "@/lib/tool-traces";
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
import type { StreamError } from "@/lib/nanobot-client"; import type { StreamError } from "@/lib/nanobot-client";
import type { import type {
InboundEvent, InboundEvent,
@ -450,12 +451,8 @@ export function useNanobotStream(
} { } {
const { client } = useClient(); const { client } = useClient();
const [messages, setMessages] = useState<UIMessage[]>(initialMessages); const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
/** If the last loaded message is a trace row (e.g. "Using 2 tools"), /** If history ends in unfinished agent activity, keep the loading spinner alive. */
* the model was still processing when the page loaded keep the const initialStreaming = hasPendingAgentActivity(initialMessages);
* loading spinner alive so the user sees the model is active. */
const initialStreaming = initialMessages.length > 0
? initialMessages[initialMessages.length - 1].kind === "trace"
: false;
const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls); const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls);
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */ /** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
const [runStartedAt, setRunStartedAt] = useState<number | null>(null); const [runStartedAt, setRunStartedAt] = useState<number | null>(null);
@ -694,9 +691,7 @@ export function useNanobotStream(
useEffect(() => { useEffect(() => {
setMessages(initialMessages); setMessages(initialMessages);
setIsStreaming( setIsStreaming(
(initialMessages.length > 0 hasPendingAgentActivity(initialMessages) || hasPendingToolCalls,
? initialMessages[initialMessages.length - 1].kind === "trace"
: false) || hasPendingToolCalls,
); );
setStreamError(null); setStreamError(null);
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null); setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);

View File

@ -5,15 +5,24 @@ import i18n from "@/i18n";
import { import {
ApiError, ApiError,
deleteSession as apiDeleteSession, deleteSession as apiDeleteSession,
fetchSessionAutomations,
fetchWebuiThread, fetchWebuiThread,
listSessions, listSessions,
} from "@/lib/api"; } from "@/lib/api";
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
import { deriveTitle } from "@/lib/format"; 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 EMPTY_MESSAGES: UIMessage[] = [];
const INITIAL_HISTORY_PAGE_LIMIT = 160; const INITIAL_HISTORY_PAGE_LIMIT = 160;
const OLDER_HISTORY_PAGE_LIMIT = 120; const OLDER_HISTORY_PAGE_LIMIT = 120;
const CHAT_CREATE_TIMEOUT_MS = 60_000;
function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] { function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
return messages.map((m, idx) => ({ return messages.map((m, idx) => ({
@ -23,6 +32,16 @@ function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
})); }));
} }
function hasPendingToolCallsFromThread(
body: Awaited<ReturnType<typeof fetchWebuiThread>>,
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. */ /** Sidebar state: fetches the full session list and exposes create / delete actions. */
export function useSessions(): { export function useSessions(): {
sessions: ChatSummary[]; sessions: ChatSummary[];
@ -31,7 +50,11 @@ export function useSessions(): {
refresh: () => Promise<void>; refresh: () => Promise<void>;
createChat: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string>; createChat: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string>;
forkChat: (sourceChatId: string, beforeUserIndex: number, title?: string) => Promise<string>; forkChat: (sourceChatId: string, beforeUserIndex: number, title?: string) => Promise<string>;
deleteChat: (key: string) => Promise<void>; deleteChat: (
key: string,
options?: { deleteAutomations?: boolean },
) => Promise<SessionDeleteResult>;
getSessionAutomations: (key: string) => Promise<SessionAutomationJob[]>;
} { } {
const { client, token } = useClient(); const { client, token } = useClient();
const [sessions, setSessions] = useState<ChatSummary[]>([]); const [sessions, setSessions] = useState<ChatSummary[]>([]);
@ -78,7 +101,7 @@ export function useSessions(): {
}, [client, refresh]); }, [client, refresh]);
const createChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null): Promise<string> => { const createChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null): Promise<string> => {
const chatId = await client.newChat(5_000, workspaceScope); const chatId = await client.newChat(CHAT_CREATE_TIMEOUT_MS, workspaceScope);
const key = `websocket:${chatId}`; const key = `websocket:${chatId}`;
optimisticKeysRef.current.add(key); optimisticKeysRef.current.add(key);
// Optimistic insert; a subsequent refresh will replace it with the // Optimistic insert; a subsequent refresh will replace it with the
@ -104,7 +127,12 @@ export function useSessions(): {
beforeUserIndex: number, beforeUserIndex: number,
title?: string, title?: string,
): Promise<string> => { ): Promise<string> => {
const chatId = await client.forkChat(sourceChatId, beforeUserIndex, title); const chatId = await client.forkChat(
sourceChatId,
beforeUserIndex,
title,
CHAT_CREATE_TIMEOUT_MS,
);
const key = `websocket:${chatId}`; const key = `websocket:${chatId}`;
optimisticKeysRef.current.add(key); optimisticKeysRef.current.add(key);
setSessions((prev) => [ setSessions((prev) => [
@ -124,15 +152,31 @@ export function useSessions(): {
}, [client]); }, [client]);
const deleteChat = useCallback( const deleteChat = useCallback(
async (key: string) => { async (key: string, options?: { deleteAutomations?: boolean }) => {
await apiDeleteSession(tokenRef.current, key); const result = await apiDeleteSession(tokenRef.current, key, options);
if (!result.deleted) return result;
optimisticKeysRef.current.delete(key); optimisticKeysRef.current.delete(key);
setSessions((prev) => prev.filter((s) => s.key !== 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. */ /** 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; return;
} }
const ui = persistedMessagesToUi(body.messages); const ui = persistedMessagesToUi(body.messages);
const last = ui[ui.length - 1]; const hasPending = hasPendingToolCallsFromThread(body, ui);
const hasPending = last?.kind === "trace";
const forkBoundary = typeof body.fork_boundary_message_count === "number" const forkBoundary = typeof body.fork_boundary_message_count === "number"
? Math.max(0, Math.min(body.fork_boundary_message_count, ui.length)) ? Math.max(0, Math.min(body.fork_boundary_message_count, ui.length))
: null; : null;
@ -326,13 +369,12 @@ export function useSessionHistory(key: string | null): {
? null ? null
: prev.forkBoundaryMessageCount + older.length; : prev.forkBoundaryMessageCount + older.length;
const nextMessages = [...older, ...prev.messages]; const nextMessages = [...older, ...prev.messages];
const last = nextMessages[nextMessages.length - 1];
return { return {
...prev, ...prev,
messages: nextMessages, messages: nextMessages,
loadingOlder: false, loadingOlder: false,
error: null, error: null,
hasPendingToolCalls: last?.kind === "trace", hasPendingToolCalls: hasPendingAgentActivity(nextMessages),
forkBoundaryMessageCount: olderBoundary ?? shiftedBoundary, forkBoundaryMessageCount: olderBoundary ?? shiftedBoundary,
beforeCursor: body.page?.before_cursor ?? null, beforeCursor: body.page?.before_cursor ?? null,
hasMoreBefore: body.page?.has_more_before === true, hasMoreBefore: body.page?.has_more_before === true,

View File

@ -551,7 +551,22 @@
"title": "Delete this chat?", "title": "Delete this chat?",
"description": "This action cannot be undone.", "description": "This action cannot be undone.",
"cancel": "Cancel", "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": { "connection": {
"idle": "Idle", "idle": "Idle",
@ -648,6 +663,7 @@
}, },
"next": { "next": {
"label": "{{time}}", "label": "{{time}}",
"pending": "Runs shortly",
"disabled": "Paused", "disabled": "Paused",
"none": "No next run" "none": "No next run"
} }

View File

@ -551,7 +551,22 @@
"title": "¿Eliminar este chat?", "title": "¿Eliminar este chat?",
"description": "Esta acción no se puede deshacer.", "description": "Esta acción no se puede deshacer.",
"cancel": "Cancelar", "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": { "connection": {
"idle": "Inactivo", "idle": "Inactivo",
@ -648,6 +663,7 @@
}, },
"next": { "next": {
"label": "Siguiente {{time}}", "label": "Siguiente {{time}}",
"pending": "Se ejecutará pronto",
"disabled": "En pausa", "disabled": "En pausa",
"none": "Sin próxima ejecución" "none": "Sin próxima ejecución"
} }

View File

@ -551,7 +551,22 @@
"title": "Supprimer cette discussion ?", "title": "Supprimer cette discussion ?",
"description": "Cette action est irréversible.", "description": "Cette action est irréversible.",
"cancel": "Annuler", "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": { "connection": {
"idle": "Inactif", "idle": "Inactif",
@ -648,6 +663,7 @@
}, },
"next": { "next": {
"label": "Prochaine {{time}}", "label": "Prochaine {{time}}",
"pending": "Exécution imminente",
"disabled": "En pause", "disabled": "En pause",
"none": "Aucune prochaine exécution" "none": "Aucune prochaine exécution"
} }

View File

@ -551,7 +551,22 @@
"title": "Hapus obrolan ini?", "title": "Hapus obrolan ini?",
"description": "Tindakan ini tidak dapat dibatalkan.", "description": "Tindakan ini tidak dapat dibatalkan.",
"cancel": "Batal", "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": { "connection": {
"idle": "Idle", "idle": "Idle",
@ -648,6 +663,7 @@
}, },
"next": { "next": {
"label": "Berikutnya {{time}}", "label": "Berikutnya {{time}}",
"pending": "Segera berjalan",
"disabled": "Dijeda", "disabled": "Dijeda",
"none": "Tidak ada jadwal berikutnya" "none": "Tidak ada jadwal berikutnya"
} }

View File

@ -551,7 +551,22 @@
"title": "このチャットを削除しますか?", "title": "このチャットを削除しますか?",
"description": "この操作は元に戻せません。", "description": "この操作は元に戻せません。",
"cancel": "キャンセル", "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": { "connection": {
"idle": "待機中", "idle": "待機中",
@ -648,6 +663,7 @@
}, },
"next": { "next": {
"label": "次回 {{time}}", "label": "次回 {{time}}",
"pending": "まもなく実行",
"disabled": "一時停止", "disabled": "一時停止",
"none": "次回実行なし" "none": "次回実行なし"
} }

View File

@ -551,7 +551,22 @@
"title": "이 채팅을 삭제할까요?", "title": "이 채팅을 삭제할까요?",
"description": "이 작업은 되돌릴 수 없습니다.", "description": "이 작업은 되돌릴 수 없습니다.",
"cancel": "취소", "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": { "connection": {
"idle": "대기 중", "idle": "대기 중",
@ -648,6 +663,7 @@
}, },
"next": { "next": {
"label": "다음 {{time}}", "label": "다음 {{time}}",
"pending": "곧 실행됨",
"disabled": "일시 중지됨", "disabled": "일시 중지됨",
"none": "다음 실행 없음" "none": "다음 실행 없음"
} }

View File

@ -551,7 +551,22 @@
"title": "Xóa cuộc trò chuyện này?", "title": "Xóa cuộc trò chuyện này?",
"description": "Không thể hoàn tác thao tác này.", "description": "Không thể hoàn tác thao tác này.",
"cancel": "Hủ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": { "connection": {
"idle": "Rảnh", "idle": "Rảnh",
@ -648,6 +663,7 @@
}, },
"next": { "next": {
"label": "Tiếp theo {{time}}", "label": "Tiếp theo {{time}}",
"pending": "Sắp chạy",
"disabled": "Đã tạm dừng", "disabled": "Đã tạm dừng",
"none": "Không có lần chạy tiếp theo" "none": "Không có lần chạy tiếp theo"
} }

View File

@ -551,7 +551,22 @@
"title": "删除这个对话?", "title": "删除这个对话?",
"description": "此操作无法撤销。", "description": "此操作无法撤销。",
"cancel": "取消", "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": { "connection": {
"idle": "空闲", "idle": "空闲",
@ -648,6 +663,7 @@
}, },
"next": { "next": {
"label": "下次 {{time}}", "label": "下次 {{time}}",
"pending": "即将执行",
"disabled": "已暂停", "disabled": "已暂停",
"none": "没有下次运行" "none": "没有下次运行"
} }

View File

@ -551,7 +551,22 @@
"title": "刪除這個對話?", "title": "刪除這個對話?",
"description": "此操作無法復原。", "description": "此操作無法復原。",
"cancel": "取消", "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": { "connection": {
"idle": "閒置", "idle": "閒置",
@ -648,6 +663,7 @@
}, },
"next": { "next": {
"label": "下次 {{time}}", "label": "下次 {{time}}",
"pending": "即將執行",
"disabled": "已暫停", "disabled": "已暫停",
"none": "沒有下次執行" "none": "沒有下次執行"
} }

View File

@ -52,6 +52,38 @@ export function isAgentActivityMember(message: UIMessage): boolean {
return isReasoningOnlyAssistant(message) || message.kind === "trace"; 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( export function normalizeActivityTimeline(
messages: UIMessage[], messages: UIMessage[],
options: NormalizeActivityTimelineOptions = {}, options: NormalizeActivityTimelineOptions = {},

View File

@ -9,6 +9,7 @@ import type {
NetworkSafetySettingsUpdate, NetworkSafetySettingsUpdate,
ProviderModelsPayload, ProviderModelsPayload,
ProviderSettingsUpdate, ProviderSettingsUpdate,
SessionDeleteResult,
SessionAutomationsPayload, SessionAutomationsPayload,
SettingsPayload, SettingsPayload,
SettingsUpdate, SettingsUpdate,
@ -211,13 +212,18 @@ export async function fetchSkillDetail(
export async function deleteSession( export async function deleteSession(
token: string, token: string,
key: string, key: string,
optionsOrBase?: { deleteAutomations?: boolean } | string,
base: string = "", base: string = "",
): Promise<boolean> { ): Promise<SessionDeleteResult> {
const body = await request<{ deleted: boolean }>( const options = typeof optionsOrBase === "string" ? undefined : optionsOrBase;
`${base}/api/sessions/${encodeURIComponent(key)}/delete`, 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<SessionDeleteResult>(
`${resolvedBase}/api/sessions/${encodeURIComponent(key)}/delete${suffix}`,
token, token,
); );
return body.deleted;
} }
export async function fetchSettings( export async function fetchSettings(

View File

@ -113,11 +113,18 @@ export interface SessionAutomationJob {
state: { state: {
next_run_at_ms?: number | null; next_run_at_ms?: number | null;
last_status?: "ok" | "error" | "skipped" | string | null; last_status?: "ok" | "error" | "skipped" | string | null;
pending?: boolean;
}; };
} }
export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; } export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; }
export interface SessionDeleteResult {
deleted: boolean;
blocked_by_automations?: boolean;
automations?: SessionAutomationJob[];
}
export interface SkillSummary { export interface SkillSummary {
name: string; name: string;
description: string; description: string;
@ -875,6 +882,7 @@ export interface WebuiThreadPersistedPayload {
savedAt?: string; savedAt?: string;
messages: UIMessage[]; messages: UIMessage[];
fork_boundary_message_count?: number; fork_boundary_message_count?: number;
has_pending_tool_calls?: boolean;
page?: WebuiThreadPagePayload; page?: WebuiThreadPagePayload;
workspace_scope?: WorkspaceScopePayload; workspace_scope?: WorkspaceScopePayload;
} }

View File

@ -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 () => { it("serializes settings updates as a narrow query string", async () => {
await updateSettings("tok", { await updateSettings("tok", {
modelPreset: "default", modelPreset: "default",

View File

@ -1,12 +1,14 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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 connectSpy = vi.fn();
const refreshSpy = vi.fn(); const refreshSpy = vi.fn();
const createChatSpy = vi.fn().mockResolvedValue("chat-1"); const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn(); const deleteChatSpy = vi.fn();
const getSessionAutomationsSpy = vi.fn<(key: string) => Promise<SessionAutomationJob[]>>();
const toggleThemeSpy = vi.fn(); const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn(); const updateUrlSpy = vi.fn();
const attachSpy = vi.fn(); const attachSpy = vi.fn();
@ -146,9 +148,12 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
refresh: refreshSpy, refresh: refreshSpy,
createChat: createChatSpy, createChat: createChatSpy,
forkChat: async () => "fork-chat", forkChat: async () => "fork-chat",
deleteChat: async (key: string) => { getSessionAutomations: getSessionAutomationsSpy,
await deleteChatSpy(key); deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => {
if (options === undefined) await deleteChatSpy(key);
else await deleteChatSpy(key, options);
setSessions((prev: ChatSummary[]) => prev.filter((s) => s.key !== key)); 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"; import App from "@/App";
describe("App layout", () => { describe("App layout", () => {
beforeEach(() => { beforeEach(async () => {
await i18n.changeLanguage("en");
mockSessions = []; mockSessions = [];
connectSpy.mockClear(); connectSpy.mockClear();
updateUrlSpy.mockClear(); updateUrlSpy.mockClear();
refreshSpy.mockReset(); refreshSpy.mockReset();
createChatSpy.mockClear(); createChatSpy.mockClear();
deleteChatSpy.mockReset(); deleteChatSpy.mockReset();
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
toggleThemeSpy.mockReset(); toggleThemeSpy.mockReset();
attachSpy.mockReset(); attachSpy.mockReset();
runStatusHandlers.clear(); runStatusHandlers.clear();
@ -433,6 +440,74 @@ describe("App layout", () => {
expect(document.body.style.pointerEvents).not.toBe("none"); expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000); }, 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(<App />);
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 () => { it("keeps the mobile session action menu inside the sidebar sheet", async () => {
mockSessions = [ mockSessions = [
{ {

View File

@ -5,14 +5,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover"; import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { setAppLanguage } from "@/i18n"; import { setAppLanguage } from "@/i18n";
function automationJob(nextRunAt = Date.now() + 3_600_000) { function automationJob(
nextRunAt = Date.now() + 3_600_000,
state: Record<string, unknown> = {},
) {
return { return {
id: "job-1", id: "job-1",
name: "Morning check", name: "Morning check",
enabled: true, enabled: true,
schedule: { kind: "every", every_ms: 3_600_000 }, schedule: { kind: "every", every_ms: 3_600_000 },
payload: { message: "Check the project status" }, 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", () => { describe("SessionInfoPopover", () => {
beforeEach(() => { beforeEach(async () => {
await setAppLanguage("en");
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn().mockResolvedValue(automationsResponse([automationJob()])), vi.fn().mockResolvedValue(automationsResponse([automationJob()])),
@ -86,6 +90,29 @@ describe("SessionInfoPopover", () => {
expect(screen.queryByText("Automations")).not.toBeInTheDocument(); 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(
<SessionInfoPopover
sessionKey="websocket:chat-1"
token="tok"
title="Release work"
/>,
);
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 () => { it("refreshes while open so completed one-shot automations disappear", async () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",

View File

@ -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 () => { it("drops pending stream work when switching chats", async () => {
const fake = fakeClient(); const fake = fakeClient();
const { result, rerender } = renderHook( const { result, rerender } = renderHook(

View File

@ -103,7 +103,7 @@ describe("useSessions", () => {
preview: "Beta", preview: "Beta",
}, },
]); ]);
vi.mocked(api.deleteSession).mockResolvedValue(true); vi.mocked(api.deleteSession).mockResolvedValue({ deleted: true });
const { result } = renderHook(() => useSessions(), { const { result } = renderHook(() => useSessions(), {
wrapper: wrap(fakeClient()), wrapper: wrap(fakeClient()),
@ -115,10 +115,42 @@ describe("useSessions", () => {
await result.current.deleteChat("websocket:chat-a"); 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"]); 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<ReturnType<typeof result.current.deleteChat>> | 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 () => { it("refreshes sessions when the websocket reports a session update", async () => {
vi.mocked(api.listSessions) vi.mocked(api.listSessions)
.mockResolvedValueOnce([ .mockResolvedValueOnce([
@ -187,7 +219,7 @@ describe("useSessions", () => {
await result.current.createChat(); 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"]); expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-new"]);
await act(async () => { await act(async () => {
@ -226,7 +258,7 @@ describe("useSessions", () => {
await result.current.createChat(workspaceScope); 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); expect(result.current.sessions[0]?.workspaceScope).toEqual(workspaceScope);
}); });
@ -384,6 +416,40 @@ describe("useSessions", () => {
expect(result.current.hasPendingToolCalls).toBe(true); 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 () => { it("does not flag transcript as pending when last row is not a trace", async () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue({ vi.mocked(api.fetchWebuiThread).mockResolvedValue({
schemaVersion: 3, schemaVersion: 3,