mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 07:14:08 +00:00
Merge PR #4299: feat(cron): bind scheduled automations to sessions
feat(cron): bind scheduled automations to sessions
This commit is contained in:
commit
dac4e39bcf
142
nanobot/agent/cron_turns.py
Normal file
142
nanobot/agent/cron_turns.py
Normal 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
|
||||
@ -19,6 +19,7 @@ from nanobot.agent import context as agent_context
|
||||
from nanobot.agent import model_presets as preset_helpers
|
||||
from nanobot.agent.autocompact import AutoCompact
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.cron_turns import CronTurnCoordinator
|
||||
from nanobot.agent.hook import AgentHook, CompositeHook
|
||||
from nanobot.agent.memory import Consolidator
|
||||
from nanobot.agent.progress_hook import AgentProgressHook
|
||||
@ -39,6 +40,9 @@ from nanobot.bus.runtime_events import (
|
||||
)
|
||||
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
||||
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
||||
from nanobot.cron.session_turns import (
|
||||
cron_history_overrides,
|
||||
)
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.providers.factory import ProviderSnapshot
|
||||
from nanobot.security.workspace_access import (
|
||||
@ -52,6 +56,7 @@ from nanobot.session.goal_state import (
|
||||
runner_wall_llm_timeout_s,
|
||||
sustained_goal_active,
|
||||
)
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY, session_key_for_channel
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.utils.document import extract_documents, reference_non_image_attachments
|
||||
from nanobot.utils.helpers import image_placeholder_text
|
||||
@ -72,8 +77,6 @@ if TYPE_CHECKING:
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
|
||||
UNIFIED_SESSION_KEY = "unified:default"
|
||||
|
||||
class TurnState(Enum):
|
||||
RESTORE = auto()
|
||||
COMPACT = auto()
|
||||
@ -300,6 +303,11 @@ class AgentLoop:
|
||||
# When a session has an active task, new messages for that session
|
||||
# are routed here instead of creating a new task.
|
||||
self._pending_queues: dict[str, asyncio.Queue] = {}
|
||||
self._cron_turns = CronTurnCoordinator(
|
||||
publish_inbound=self.bus.publish_inbound,
|
||||
dispatch=self._dispatch,
|
||||
is_running=lambda: self._running,
|
||||
)
|
||||
# NANOBOT_MAX_CONCURRENT_REQUESTS: <=0 means unlimited; default 3.
|
||||
_max = int(os.environ.get("NANOBOT_MAX_CONCURRENT_REQUESTS", "3"))
|
||||
self._concurrency_gate: asyncio.Semaphore | None = (
|
||||
@ -512,13 +520,11 @@ class AgentLoop:
|
||||
"""Update context for all tools that need routing info."""
|
||||
from nanobot.agent.tools.context import ContextAware
|
||||
|
||||
if session_key is not None:
|
||||
effective_key = session_key
|
||||
elif self._unified_session:
|
||||
effective_key = UNIFIED_SESSION_KEY
|
||||
else:
|
||||
effective_key = f"{channel}:{chat_id}"
|
||||
|
||||
effective_key = session_key or session_key_for_channel(
|
||||
channel,
|
||||
chat_id,
|
||||
unified_session=self._unified_session,
|
||||
)
|
||||
request_ctx = RequestContext(
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
@ -565,6 +571,12 @@ class AgentLoop:
|
||||
def _runtime_events(self) -> RuntimeEventPublisher:
|
||||
return ensure_runtime_event_publisher(self)
|
||||
|
||||
async def submit_cron_turn(self, msg: InboundMessage) -> OutboundMessage | None:
|
||||
return await self._cron_turns.submit(msg)
|
||||
|
||||
def pending_cron_job_ids_for_session(self, session_key: str) -> set[str]:
|
||||
return self._cron_turns.pending_job_ids_for_session(session_key)
|
||||
|
||||
def _persist_user_message_early(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
@ -583,6 +595,10 @@ class AgentLoop:
|
||||
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
|
||||
extra.update(kwargs)
|
||||
text = msg.content if isinstance(msg.content, str) else ""
|
||||
text_override, cron_extra = cron_history_overrides(msg.metadata)
|
||||
if text_override is not None:
|
||||
text = text_override
|
||||
extra.update(cron_extra)
|
||||
session.add_message("user", text, **extra)
|
||||
self._mark_pending_user_turn(session)
|
||||
self.sessions.save(session)
|
||||
@ -883,6 +899,16 @@ class AgentLoop:
|
||||
self.commands.dispatch_priority,
|
||||
)
|
||||
continue
|
||||
if self._cron_turns.defer_if_active(
|
||||
msg,
|
||||
session_key=effective_key,
|
||||
active_session_keys=self._pending_queues.keys(),
|
||||
):
|
||||
logger.info(
|
||||
"Deferred cron turn for active session {}",
|
||||
effective_key,
|
||||
)
|
||||
continue
|
||||
# If this session already has an active pending queue (i.e. a task
|
||||
# is processing this session), route the message there for mid-turn
|
||||
# injection instead of creating a competing task.
|
||||
@ -996,7 +1022,12 @@ class AgentLoop:
|
||||
session_key=session_key,
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
self._cron_turns.complete(msg, response=response)
|
||||
except asyncio.CancelledError:
|
||||
self._cron_turns.complete(
|
||||
msg,
|
||||
error=asyncio.CancelledError(),
|
||||
)
|
||||
logger.info("Task cancelled for session {}", session_key)
|
||||
# Preserve partial context from the interrupted turn so
|
||||
# the user does not lose tool results and assistant
|
||||
@ -1022,7 +1053,7 @@ class AgentLoop:
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception("Error processing message for session {}", session_key)
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id,
|
||||
@ -1035,6 +1066,7 @@ class AgentLoop:
|
||||
session_key=session_key,
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
self._cron_turns.complete(msg, error=exc)
|
||||
finally:
|
||||
# Drain any messages still in the pending queue and re-publish
|
||||
# them to the bus so they are processed as fresh inbound messages
|
||||
@ -1065,12 +1097,14 @@ class AgentLoop:
|
||||
msg, session_key, "idle"
|
||||
)
|
||||
self._runtime_events().clear_turn(session_key)
|
||||
await self._cron_turns.publish_next_deferred(session_key)
|
||||
finally:
|
||||
if pending is None:
|
||||
await self._runtime_events().run_status_changed(
|
||||
msg, session_key, "idle"
|
||||
)
|
||||
self._runtime_events().clear_turn(session_key)
|
||||
await self._cron_turns.publish_next_deferred(session_key)
|
||||
|
||||
async def close_mcp(self) -> None:
|
||||
"""Drain pending background archives, then close MCP connections."""
|
||||
|
||||
@ -9,13 +9,13 @@ from typing import Any
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||
from nanobot.agent.tools.schema import (
|
||||
BooleanSchema,
|
||||
IntegerSchema,
|
||||
StringSchema,
|
||||
tool_parameters_schema,
|
||||
)
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronJob, CronJobState, CronSchedule
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||
|
||||
_CRON_PARAMETERS = tool_parameters_schema(
|
||||
action=StringSchema("Action to perform", enum=["add", "list", "remove"]),
|
||||
@ -38,10 +38,6 @@ _CRON_PARAMETERS = tool_parameters_schema(
|
||||
"ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00'). "
|
||||
"Naive values use the tool's default timezone."
|
||||
),
|
||||
deliver=BooleanSchema(
|
||||
description="Whether to deliver the execution result to the user channel (default true)",
|
||||
default=True,
|
||||
),
|
||||
job_id=StringSchema("REQUIRED when action='remove'. Job ID to remove (obtain via action='list')."),
|
||||
required=["action"],
|
||||
description=(
|
||||
@ -61,10 +57,13 @@ class CronTool(Tool, ContextAware):
|
||||
def __init__(self, cron_service: CronService, default_timezone: str = "UTC"):
|
||||
self._cron = cron_service
|
||||
self._default_timezone = default_timezone
|
||||
self._channel: ContextVar[str] = ContextVar("cron_channel", default="")
|
||||
self._chat_id: ContextVar[str] = ContextVar("cron_chat_id", default="")
|
||||
self._metadata: ContextVar[dict] = ContextVar("cron_metadata", default={})
|
||||
self._session_key: ContextVar[str] = ContextVar("cron_session_key", default="")
|
||||
self._origin_channel: ContextVar[str] = ContextVar("cron_origin_channel", default="")
|
||||
self._origin_chat_id: ContextVar[str] = ContextVar("cron_origin_chat_id", default="")
|
||||
self._origin_metadata: ContextVar[dict[str, Any] | None] = ContextVar(
|
||||
"cron_origin_metadata",
|
||||
default=None,
|
||||
)
|
||||
self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False)
|
||||
|
||||
@classmethod
|
||||
@ -76,11 +75,14 @@ class CronTool(Tool, ContextAware):
|
||||
return cls(cron_service=ctx.cron_service, default_timezone=ctx.timezone)
|
||||
|
||||
def set_context(self, ctx: RequestContext) -> None:
|
||||
"""Set the current session context for delivery."""
|
||||
self._channel.set(ctx.channel)
|
||||
self._chat_id.set(ctx.chat_id)
|
||||
self._metadata.set(ctx.metadata)
|
||||
self._session_key.set(ctx.session_key or f"{ctx.channel}:{ctx.chat_id}")
|
||||
"""Set the current session context for scheduled cron job ownership."""
|
||||
raw_key = f"{ctx.channel}:{ctx.chat_id}" if ctx.channel and ctx.chat_id else ""
|
||||
self._session_key.set(
|
||||
raw_key if ctx.session_key == UNIFIED_SESSION_KEY else (ctx.session_key or "")
|
||||
)
|
||||
self._origin_channel.set(ctx.channel or "")
|
||||
self._origin_chat_id.set(ctx.chat_id or "")
|
||||
self._origin_metadata.set(dict(ctx.metadata or {}))
|
||||
|
||||
def set_cron_context(self, active: bool):
|
||||
"""Mark whether the tool is executing inside a cron job callback."""
|
||||
@ -147,7 +149,7 @@ class CronTool(Tool, ContextAware):
|
||||
if action == "add":
|
||||
if self._in_cron_context.get():
|
||||
return "Error: cannot schedule new jobs from within a cron job execution"
|
||||
return self._add_job(name, message, every_seconds, cron_expr, tz, at, deliver)
|
||||
return self._add_job(name, message, every_seconds, cron_expr, tz, at)
|
||||
elif action == "list":
|
||||
return self._list_jobs()
|
||||
elif action == "remove":
|
||||
@ -162,7 +164,6 @@ class CronTool(Tool, ContextAware):
|
||||
cron_expr: str | None,
|
||||
tz: str | None,
|
||||
at: str | None,
|
||||
deliver: bool = True,
|
||||
) -> str:
|
||||
if not message:
|
||||
return (
|
||||
@ -170,10 +171,13 @@ class CronTool(Tool, ContextAware):
|
||||
"describing what to do when the job triggers "
|
||||
"(e.g. the reminder text). Retry including message=\"...\"."
|
||||
)
|
||||
channel = self._channel.get()
|
||||
chat_id = self._chat_id.get()
|
||||
if not channel or not chat_id:
|
||||
return "Error: no session context (channel/chat_id)"
|
||||
session_key = self._session_key.get()
|
||||
if not session_key:
|
||||
return "Error: scheduled cron jobs must be created from a chat session"
|
||||
origin_channel = self._origin_channel.get()
|
||||
origin_chat_id = self._origin_chat_id.get()
|
||||
if not origin_channel or not origin_chat_id:
|
||||
return "Error: scheduled cron jobs must be created from a chat session"
|
||||
if tz and not cron_expr:
|
||||
return "Error: tz can only be used with cron_expr"
|
||||
if tz:
|
||||
@ -210,12 +214,11 @@ class CronTool(Tool, ContextAware):
|
||||
name=name or message[:30],
|
||||
schedule=schedule,
|
||||
message=message,
|
||||
deliver=deliver,
|
||||
channel=channel,
|
||||
to=chat_id,
|
||||
delete_after_run=delete_after,
|
||||
channel_meta=self._metadata.get(),
|
||||
session_key=self._session_key.get() or None,
|
||||
session_key=session_key,
|
||||
origin_channel=origin_channel,
|
||||
origin_chat_id=origin_chat_id,
|
||||
origin_metadata=dict(self._origin_metadata.get() or {}),
|
||||
)
|
||||
return f"Created job '{job.name}' (id: {job.id})"
|
||||
|
||||
|
||||
@ -58,6 +58,7 @@ class ChannelManager:
|
||||
session_manager: "SessionManager | None" = None,
|
||||
cron_service: Any | None = None,
|
||||
webui_runtime_model_name: Callable[[], str | None] | None = None,
|
||||
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
||||
webui_static_dist: bool = True,
|
||||
webui_runtime_surface: str = "browser",
|
||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||
@ -67,6 +68,7 @@ class ChannelManager:
|
||||
self._session_manager = session_manager
|
||||
self._cron_service = cron_service
|
||||
self._webui_runtime_model_name = webui_runtime_model_name
|
||||
self._webui_cron_pending_job_ids = webui_cron_pending_job_ids
|
||||
self._webui_static_dist = webui_static_dist
|
||||
self._webui_runtime_surface = webui_runtime_surface
|
||||
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
||||
@ -126,6 +128,7 @@ class ChannelManager:
|
||||
runtime_surface=self._webui_runtime_surface,
|
||||
runtime_capabilities_overrides=self._webui_runtime_capabilities,
|
||||
cron_service=self._cron_service,
|
||||
cron_pending_job_ids=self._webui_cron_pending_job_ids,
|
||||
logger=logger,
|
||||
)
|
||||
kwargs["gateway"] = gateway
|
||||
|
||||
@ -5,10 +5,8 @@ import os
|
||||
import select
|
||||
import signal
|
||||
import sys
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from contextlib import nullcontext, suppress
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@ -87,32 +85,6 @@ class SafeFileHistory(FileHistory):
|
||||
super().store_string(_sanitize_surrogates(string))
|
||||
|
||||
|
||||
_WEBUI_TURN_META_KEY = "webui_turn_id"
|
||||
_WEBUI_MESSAGE_SOURCE_META_KEY = "_webui_message_source"
|
||||
_PROACTIVE_WEBUI_METADATA: ContextVar[dict[str, Any] | None] = ContextVar(
|
||||
"proactive_webui_metadata",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def _proactive_delivery_metadata(
|
||||
channel: str,
|
||||
metadata: dict[str, Any] | None,
|
||||
*,
|
||||
turn_seed: str,
|
||||
source_label: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return channel metadata for a fresh proactive delivery turn."""
|
||||
out = dict(metadata or {})
|
||||
out.pop(_WEBUI_TURN_META_KEY, None)
|
||||
if channel == "websocket":
|
||||
out[_WEBUI_TURN_META_KEY] = f"{turn_seed}:{uuid.uuid4().hex}"
|
||||
source: dict[str, str] = {"kind": "cron"}
|
||||
if source_label:
|
||||
source["label"] = source_label
|
||||
out[_WEBUI_MESSAGE_SOURCE_META_KEY] = source
|
||||
return out
|
||||
|
||||
app = typer.Typer(
|
||||
name="nanobot",
|
||||
context_settings={"help_option_names": ["-h", "--help"]},
|
||||
@ -970,12 +942,13 @@ def _run_gateway(
|
||||
health_server_enabled: bool = True,
|
||||
) -> None:
|
||||
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.bus.runtime_events import RuntimeEventBus
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.bound_runner import run_bound_cron_job
|
||||
from nanobot.cron.service import CronJobSkippedError, CronService
|
||||
from nanobot.cron.session_turns import is_bound_cron_job
|
||||
from nanobot.cron.types import CronJob
|
||||
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
|
||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||
@ -1024,14 +997,14 @@ def _run_gateway(
|
||||
schedule_background=lambda coro: agent._schedule_background(coro),
|
||||
).subscribe(runtime_events)
|
||||
|
||||
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.session.keys import session_key_for_channel
|
||||
|
||||
def _channel_session_key(channel: str, chat_id: str) -> str:
|
||||
return (
|
||||
UNIFIED_SESSION_KEY
|
||||
if config.agents.defaults.unified_session
|
||||
else f"{channel}:{chat_id}"
|
||||
return session_key_for_channel(
|
||||
channel,
|
||||
chat_id,
|
||||
unified_session=config.agents.defaults.unified_session,
|
||||
)
|
||||
|
||||
async def _deliver_to_channel(
|
||||
@ -1040,9 +1013,6 @@ def _run_gateway(
|
||||
"""Publish a user-visible message and mirror it into that channel's session."""
|
||||
metadata = dict(msg.metadata or {})
|
||||
record = record or bool(metadata.pop("_record_channel_delivery", False))
|
||||
proactive_webui_metadata = _PROACTIVE_WEBUI_METADATA.get()
|
||||
if record and msg.channel == "websocket" and proactive_webui_metadata:
|
||||
metadata = {**metadata, **proactive_webui_metadata}
|
||||
if metadata != (msg.metadata or {}):
|
||||
msg = OutboundMessage(
|
||||
channel=msg.channel,
|
||||
@ -1194,73 +1164,17 @@ def _run_gateway(
|
||||
logger.info("Heartbeat: silenced by post-run evaluation")
|
||||
return response
|
||||
|
||||
reminder_note = (
|
||||
"The scheduled time has arrived. Deliver this reminder to the user now, "
|
||||
"as a brief and natural message in their language. Speak directly to them — "
|
||||
"do not narrate progress, summarize, include user IDs, or add status reports "
|
||||
"like 'Done' or 'Reminded'.\n\n"
|
||||
f"Reminder: {job.payload.message}"
|
||||
if is_bound_cron_job(job):
|
||||
return await run_bound_cron_job(job, agent=agent, cron=cron)
|
||||
|
||||
reason = "unbound agent cron job must be recreated from a chat session"
|
||||
logger.warning(
|
||||
"Cron: skipped unbound agent job '{}' ({}): {}",
|
||||
job.name,
|
||||
job.id,
|
||||
reason,
|
||||
)
|
||||
|
||||
cron_tool = agent.tools.get("cron")
|
||||
cron_token = None
|
||||
if isinstance(cron_tool, CronTool):
|
||||
cron_token = cron_tool.set_cron_context(True)
|
||||
|
||||
message_record_token = None
|
||||
if isinstance(message_tool, MessageTool):
|
||||
message_record_token = message_tool.set_record_channel_delivery(True)
|
||||
|
||||
proactive_webui_metadata = _proactive_delivery_metadata(
|
||||
"websocket",
|
||||
None,
|
||||
turn_seed=f"cron:{job.id}",
|
||||
source_label=job.name,
|
||||
)
|
||||
proactive_token = _PROACTIVE_WEBUI_METADATA.set(proactive_webui_metadata)
|
||||
|
||||
try:
|
||||
resp = await agent.process_direct(
|
||||
reminder_note,
|
||||
session_key=f"cron:{job.id}",
|
||||
channel=job.payload.channel or "cli",
|
||||
chat_id=job.payload.to or "direct",
|
||||
on_progress=_silent,
|
||||
)
|
||||
finally:
|
||||
_PROACTIVE_WEBUI_METADATA.reset(proactive_token)
|
||||
if isinstance(cron_tool, CronTool) and cron_token is not None:
|
||||
cron_tool.reset_cron_context(cron_token)
|
||||
if isinstance(message_tool, MessageTool) and message_record_token is not None:
|
||||
message_tool.reset_record_channel_delivery(message_record_token)
|
||||
|
||||
response = resp.content if resp else ""
|
||||
|
||||
if job.payload.deliver and isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
|
||||
return response
|
||||
|
||||
if job.payload.deliver and job.payload.to and response:
|
||||
should_notify = await evaluate_response(
|
||||
response, reminder_note, agent.provider, agent.model,
|
||||
)
|
||||
if should_notify:
|
||||
proactive_metadata = _proactive_delivery_metadata(
|
||||
job.payload.channel or "cli",
|
||||
job.payload.channel_meta,
|
||||
turn_seed=f"cron:{job.id}",
|
||||
source_label=job.name,
|
||||
)
|
||||
await _deliver_to_channel(
|
||||
OutboundMessage(
|
||||
channel=job.payload.channel or "cli",
|
||||
chat_id=job.payload.to,
|
||||
content=response,
|
||||
metadata=proactive_metadata,
|
||||
),
|
||||
record=True,
|
||||
session_key=job.payload.session_key,
|
||||
)
|
||||
return response
|
||||
raise CronJobSkippedError(reason)
|
||||
|
||||
cron.on_job = on_cron_job
|
||||
|
||||
@ -1279,6 +1193,7 @@ def _run_gateway(
|
||||
session_manager=session_manager,
|
||||
cron_service=cron,
|
||||
webui_runtime_model_name=_webui_runtime_model_name,
|
||||
webui_cron_pending_job_ids=getattr(agent, "pending_cron_job_ids_for_session", None),
|
||||
webui_static_dist=webui_static_dist,
|
||||
webui_runtime_surface=webui_runtime_surface,
|
||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||
|
||||
151
nanobot/cron/bound_runner.py
Normal file
151
nanobot/cron/bound_runner.py
Normal 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
|
||||
@ -14,6 +14,7 @@ from typing import Any, Callable, Coroutine, Literal
|
||||
from filelock import FileLock
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.cron.session_turns import is_bound_cron_job
|
||||
from nanobot.cron.types import (
|
||||
CronJob,
|
||||
CronJobState,
|
||||
@ -24,6 +25,10 @@ from nanobot.cron.types import (
|
||||
)
|
||||
|
||||
|
||||
class CronJobSkippedError(Exception):
|
||||
"""Raised by cron callbacks when a job was intentionally skipped."""
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
@ -71,6 +76,62 @@ def _validate_schedule_for_add(schedule: CronSchedule) -> None:
|
||||
raise ValueError(f"unknown timezone '{schedule.tz}'") from None
|
||||
|
||||
|
||||
def _has_legacy_delivery_context(payload: CronPayload) -> bool:
|
||||
return bool(payload.deliver or payload.channel or payload.to or payload.channel_meta)
|
||||
|
||||
|
||||
def _legacy_session_key(payload: CronPayload) -> str | None:
|
||||
if payload.session_key:
|
||||
return payload.session_key
|
||||
if payload.channel and payload.to:
|
||||
return f"{payload.channel}:{payload.to}"
|
||||
return None
|
||||
|
||||
|
||||
def _disable_malformed_legacy_job(job: CronJob) -> None:
|
||||
reason = "legacy cron payload is missing channel/to; recreate it from a chat session"
|
||||
job.payload.deliver = False
|
||||
job.payload.channel = None
|
||||
job.payload.to = None
|
||||
job.payload.channel_meta = {}
|
||||
job.enabled = False
|
||||
job.state.next_run_at_ms = None
|
||||
job.state.last_status = "error"
|
||||
job.state.last_error = reason
|
||||
logger.warning("Cron: disabled malformed legacy job '{}' ({}): {}", job.name, job.id, reason)
|
||||
|
||||
|
||||
def _normalize_agent_turn_job(job: CronJob) -> bool:
|
||||
"""Migrate legacy user cron payloads into session-bound payloads.
|
||||
|
||||
Pre-bound user cron jobs stored their delivery target in ``channel``/``to``.
|
||||
Normal user-created legacy jobs always have those fields; if they are
|
||||
missing, keep the record for inspection but disable it instead of preserving
|
||||
a runtime legacy execution path.
|
||||
"""
|
||||
payload = job.payload
|
||||
if payload.kind != "agent_turn" or not _has_legacy_delivery_context(payload):
|
||||
return False
|
||||
|
||||
if not payload.channel or not payload.to:
|
||||
_disable_malformed_legacy_job(job)
|
||||
return True
|
||||
|
||||
payload.session_key = _legacy_session_key(payload)
|
||||
payload.origin_channel = payload.origin_channel or payload.channel
|
||||
payload.origin_chat_id = payload.origin_chat_id or payload.to
|
||||
if not payload.origin_metadata:
|
||||
payload.origin_metadata = dict(payload.channel_meta or {})
|
||||
|
||||
payload.deliver = False
|
||||
payload.channel = None
|
||||
payload.to = None
|
||||
payload.channel_meta = {}
|
||||
job.updated_at_ms = max(job.updated_at_ms, _now_ms())
|
||||
logger.info("Cron: migrated legacy job '{}' ({}) to session-bound payload", job.name, job.id)
|
||||
return True
|
||||
|
||||
|
||||
class CronService:
|
||||
"""Service for managing and executing scheduled jobs."""
|
||||
|
||||
@ -84,6 +145,7 @@ class CronService:
|
||||
):
|
||||
self.store_path = store_path
|
||||
self._action_path = store_path.parent / "action.jsonl"
|
||||
self._run_records_dir = store_path.parent / "runs"
|
||||
self._lock = FileLock(str(self._action_path.parent) + ".lock")
|
||||
self.on_job = on_job
|
||||
self._store: CronStore | None = None
|
||||
@ -113,7 +175,7 @@ class CronService:
|
||||
jobs = []
|
||||
version = data.get("version", 1)
|
||||
for j in data.get("jobs", []):
|
||||
jobs.append(CronJob(
|
||||
job = CronJob(
|
||||
id=j["id"],
|
||||
name=j["name"],
|
||||
enabled=j.get("enabled", True),
|
||||
@ -136,6 +198,19 @@ class CronService:
|
||||
or {}
|
||||
),
|
||||
session_key=j["payload"].get("sessionKey") or j["payload"].get("session_key"),
|
||||
origin_channel=(
|
||||
j["payload"].get("originChannel")
|
||||
or j["payload"].get("origin_channel")
|
||||
),
|
||||
origin_chat_id=(
|
||||
j["payload"].get("originChatId")
|
||||
or j["payload"].get("origin_chat_id")
|
||||
),
|
||||
origin_metadata=(
|
||||
j["payload"].get("originMetadata")
|
||||
or j["payload"].get("origin_metadata")
|
||||
or {}
|
||||
),
|
||||
),
|
||||
state=CronJobState(
|
||||
next_run_at_ms=j.get("state", {}).get("nextRunAtMs"),
|
||||
@ -155,7 +230,9 @@ class CronService:
|
||||
created_at_ms=j.get("createdAtMs", 0),
|
||||
updated_at_ms=j.get("updatedAtMs", 0),
|
||||
delete_after_run=j.get("deleteAfterRun", False),
|
||||
))
|
||||
)
|
||||
_normalize_agent_turn_job(job)
|
||||
jobs.append(job)
|
||||
except Exception:
|
||||
# Preserve the corrupt file for forensic recovery instead of
|
||||
# letting the next save overwrite it with an empty job list.
|
||||
@ -181,6 +258,7 @@ class CronService:
|
||||
jobs_map = {j.id: j for j in self._store.jobs}
|
||||
def _update(params: dict):
|
||||
j = CronJob.from_dict(params)
|
||||
_normalize_agent_turn_job(j)
|
||||
jobs_map[j.id] = j
|
||||
|
||||
def _del(params: dict):
|
||||
@ -266,6 +344,9 @@ class CronService:
|
||||
"to": j.payload.to,
|
||||
"channelMeta": j.payload.channel_meta,
|
||||
"sessionKey": j.payload.session_key,
|
||||
"originChannel": j.payload.origin_channel,
|
||||
"originChatId": j.payload.origin_chat_id,
|
||||
"originMetadata": j.payload.origin_metadata,
|
||||
},
|
||||
"state": {
|
||||
"nextRunAtMs": j.state.next_run_at_ms,
|
||||
@ -325,6 +406,23 @@ class CronService:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _safe_run_record_name(run_id: str) -> str:
|
||||
return "".join(c if c.isalnum() or c in "._-" else "_" for c in run_id)
|
||||
|
||||
def write_run_record(self, run_id: str, record: dict[str, Any]) -> None:
|
||||
"""Write an internal audit record for one cron execution."""
|
||||
name = self._safe_run_record_name(run_id)
|
||||
if not name:
|
||||
name = str(uuid.uuid4())
|
||||
path = self._run_records_dir / f"{name}.json"
|
||||
payload = {
|
||||
**record,
|
||||
"run_id": run_id,
|
||||
"updated_at_ms": _now_ms(),
|
||||
}
|
||||
self._atomic_write(path, json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the cron service."""
|
||||
self._running = True
|
||||
@ -430,6 +528,17 @@ class CronService:
|
||||
job.state.last_error = None
|
||||
logger.info("Cron: job '{}' completed", job.name)
|
||||
|
||||
except CronJobSkippedError as e:
|
||||
job.state.last_status = "skipped"
|
||||
job.state.last_error = str(e) or None
|
||||
logger.warning("Cron: job '{}' skipped: {}", job.name, job.state.last_error or "")
|
||||
except asyncio.CancelledError as e:
|
||||
current = asyncio.current_task()
|
||||
if current is not None and current.cancelling():
|
||||
raise
|
||||
job.state.last_status = "error"
|
||||
job.state.last_error = str(e) or e.__class__.__name__
|
||||
logger.exception("Cron: job '{}' was cancelled", job.name)
|
||||
except Exception as e:
|
||||
job.state.last_status = "error"
|
||||
job.state.last_error = str(e)
|
||||
@ -473,6 +582,20 @@ class CronService:
|
||||
jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled]
|
||||
return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float('inf'))
|
||||
|
||||
def list_bound_cron_jobs_for_session(
|
||||
self,
|
||||
session_key: str,
|
||||
*,
|
||||
include_disabled: bool = True,
|
||||
) -> list[CronJob]:
|
||||
"""Return user-created bound cron jobs owned by *session_key*."""
|
||||
return [
|
||||
job
|
||||
for job in self.list_jobs(include_disabled=include_disabled)
|
||||
if is_bound_cron_job(job)
|
||||
and job.payload.session_key == session_key
|
||||
]
|
||||
|
||||
def add_job(
|
||||
self,
|
||||
name: str,
|
||||
@ -484,6 +607,9 @@ class CronService:
|
||||
delete_after_run: bool = False,
|
||||
channel_meta: dict | None = None,
|
||||
session_key: str | None = None,
|
||||
origin_channel: str | None = None,
|
||||
origin_chat_id: str | None = None,
|
||||
origin_metadata: dict | None = None,
|
||||
) -> CronJob:
|
||||
"""Add a new job."""
|
||||
_validate_schedule_for_add(schedule)
|
||||
@ -502,12 +628,16 @@ class CronService:
|
||||
to=to,
|
||||
channel_meta=channel_meta or {},
|
||||
session_key=session_key,
|
||||
origin_channel=origin_channel,
|
||||
origin_chat_id=origin_chat_id,
|
||||
origin_metadata=origin_metadata or {},
|
||||
),
|
||||
state=CronJobState(next_run_at_ms=_compute_next_run(schedule, now)),
|
||||
created_at_ms=now,
|
||||
updated_at_ms=now,
|
||||
delete_after_run=delete_after_run,
|
||||
)
|
||||
_normalize_agent_turn_job(job)
|
||||
if self._running:
|
||||
store = self._load_store()
|
||||
store.jobs.append(job)
|
||||
@ -616,6 +746,7 @@ class CronService:
|
||||
job.payload.to = to
|
||||
if delete_after_run is not None:
|
||||
job.delete_after_run = delete_after_run
|
||||
_normalize_agent_turn_job(job)
|
||||
|
||||
job.updated_at_ms = _now_ms()
|
||||
if job.enabled:
|
||||
|
||||
15
nanobot/cron/session_delivery.py
Normal file
15
nanobot/cron/session_delivery.py
Normal 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 {})
|
||||
74
nanobot/cron/session_turns.py
Normal file
74
nanobot/cron/session_turns.py
Normal 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
|
||||
)
|
||||
@ -1,7 +1,7 @@
|
||||
"""Cron types."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -23,12 +23,15 @@ class CronPayload:
|
||||
"""What to do when the job runs."""
|
||||
kind: Literal["system_event", "agent_turn"] = "agent_turn"
|
||||
message: str = ""
|
||||
# Deliver response to channel
|
||||
# Legacy delivery fields used by pre-session-bound cron jobs.
|
||||
deliver: bool = False
|
||||
channel: str | None = None # e.g. "whatsapp"
|
||||
to: str | None = None # e.g. phone number
|
||||
channel_meta: dict = field(default_factory=dict) # channel-specific routing (e.g. Slack thread_ts)
|
||||
channel_meta: dict[str, Any] = field(default_factory=dict)
|
||||
session_key: str | None = None # original session key for correct session recording
|
||||
origin_channel: str | None = None
|
||||
origin_chat_id: str | None = None
|
||||
origin_metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
27
nanobot/cron/webui_metadata.py
Normal file
27
nanobot/cron/webui_metadata.py
Normal 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
12
nanobot/session/keys.py
Normal 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}"
|
||||
@ -22,6 +22,7 @@ from nanobot.bus.runtime_events import (
|
||||
TurnCompleted,
|
||||
TurnRunStatusChanged,
|
||||
)
|
||||
from nanobot.cron.session_turns import CRON_HISTORY_META
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.session.goal_state import goal_state_ws_blob
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
@ -68,6 +69,8 @@ def _title_inputs(session: Session) -> tuple[str, str]:
|
||||
for message in session.messages:
|
||||
if message.get("_command") is True:
|
||||
continue
|
||||
if message.get(CRON_HISTORY_META) is True:
|
||||
continue
|
||||
role = message.get("role")
|
||||
content = message.get("content")
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
|
||||
9
nanobot/templates/agent/cron_reminder.md
Normal file
9
nanobot/templates/agent/cron_reminder.md
Normal 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 }}
|
||||
@ -1,6 +1,6 @@
|
||||
"""Post-run evaluation for background tasks (heartbeat & cron).
|
||||
"""Post-run notification evaluation for heartbeat checks.
|
||||
|
||||
After the agent executes a background task, this module makes a lightweight
|
||||
After heartbeat executes an internal check, this module makes a lightweight
|
||||
LLM call to decide whether the result warrants notifying the user.
|
||||
"""
|
||||
|
||||
@ -46,10 +46,10 @@ async def evaluate_response(
|
||||
model: str,
|
||||
default_notify: bool = True,
|
||||
) -> bool:
|
||||
"""Decide whether a background-task result should be delivered to the user.
|
||||
"""Decide whether a heartbeat result should be delivered to the user.
|
||||
|
||||
On any failure, falls back to ``default_notify`` (cron reminders fail open;
|
||||
heartbeat passes ``False`` to fail closed).
|
||||
On any failure, falls back to ``default_notify``. Heartbeat passes
|
||||
``False`` to fail closed.
|
||||
"""
|
||||
try:
|
||||
llm_response = await provider.chat_with_retry(
|
||||
|
||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
from loguru import logger as default_logger
|
||||
|
||||
@ -26,6 +26,7 @@ class GatewayServices:
|
||||
workspaces: WebUIWorkspaceController
|
||||
session_manager: Any | None
|
||||
cron_service: Any | None
|
||||
cron_pending_job_ids: Callable[[str], set[str]] | None
|
||||
|
||||
|
||||
def build_gateway_services(
|
||||
@ -41,6 +42,7 @@ def build_gateway_services(
|
||||
runtime_capabilities_overrides: dict[str, Any] | None,
|
||||
disabled_skills: set[str] | None = None,
|
||||
cron_service: Any | None = None,
|
||||
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
||||
logger: Any = default_logger,
|
||||
) -> GatewayServices:
|
||||
tokens = GatewayTokenStore()
|
||||
@ -68,6 +70,7 @@ def build_gateway_services(
|
||||
skills_workspace_path=workspace_path,
|
||||
disabled_skills=disabled_skills,
|
||||
cron_service=cron_service,
|
||||
cron_pending_job_ids=cron_pending_job_ids,
|
||||
log=logger,
|
||||
)
|
||||
return GatewayServices(
|
||||
@ -78,4 +81,5 @@ def build_gateway_services(
|
||||
workspaces=workspaces,
|
||||
session_manager=session_manager,
|
||||
cron_service=cron_service,
|
||||
cron_pending_job_ids=cron_pending_job_ids,
|
||||
)
|
||||
|
||||
4
nanobot/webui/metadata.py
Normal file
4
nanobot/webui/metadata.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""Shared WebUI metadata keys."""
|
||||
|
||||
WEBUI_TURN_METADATA_KEY = "webui_turn_id"
|
||||
WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source"
|
||||
@ -2,39 +2,58 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Collection
|
||||
from typing import Any, Protocol
|
||||
|
||||
from nanobot.cron.types import CronJob
|
||||
|
||||
|
||||
class _CronServiceLike(Protocol):
|
||||
def list_jobs(self, *, include_disabled: bool = False) -> list[CronJob]: ...
|
||||
def list_bound_cron_jobs_for_session(
|
||||
self,
|
||||
session_key: str,
|
||||
*,
|
||||
include_disabled: bool = True,
|
||||
) -> list[CronJob]: ...
|
||||
|
||||
|
||||
def session_automation_jobs(
|
||||
cron_service: _CronServiceLike | None,
|
||||
session_key: str,
|
||||
) -> list[CronJob]:
|
||||
"""Return user automations attached to the WebUI session."""
|
||||
if cron_service is None:
|
||||
return []
|
||||
return cron_service.list_bound_cron_jobs_for_session(
|
||||
session_key,
|
||||
include_disabled=True,
|
||||
)
|
||||
|
||||
|
||||
def session_automations_payload(
|
||||
cron_service: _CronServiceLike | None,
|
||||
session_key: str,
|
||||
*,
|
||||
pending_job_ids: Collection[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return user-created automation jobs attached to a WebUI session."""
|
||||
jobs: list[CronJob] = []
|
||||
if cron_service is not None:
|
||||
all_jobs = cron_service.list_jobs(include_disabled=True)
|
||||
jobs = [job for job in all_jobs if _job_matches_session(job, session_key)]
|
||||
return {"jobs": [_serialize_job(job) for job in jobs]}
|
||||
return {
|
||||
"jobs": serialize_automation_jobs(
|
||||
session_automation_jobs(cron_service, session_key),
|
||||
pending_job_ids=pending_job_ids,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _job_matches_session(job: CronJob, session_key: str) -> bool:
|
||||
payload = job.payload
|
||||
if payload.kind != "agent_turn":
|
||||
return False
|
||||
if payload.session_key:
|
||||
return payload.session_key == session_key
|
||||
if payload.channel and payload.to:
|
||||
return f"{payload.channel}:{payload.to}" == session_key
|
||||
return False
|
||||
def serialize_automation_jobs(
|
||||
jobs: list[CronJob],
|
||||
*,
|
||||
pending_job_ids: Collection[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
return [_serialize_job(job, pending=job.id in (pending_job_ids or ())) for job in jobs]
|
||||
|
||||
|
||||
def _serialize_job(job: CronJob) -> dict[str, Any]:
|
||||
def _serialize_job(job: CronJob, *, pending: bool = False) -> dict[str, Any]:
|
||||
return {
|
||||
"id": job.id,
|
||||
"name": job.name,
|
||||
@ -52,5 +71,6 @@ def _serialize_job(job: CronJob) -> dict[str, Any]:
|
||||
"state": {
|
||||
"next_run_at_ms": job.state.next_run_at_ms,
|
||||
"last_status": job.state.last_status,
|
||||
"pending": pending,
|
||||
},
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.cron.session_turns import CRON_HISTORY_META
|
||||
from nanobot.session.manager import (
|
||||
_SESSION_LIST_PREVIEW_MAX_CHARS,
|
||||
_SESSION_LIST_PREVIEW_MAX_RECORDS,
|
||||
@ -142,6 +143,8 @@ def _preview_from_messages(messages: list[dict[str, Any]]) -> str:
|
||||
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
|
||||
):
|
||||
break
|
||||
if item.get(CRON_HISTORY_META) is True:
|
||||
continue
|
||||
text = _message_preview_text(item)
|
||||
if not text:
|
||||
continue
|
||||
@ -193,6 +196,8 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
|
||||
item = json.loads(line)
|
||||
if item.get("_type") == "metadata":
|
||||
continue
|
||||
if item.get(CRON_HISTORY_META) is True:
|
||||
continue
|
||||
text = _message_preview_text(item)
|
||||
if not text:
|
||||
continue
|
||||
|
||||
@ -17,7 +17,9 @@ from urllib.parse import unquote, urlparse
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.config.paths import get_webui_dir
|
||||
from nanobot.cron.session_turns import CRON_HISTORY_META
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
|
||||
|
||||
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
|
||||
WEBUI_FORK_MARKER_EVENT = "fork_marker"
|
||||
@ -29,8 +31,6 @@ _TRANSCRIPT_SEGMENT_RE = re.compile(r"^\d{6}\.jsonl$")
|
||||
_DEFAULT_TRANSCRIPT_PAGE_LIMIT = 160
|
||||
_MAX_TRANSCRIPT_PAGE_LIMIT = 1000
|
||||
_WEBUI_TURN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
||||
WEBUI_TURN_METADATA_KEY = "webui_turn_id"
|
||||
WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source"
|
||||
_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
|
||||
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
|
||||
)
|
||||
@ -855,6 +855,8 @@ def _session_user_event(
|
||||
) -> dict[str, Any] | None:
|
||||
if message.get("role") != "user":
|
||||
return None
|
||||
if message.get(CRON_HISTORY_META) is True:
|
||||
return None
|
||||
content = message.get("content")
|
||||
text = content if isinstance(content, str) else ""
|
||||
media = message.get("media")
|
||||
@ -1823,6 +1825,29 @@ def fork_boundary_message_count(lines: list[dict[str, Any]]) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def has_pending_tool_calls(lines: list[dict[str, Any]]) -> bool:
|
||||
"""Return True when the selected transcript tail looks like an unfinished turn."""
|
||||
for rec in reversed(lines):
|
||||
ev = rec.get("event")
|
||||
if ev == "turn_end":
|
||||
return False
|
||||
if ev == "user":
|
||||
return False
|
||||
if ev == "message":
|
||||
return rec.get("kind") in {"tool_hint", "progress", "reasoning"}
|
||||
if ev in {
|
||||
"delta",
|
||||
"stream_end",
|
||||
"reasoning_delta",
|
||||
"reasoning_end",
|
||||
"file_edit",
|
||||
}:
|
||||
return True
|
||||
if ev in {WEBUI_FORK_MARKER_EVENT}:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def build_webui_thread_response(
|
||||
session_key: str,
|
||||
*,
|
||||
@ -1855,6 +1880,7 @@ def build_webui_thread_response(
|
||||
"schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION,
|
||||
"sessionKey": session_key,
|
||||
"messages": msgs,
|
||||
"has_pending_tool_calls": has_pending_tool_calls(lines),
|
||||
}
|
||||
if page is not None:
|
||||
page["loaded_message_count"] = len(msgs)
|
||||
|
||||
@ -61,7 +61,11 @@ from nanobot.webui.http_utils import (
|
||||
safe_host_header as _safe_host_header,
|
||||
)
|
||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||
from nanobot.webui.session_automations import session_automations_payload
|
||||
from nanobot.webui.session_automations import (
|
||||
serialize_automation_jobs,
|
||||
session_automation_jobs,
|
||||
session_automations_payload,
|
||||
)
|
||||
from nanobot.webui.session_list_index import list_webui_sessions
|
||||
from nanobot.webui.sidebar_state import (
|
||||
read_webui_sidebar_state,
|
||||
@ -142,6 +146,7 @@ class GatewayHTTPHandler:
|
||||
skills_workspace_path: Path,
|
||||
disabled_skills: set[str] | None = None,
|
||||
cron_service: CronService | None = None,
|
||||
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
||||
log: Any = logger,
|
||||
) -> None:
|
||||
self.config = config
|
||||
@ -155,6 +160,7 @@ class GatewayHTTPHandler:
|
||||
self.skills_workspace_path = skills_workspace_path
|
||||
self.disabled_skills = disabled_skills or set()
|
||||
self.cron_service = cron_service
|
||||
self.cron_pending_job_ids = cron_pending_job_ids
|
||||
self._log = log
|
||||
self._runtime_surface = runtime_surface
|
||||
|
||||
@ -432,8 +438,15 @@ class GatewayHTTPHandler:
|
||||
return _http_error(400, "invalid session key")
|
||||
if not _is_websocket_channel_session_key(decoded_key):
|
||||
return _http_error(404, "session not found")
|
||||
pending_job_ids: set[str] = set()
|
||||
if self.cron_pending_job_ids is not None:
|
||||
pending_job_ids = self.cron_pending_job_ids(decoded_key)
|
||||
return _http_json_response(
|
||||
session_automations_payload(self.cron_service, decoded_key)
|
||||
session_automations_payload(
|
||||
self.cron_service,
|
||||
decoded_key,
|
||||
pending_job_ids=pending_job_ids,
|
||||
)
|
||||
)
|
||||
|
||||
def _handle_session_delete(self, request: WsRequest, key: str) -> Response:
|
||||
@ -446,6 +459,20 @@ class GatewayHTTPHandler:
|
||||
return _http_error(400, "invalid session key")
|
||||
if not _is_websocket_channel_session_key(decoded_key):
|
||||
return _http_error(404, "session not found")
|
||||
query = _parse_query(request.path)
|
||||
delete_automations = (_query_first(query, "delete_automations") or "").lower()
|
||||
automation_jobs = session_automation_jobs(self.cron_service, decoded_key)
|
||||
if automation_jobs and delete_automations not in {"1", "true", "yes"}:
|
||||
return _http_json_response(
|
||||
{
|
||||
"deleted": False,
|
||||
"blocked_by_automations": True,
|
||||
"automations": serialize_automation_jobs(automation_jobs),
|
||||
}
|
||||
)
|
||||
if automation_jobs and self.cron_service is not None:
|
||||
for job in automation_jobs:
|
||||
self.cron_service.remove_job(job.id)
|
||||
deleted = self.session_manager.delete_session(decoded_key)
|
||||
delete_webui_thread(decoded_key)
|
||||
return _http_json_response({"deleted": bool(deleted)})
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from nanobot.utils.evaluator import evaluate_response
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.utils.evaluator import evaluate_response
|
||||
|
||||
|
||||
class DummyProvider(LLMProvider):
|
||||
|
||||
@ -8,6 +8,7 @@ from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
|
||||
from nanobot.providers.base import LLMResponse
|
||||
from nanobot.session.goal_state import GOAL_STATE_KEY
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
@ -64,6 +65,41 @@ def test_agent_loop_llm_runtime_reflects_current_provider_and_model(tmp_path: Pa
|
||||
assert runtime.model == "next-model"
|
||||
|
||||
|
||||
def test_persist_cron_turn_uses_distinct_history_marker(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
session = loop.sessions.get_or_create("websocket:auto")
|
||||
prompt_ref = {"id": "cron.agent_turn.reminder", "version": 1, "sha256": "abc"}
|
||||
|
||||
persisted = loop._persist_user_message_early(
|
||||
InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="cron",
|
||||
chat_id="auto",
|
||||
content="Cron job: internal prompt",
|
||||
metadata={
|
||||
CRON_TRIGGER_META: {
|
||||
"job_id": "job-1",
|
||||
"job_name": "Daily check",
|
||||
"run_id": "job-1:1",
|
||||
"prompt_ref": prompt_ref,
|
||||
"persist_content": "Scheduled cron job triggered: Daily check",
|
||||
}
|
||||
},
|
||||
),
|
||||
session,
|
||||
)
|
||||
|
||||
assert persisted is True
|
||||
message = session.messages[-1]
|
||||
assert message["content"] == "Scheduled cron job triggered: Daily check"
|
||||
assert message[CRON_HISTORY_META] is True
|
||||
assert CRON_TRIGGER_META not in message
|
||||
assert message["cron_job_id"] == "job-1"
|
||||
assert message["cron_job_name"] == "Daily check"
|
||||
assert message["cron_run_id"] == "job-1:1"
|
||||
assert message["cron_prompt_ref"] == prompt_ref
|
||||
|
||||
|
||||
def test_clean_generated_title_strips_reasoning_tags() -> None:
|
||||
assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish"
|
||||
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()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_webui_title_ignores_cron_internal_turns(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
session = loop.sessions.get_or_create("websocket:cron-title")
|
||||
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
|
||||
session.add_message(
|
||||
"user",
|
||||
"Scheduled cron job triggered: 30s-test\n\nInternal reminder prompt",
|
||||
**{CRON_HISTORY_META: True},
|
||||
)
|
||||
session.add_message("assistant", "提醒已经到期。")
|
||||
loop.sessions.save(session)
|
||||
|
||||
generated = await maybe_generate_webui_title(
|
||||
sessions=loop.sessions,
|
||||
session_key="websocket:cron-title",
|
||||
provider=loop.provider,
|
||||
model=loop.model,
|
||||
)
|
||||
|
||||
assert generated is False
|
||||
assert WEBUI_TITLE_METADATA_KEY not in session.metadata
|
||||
loop.provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
|
||||
def test_webui_title_update_uses_captured_llm_runtime(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@ -592,8 +592,8 @@ async def test_waiting_dispatch_does_not_replace_active_pending_queue(tmp_path):
|
||||
@pytest.mark.asyncio
|
||||
async def test_followup_routed_to_pending_queue(tmp_path):
|
||||
"""Unified-session follow-ups should route into the active pending queue."""
|
||||
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||
|
||||
loop = _make_loop(tmp_path)
|
||||
loop._unified_session = True
|
||||
@ -616,6 +616,92 @@ async def test_followup_routed_to_pending_queue(tmp_path):
|
||||
assert queued_msg.session_key == UNIFIED_SESSION_KEY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cron_turn_deferred_while_session_active(tmp_path):
|
||||
"""Cron turns wait for the active session instead of becoming injections."""
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.cron.session_turns import (
|
||||
CRON_DEFER_UNTIL_IDLE_META,
|
||||
CRON_TRIGGER_META,
|
||||
)
|
||||
|
||||
loop = _make_loop(tmp_path)
|
||||
loop._dispatch = AsyncMock() # type: ignore[method-assign]
|
||||
|
||||
session_key = "websocket:chat-1"
|
||||
pending = asyncio.Queue(maxsize=20)
|
||||
loop._pending_queues[session_key] = pending
|
||||
|
||||
run_task = asyncio.create_task(loop.run())
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="cron",
|
||||
chat_id="chat-1",
|
||||
content="scheduled work",
|
||||
metadata={
|
||||
CRON_TRIGGER_META: {"job_id": "job-1", "run_id": "run-1"},
|
||||
CRON_DEFER_UNTIL_IDLE_META: True,
|
||||
},
|
||||
session_key_override=session_key,
|
||||
)
|
||||
await loop.bus.publish_inbound(msg)
|
||||
|
||||
for _ in range(20):
|
||||
if loop._cron_turns.deferred_queues.get(session_key):
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
loop.stop()
|
||||
await asyncio.wait_for(run_task, timeout=2)
|
||||
|
||||
assert pending.empty()
|
||||
assert loop._dispatch.await_count == 0
|
||||
assert loop._cron_turns.deferred_queues[session_key] == [msg]
|
||||
assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"}
|
||||
|
||||
await loop._cron_turns.publish_next_deferred(session_key)
|
||||
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
||||
assert queued is msg
|
||||
assert session_key not in loop._cron_turns.deferred_queues
|
||||
assert loop.pending_cron_job_ids_for_session(session_key) == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submitted_cron_turn_reports_pending_until_completed(tmp_path):
|
||||
"""Bound cron jobs remain marked pending while their session turn is in flight."""
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.cron.session_turns import CRON_TRIGGER_META
|
||||
|
||||
loop = _make_loop(tmp_path)
|
||||
loop._running = True
|
||||
|
||||
session_key = "websocket:chat-1"
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="cron",
|
||||
chat_id="chat-1",
|
||||
content="scheduled work",
|
||||
metadata={CRON_TRIGGER_META: {"job_id": "job-1", "run_id": "run-1"}},
|
||||
session_key_override=session_key,
|
||||
)
|
||||
|
||||
submit_task = asyncio.create_task(loop.submit_cron_turn(msg))
|
||||
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
||||
|
||||
assert queued is msg
|
||||
assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"}
|
||||
|
||||
response = OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content="done",
|
||||
)
|
||||
loop._cron_turns.complete(msg, response=response)
|
||||
|
||||
assert await asyncio.wait_for(submit_task, timeout=0.5) is response
|
||||
assert loop.pending_cron_job_ids_for_session(session_key) == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_queue_preserves_overflow_for_next_injection_cycle(tmp_path):
|
||||
"""Pending queue should leave overflow messages queued for later drains."""
|
||||
|
||||
@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
|
||||
@ -450,12 +451,12 @@ class TestSubagentAnnounceSessionKey:
|
||||
so the result matches the pending queue key."""
|
||||
mgr, bus = self._make_mgr()
|
||||
|
||||
origin = {"channel": "telegram", "chat_id": "111", "session_key": "unified:default"}
|
||||
origin = {"channel": "telegram", "chat_id": "111", "session_key": UNIFIED_SESSION_KEY}
|
||||
await mgr._announce_result("sub-1", "label", "task", "result", origin, "ok")
|
||||
|
||||
msg = await bus.consume_inbound()
|
||||
assert msg.session_key_override == "unified:default"
|
||||
assert msg.session_key == "unified:default"
|
||||
assert msg.session_key_override == UNIFIED_SESSION_KEY
|
||||
assert msg.session_key == UNIFIED_SESSION_KEY
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_announce_uses_raw_key_in_normal_mode(self):
|
||||
@ -505,9 +506,9 @@ class TestSubagentAnnounceSessionKey:
|
||||
)
|
||||
await mgr._run_subagent(
|
||||
"sub-4", "task", "label",
|
||||
{"channel": "telegram", "chat_id": "444", "session_key": "unified:default"},
|
||||
{"channel": "telegram", "chat_id": "444", "session_key": UNIFIED_SESSION_KEY},
|
||||
status,
|
||||
)
|
||||
|
||||
msg = await bus.consume_inbound()
|
||||
assert msg.session_key_override == "unified:default"
|
||||
assert msg.session_key_override == UNIFIED_SESSION_KEY
|
||||
|
||||
@ -25,9 +25,9 @@ from nanobot.bus.queue import MessageBus
|
||||
from nanobot.command.builtin import cmd_new, register_builtin_commands
|
||||
from nanobot.command.router import CommandContext, CommandRouter
|
||||
from nanobot.config.schema import AgentDefaults, Config
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -39,8 +39,8 @@ def _make_loop(tmp_path: Path, unified_session: bool = False) -> AgentLoop:
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
|
||||
with patch("nanobot.agent.loop.SessionManager"), \
|
||||
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
|
||||
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
|
||||
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
loop = AgentLoop(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
@ -415,10 +415,8 @@ class TestStopCommandWithUnifiedSession:
|
||||
@pytest.mark.asyncio
|
||||
async def test_active_tasks_use_effective_key_in_unified_mode(self, tmp_path: Path):
|
||||
"""When unified_session=True, tasks are stored under UNIFIED_SESSION_KEY."""
|
||||
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
||||
|
||||
loop = _make_loop(tmp_path, unified_session=True)
|
||||
|
||||
|
||||
# Create a message from telegram channel
|
||||
msg = _make_msg(channel="telegram", chat_id="123456")
|
||||
|
||||
@ -443,7 +441,6 @@ class TestStopCommandWithUnifiedSession:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_command_finds_task_in_unified_mode(self, tmp_path: Path):
|
||||
"""cmd_stop can cancel tasks when unified_session=True."""
|
||||
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
||||
from nanobot.command.builtin import cmd_stop
|
||||
|
||||
loop = _make_loop(tmp_path, unified_session=True)
|
||||
@ -476,7 +473,6 @@ class TestStopCommandWithUnifiedSession:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_command_uses_effective_key_without_session_override(self, tmp_path: Path):
|
||||
"""Priority /stop must cancel the unified session even before dispatch rewrites the message."""
|
||||
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
||||
from nanobot.command.builtin import cmd_stop
|
||||
|
||||
loop = _make_loop(tmp_path, unified_session=True)
|
||||
@ -502,7 +498,6 @@ class TestStopCommandWithUnifiedSession:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_command_cross_channel_in_unified_mode(self, tmp_path: Path):
|
||||
"""In unified mode, /stop from one channel cancels tasks from another channel."""
|
||||
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
||||
from nanobot.command.builtin import cmd_stop
|
||||
|
||||
loop = _make_loop(tmp_path, unified_session=True)
|
||||
|
||||
@ -2866,3 +2866,49 @@ def test_handle_webui_thread_get_backfills_legacy_missing_user_rows(
|
||||
"legacy question",
|
||||
"legacy answer",
|
||||
]
|
||||
|
||||
|
||||
def test_handle_webui_thread_get_does_not_backfill_cron_internal_prompt(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from urllib.parse import quote
|
||||
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.http11 import Request
|
||||
|
||||
from nanobot.cron.session_turns import CRON_HISTORY_META
|
||||
from nanobot.webui.transcript import append_transcript_object
|
||||
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
workspace = tmp_path / "workspace"
|
||||
sessions = SessionManager(workspace)
|
||||
key = "websocket:c-cron"
|
||||
session = sessions.get_or_create(key)
|
||||
session.add_message(
|
||||
"user",
|
||||
"Scheduled cron job triggered: 30s-test\n\nInternal reminder prompt",
|
||||
**{CRON_HISTORY_META: True},
|
||||
)
|
||||
session.add_message("assistant", "提醒已经到期。")
|
||||
sessions.save(session)
|
||||
append_transcript_object(
|
||||
key,
|
||||
{"event": "message", "chat_id": "c-cron", "text": "提醒已经到期。"},
|
||||
)
|
||||
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=workspace),
|
||||
)
|
||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
|
||||
enc = quote(key, safe="")
|
||||
req = Request(f"/api/sessions/{enc}/webui-thread", Headers([("Authorization", "Bearer tok")]))
|
||||
resp = channel.gateway.http._handle_webui_thread_get(req, enc)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(resp.body.decode())
|
||||
assert [message["role"] for message in body["messages"]] == ["assistant"]
|
||||
assert [message["content"] for message in body["messages"]] == ["提醒已经到期。"]
|
||||
|
||||
@ -14,6 +14,7 @@ import pytest
|
||||
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
||||
|
||||
@ -29,6 +30,7 @@ def _make_handler(
|
||||
workspace_path: Path | None = None,
|
||||
runtime_model_name: Any | None = None,
|
||||
cron_service: CronService | None = None,
|
||||
cron_pending_job_ids: Any | None = None,
|
||||
) -> GatewayServices:
|
||||
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
|
||||
workspace = workspace_path or Path.cwd()
|
||||
@ -43,6 +45,7 @@ def _make_handler(
|
||||
runtime_surface="browser",
|
||||
runtime_capabilities_overrides=None,
|
||||
cron_service=cron_service,
|
||||
cron_pending_job_ids=cron_pending_job_ids,
|
||||
)
|
||||
|
||||
|
||||
@ -55,6 +58,7 @@ def _ch(
|
||||
port: int = _PORT,
|
||||
runtime_model_name: Any | None = None,
|
||||
cron_service: CronService | None = None,
|
||||
cron_pending_job_ids: Any | None = None,
|
||||
**extra: Any,
|
||||
) -> WebSocketChannel:
|
||||
cfg: dict[str, Any] = {
|
||||
@ -73,6 +77,7 @@ def _ch(
|
||||
workspace_path=workspace_path,
|
||||
runtime_model_name=runtime_model_name,
|
||||
cron_service=cron_service,
|
||||
cron_pending_job_ids=cron_pending_job_ids,
|
||||
)
|
||||
return WebSocketChannel(cfg, bus, gateway=gateway)
|
||||
|
||||
@ -176,18 +181,30 @@ async def test_session_automations_route_filters_by_webui_session(
|
||||
) -> None:
|
||||
cron = CronService(tmp_path / "cron" / "jobs.json")
|
||||
hourly = CronSchedule(kind="every", every_ms=3_600_000)
|
||||
pending_job_id = ""
|
||||
for name, message, to in (
|
||||
("Morning check", "Check the project status", "abc"),
|
||||
("Other session", "Do not show", "other"),
|
||||
):
|
||||
cron.add_job(
|
||||
job = cron.add_job(
|
||||
name=name,
|
||||
schedule=hourly,
|
||||
message=message,
|
||||
channel="websocket",
|
||||
to=to,
|
||||
session_key=f"websocket:{to}",
|
||||
origin_channel="websocket",
|
||||
origin_chat_id=to,
|
||||
)
|
||||
if name == "Morning check":
|
||||
pending_job_id = job.id
|
||||
cron.add_job(
|
||||
name="Legacy same target",
|
||||
schedule=hourly,
|
||||
message="Legacy job should be migrated",
|
||||
deliver=True,
|
||||
channel="websocket",
|
||||
to="abc",
|
||||
session_key="websocket:abc",
|
||||
)
|
||||
cron.register_system_job(
|
||||
CronJob(
|
||||
id="heartbeat",
|
||||
@ -200,6 +217,7 @@ async def test_session_automations_route_filters_by_webui_session(
|
||||
bus,
|
||||
session_manager=_seed_session(tmp_path, key="websocket:abc"),
|
||||
cron_service=cron,
|
||||
cron_pending_job_ids=lambda key: {pending_job_id} if key == "websocket:abc" else set(),
|
||||
port=29914,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
@ -220,11 +238,66 @@ async def test_session_automations_route_filters_by_webui_session(
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert [job["name"] for job in body["jobs"]] == ["Morning check"]
|
||||
assert [job["name"] for job in body["jobs"]] == ["Morning check", "Legacy same target"]
|
||||
job = body["jobs"][0]
|
||||
assert job["schedule"]["kind"] == "every"
|
||||
assert job["schedule"]["every_ms"] == 3_600_000
|
||||
assert job["payload"]["message"] == "Check the project status"
|
||||
assert job["state"]["pending"] is True
|
||||
assert body["jobs"][1]["state"]["pending"] is False
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_automations_route_ignores_unified_owner(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
cron = CronService(tmp_path / "cron" / "jobs.json")
|
||||
hourly = CronSchedule(kind="every", every_ms=3_600_000)
|
||||
cron.add_job(
|
||||
name="Unified check",
|
||||
schedule=hourly,
|
||||
message="Check the shared session",
|
||||
session_key=UNIFIED_SESSION_KEY,
|
||||
origin_channel="websocket",
|
||||
origin_chat_id="abc",
|
||||
)
|
||||
cron.add_job(
|
||||
name="Visible chat job",
|
||||
schedule=hourly,
|
||||
message="Show for this chat",
|
||||
session_key="websocket:abc",
|
||||
origin_channel="websocket",
|
||||
origin_chat_id="abc",
|
||||
)
|
||||
channel = _ch(
|
||||
bus,
|
||||
session_manager=_seed_session(tmp_path, key="websocket:abc"),
|
||||
cron_service=cron,
|
||||
port=29917,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
boot = await _http_get("http://127.0.0.1:29917/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
resp = await _http_get(
|
||||
"http://127.0.0.1:29917/api/sessions/websocket%3Aabc/automations",
|
||||
headers=auth,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert [job["name"] for job in resp.json()["jobs"]] == ["Visible chat job"]
|
||||
|
||||
resp = await _http_get(
|
||||
"http://127.0.0.1:29917/api/sessions/websocket%3Aother/automations",
|
||||
headers=auth,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["jobs"] == []
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
@ -659,6 +732,141 @@ async def test_session_delete_removes_file(
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_blocks_when_bound_automation_exists(
|
||||
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
sm = _seed_session(tmp_path, key="websocket:doomed")
|
||||
cron = CronService(tmp_path / "cron" / "jobs.json")
|
||||
cron.add_job(
|
||||
name="Daily check",
|
||||
schedule=CronSchedule(kind="every", every_ms=86_400_000),
|
||||
message="Check the repo",
|
||||
session_key="websocket:doomed",
|
||||
origin_channel="websocket",
|
||||
origin_chat_id="doomed",
|
||||
)
|
||||
channel = _ch(bus, session_manager=sm, cron_service=cron, port=29915)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
boot = await _http_get("http://127.0.0.1:29915/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
path = sm._get_session_path("websocket:doomed")
|
||||
resp = await _http_get(
|
||||
"http://127.0.0.1:29915/api/sessions/websocket:doomed/delete",
|
||||
headers=auth,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["deleted"] is False
|
||||
assert body["blocked_by_automations"] is True
|
||||
assert [job["name"] for job in body["automations"]] == ["Daily check"]
|
||||
assert path.exists()
|
||||
assert cron.list_bound_cron_jobs_for_session("websocket:doomed")
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_can_cascade_bound_automations(
|
||||
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
sm = _seed_session(tmp_path, key="websocket:doomed")
|
||||
cron = CronService(tmp_path / "cron" / "jobs.json")
|
||||
cron.add_job(
|
||||
name="Daily check",
|
||||
schedule=CronSchedule(kind="every", every_ms=86_400_000),
|
||||
message="Check the repo",
|
||||
session_key="websocket:doomed",
|
||||
origin_channel="websocket",
|
||||
origin_chat_id="doomed",
|
||||
)
|
||||
cron.add_job(
|
||||
name="Legacy same target",
|
||||
schedule=CronSchedule(kind="every", every_ms=86_400_000),
|
||||
message="Legacy job remains",
|
||||
channel="websocket",
|
||||
to="doomed",
|
||||
)
|
||||
channel = _ch(bus, session_manager=sm, cron_service=cron, port=29916)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
boot = await _http_get("http://127.0.0.1:29916/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
path = sm._get_session_path("websocket:doomed")
|
||||
resp = await _http_get(
|
||||
"http://127.0.0.1:29916/api/sessions/websocket:doomed/delete?delete_automations=true",
|
||||
headers=auth,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["deleted"] is True
|
||||
assert not path.exists()
|
||||
assert cron.list_bound_cron_jobs_for_session("websocket:doomed") == []
|
||||
assert cron.list_jobs(include_disabled=True) == []
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_blocks_origin_automation_when_unified_enabled(
|
||||
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
sm = _seed_session(tmp_path, key="websocket:doomed")
|
||||
cron = CronService(tmp_path / "cron" / "jobs.json")
|
||||
cron.add_job(
|
||||
name="Chat daily check",
|
||||
schedule=CronSchedule(kind="every", every_ms=86_400_000),
|
||||
message="Check this chat",
|
||||
session_key="websocket:doomed",
|
||||
origin_channel="websocket",
|
||||
origin_chat_id="doomed",
|
||||
)
|
||||
channel = _ch(
|
||||
bus,
|
||||
session_manager=sm,
|
||||
cron_service=cron,
|
||||
port=29918,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
boot = await _http_get("http://127.0.0.1:29918/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
path = sm._get_session_path("websocket:doomed")
|
||||
resp = await _http_get(
|
||||
"http://127.0.0.1:29918/api/sessions/websocket:doomed/delete",
|
||||
headers=auth,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["deleted"] is False
|
||||
assert body["blocked_by_automations"] is True
|
||||
assert [job["name"] for job in body["automations"]] == ["Chat daily check"]
|
||||
assert path.exists()
|
||||
assert [job.name for job in cron.list_bound_cron_jobs_for_session("websocket:doomed")] == [
|
||||
"Chat daily check"
|
||||
]
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_routes_accept_percent_encoded_websocket_keys(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
|
||||
@ -8,13 +8,20 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.cli.commands import _proactive_delivery_metadata, app
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.cli.commands import app
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.cron.service import CronJobSkippedError
|
||||
from nanobot.cron.session_turns import CRON_DEFER_UNTIL_IDLE_META, CRON_TRIGGER_META
|
||||
from nanobot.cron.types import CronJob, CronPayload
|
||||
from nanobot.cron.webui_metadata import cron_proactive_delivery_metadata
|
||||
from nanobot.providers.factory import ProviderSnapshot, make_provider
|
||||
from nanobot.providers.openai_codex_provider import _strip_model_prefix
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.webui.metadata import (
|
||||
WEBUI_MESSAGE_SOURCE_METADATA_KEY,
|
||||
WEBUI_TURN_METADATA_KEY,
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
@ -22,11 +29,11 @@ runner = CliRunner()
|
||||
def test_proactive_websocket_delivery_gets_fresh_turn_id() -> None:
|
||||
metadata = {
|
||||
"webui": True,
|
||||
"webui_turn_id": "turn-that-created-the-reminder",
|
||||
WEBUI_TURN_METADATA_KEY: "turn-that-created-the-reminder",
|
||||
"workspace_scope": {"mode": "default"},
|
||||
}
|
||||
|
||||
out = _proactive_delivery_metadata(
|
||||
out = cron_proactive_delivery_metadata(
|
||||
"websocket",
|
||||
metadata,
|
||||
turn_seed="cron:drink-water",
|
||||
@ -35,9 +42,9 @@ def test_proactive_websocket_delivery_gets_fresh_turn_id() -> None:
|
||||
|
||||
assert out["webui"] is True
|
||||
assert out["workspace_scope"] == {"mode": "default"}
|
||||
assert out["webui_turn_id"].startswith("cron:drink-water:")
|
||||
assert out["webui_turn_id"] != metadata["webui_turn_id"]
|
||||
assert out["_webui_message_source"] == {"kind": "cron", "label": "drink water"}
|
||||
assert out[WEBUI_TURN_METADATA_KEY].startswith("cron:drink-water:")
|
||||
assert out[WEBUI_TURN_METADATA_KEY] != metadata[WEBUI_TURN_METADATA_KEY]
|
||||
assert out[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {"kind": "cron", "label": "drink water"}
|
||||
|
||||
|
||||
def _fake_provider():
|
||||
@ -1338,7 +1345,7 @@ def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path:
|
||||
assert seen["cron_store"] == config.workspace_path / "cron" / "jobs.json"
|
||||
|
||||
|
||||
def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
def test_gateway_unbound_agent_cron_is_skipped(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
config_file = tmp_path / "instance" / "config.json"
|
||||
@ -1403,11 +1410,10 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
seen["agent"] = self
|
||||
|
||||
async def process_direct(self, *_args, **_kwargs):
|
||||
return OutboundMessage(
|
||||
channel="telegram",
|
||||
chat_id="user-1",
|
||||
content="Time to stretch.",
|
||||
)
|
||||
raise AssertionError("unbound cron job must not use process_direct")
|
||||
|
||||
async def submit_cron_turn(self, _msg: InboundMessage):
|
||||
raise AssertionError("unbound cron job must not run as a bound cron turn")
|
||||
|
||||
async def close_mcp(self) -> None:
|
||||
return None
|
||||
@ -1423,16 +1429,10 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
raise _StopGatewayError("stop")
|
||||
|
||||
async def _capture_evaluate_response(
|
||||
response: str,
|
||||
task_context: str,
|
||||
provider_arg: object,
|
||||
model: str,
|
||||
*_args,
|
||||
**_kwargs,
|
||||
) -> bool:
|
||||
seen["response"] = response
|
||||
seen["task_context"] = task_context
|
||||
seen["provider"] = provider_arg
|
||||
seen["model"] = model
|
||||
return True
|
||||
raise AssertionError("unbound cron job must not be evaluated for delivery")
|
||||
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||
@ -1465,124 +1465,71 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
),
|
||||
)
|
||||
|
||||
response = asyncio.run(cron.on_job(job))
|
||||
with pytest.raises(CronJobSkippedError, match="unbound agent cron job"):
|
||||
asyncio.run(cron.on_job(job))
|
||||
|
||||
assert response == "Time to stretch."
|
||||
assert seen["response"] == "Time to stretch."
|
||||
assert seen["provider"] is runtime_provider
|
||||
assert seen["model"] == "runtime-model"
|
||||
assert seen["task_context"] == (
|
||||
"The scheduled time has arrived. Deliver this reminder to the user now, "
|
||||
"as a brief and natural message in their language. Speak directly to them — "
|
||||
"do not narrate progress, summarize, include user IDs, or add status reports "
|
||||
"like 'Done' or 'Reminded'.\n\n"
|
||||
"Reminder: Remind me to stretch."
|
||||
)
|
||||
bus.publish_outbound.assert_awaited_once_with(
|
||||
OutboundMessage(
|
||||
channel="telegram",
|
||||
chat_id="user-1",
|
||||
content="Time to stretch.",
|
||||
)
|
||||
)
|
||||
assert seen["session_key"] == "telegram:user-1"
|
||||
saved_session = seen["saved_session"]
|
||||
assert isinstance(saved_session, _FakeSession)
|
||||
assert saved_session.messages == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Time to stretch.",
|
||||
"_channel_delivery": True,
|
||||
}
|
||||
]
|
||||
|
||||
bus.publish_outbound.reset_mock()
|
||||
old_turn_id = "turn-that-created-the-reminder"
|
||||
websocket_job = CronJob(
|
||||
id="drink-water",
|
||||
name="drink water",
|
||||
payload=CronPayload(
|
||||
message="Remind me to drink water.",
|
||||
deliver=True,
|
||||
channel="websocket",
|
||||
to="chat-1",
|
||||
channel_meta={
|
||||
"webui": True,
|
||||
"webui_turn_id": old_turn_id,
|
||||
"workspace_scope": {"mode": "default"},
|
||||
},
|
||||
session_key="websocket:chat-1",
|
||||
),
|
||||
)
|
||||
|
||||
response = asyncio.run(cron.on_job(websocket_job))
|
||||
|
||||
assert response == "Time to stretch."
|
||||
bus.publish_outbound.assert_awaited_once()
|
||||
delivered = bus.publish_outbound.await_args.args[0]
|
||||
assert delivered.channel == "websocket"
|
||||
assert delivered.chat_id == "chat-1"
|
||||
assert delivered.metadata["webui"] is True
|
||||
assert delivered.metadata["workspace_scope"] == {"mode": "default"}
|
||||
assert delivered.metadata["webui_turn_id"].startswith("cron:drink-water:")
|
||||
assert delivered.metadata["webui_turn_id"] != old_turn_id
|
||||
assert delivered.metadata["_webui_message_source"] == {
|
||||
"kind": "cron",
|
||||
"label": "drink water",
|
||||
}
|
||||
bus.publish_outbound.assert_not_awaited()
|
||||
|
||||
|
||||
def test_gateway_cron_job_suppresses_intermediate_progress(
|
||||
def test_gateway_bound_cron_runs_as_session_turn(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Cron jobs must pass on_progress=_silent to process_direct so that
|
||||
tool hints and streaming deltas are never leaked to the user channel
|
||||
before evaluate_response decides whether to deliver."""
|
||||
config_file = tmp_path / "instance" / "config.json"
|
||||
config_file.parent.mkdir(parents=True)
|
||||
config_file.write_text("{}")
|
||||
|
||||
config = Config()
|
||||
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
||||
provider = _fake_provider()
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
seen: dict[str, object] = {}
|
||||
seen: dict[str, object] = {"run_records": []}
|
||||
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.build_provider_snapshot",
|
||||
lambda _config: _test_provider_snapshot(object(), _config),
|
||||
lambda _config: _test_provider_snapshot(provider, _config),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.load_provider_snapshot",
|
||||
lambda _config_path=None: _test_provider_snapshot(object(), config),
|
||||
lambda _config_path=None: _test_provider_snapshot(provider, config),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus)
|
||||
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
|
||||
|
||||
class _FakeSessionManager:
|
||||
def __init__(self, _workspace: Path) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("nanobot.session.manager.SessionManager", _FakeSessionManager)
|
||||
|
||||
class _FakeCron:
|
||||
def __init__(self, _store_path: Path) -> None:
|
||||
self.on_job = None
|
||||
seen["cron"] = self
|
||||
|
||||
def write_run_record(self, run_id: str, record: dict[str, object]) -> None:
|
||||
seen["run_records"].append((run_id, record))
|
||||
|
||||
class _FakeAgentLoop:
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
return cls(**extra)
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.model = "test-model"
|
||||
self.provider = object()
|
||||
self.provider = kwargs.get("provider", object())
|
||||
self.tools = {}
|
||||
seen["agent"] = self
|
||||
|
||||
async def process_direct(self, *_args, on_progress=None, **_kwargs):
|
||||
seen["on_progress"] = on_progress
|
||||
async def submit_cron_turn(self, msg: InboundMessage):
|
||||
seen["cron_msg"] = msg
|
||||
return OutboundMessage(
|
||||
channel="telegram",
|
||||
chat_id="user-1",
|
||||
content="Done.",
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content="Checked the repo.",
|
||||
)
|
||||
|
||||
async def close_mcp(self) -> None:
|
||||
@ -1598,41 +1545,131 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
|
||||
def __init__(self, *_args, **_kwargs) -> None:
|
||||
raise _StopGatewayError("stop")
|
||||
|
||||
async def _always_reject(*_args, **_kwargs) -> bool:
|
||||
return False
|
||||
async def _unexpected_evaluator(*_args, **_kwargs) -> bool:
|
||||
raise AssertionError("bound cron must not use legacy response evaluator")
|
||||
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands.evaluate_response",
|
||||
_always_reject,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.cli.commands.evaluate_response", _unexpected_evaluator)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||
assert isinstance(result.exception, _StopGatewayError)
|
||||
|
||||
cron = seen["cron"]
|
||||
job = CronJob(
|
||||
id="cron-silent-test",
|
||||
name="test-silent",
|
||||
id="repo-check",
|
||||
name="Repo check",
|
||||
payload=CronPayload(
|
||||
message="Run something.",
|
||||
deliver=True,
|
||||
channel="telegram",
|
||||
to="user-1",
|
||||
message="Check repository health.",
|
||||
session_key="websocket:chat-1",
|
||||
origin_channel="websocket",
|
||||
origin_chat_id="chat-1",
|
||||
),
|
||||
)
|
||||
|
||||
response = asyncio.run(cron.on_job(job))
|
||||
|
||||
assert response == "Done."
|
||||
# on_progress must be a callable (the _silent noop), not None and not bus_progress
|
||||
assert seen["on_progress"] is not None
|
||||
assert callable(seen["on_progress"])
|
||||
# Verify it actually swallows calls (no side effects)
|
||||
asyncio.run(seen["on_progress"]("tool_hint", "🔧 $ echo test"))
|
||||
# Nothing published to bus since evaluator rejected
|
||||
bus.publish_outbound.assert_not_awaited()
|
||||
assert response == "Checked the repo."
|
||||
msg = seen["cron_msg"]
|
||||
assert isinstance(msg, InboundMessage)
|
||||
assert msg.channel == "websocket"
|
||||
assert msg.chat_id == "chat-1"
|
||||
assert msg.sender_id == "cron"
|
||||
assert msg.session_key_override == "websocket:chat-1"
|
||||
assert "Cron job: Check repository health." in msg.content
|
||||
assert msg.metadata["webui"] is True
|
||||
assert msg.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {
|
||||
"kind": "cron",
|
||||
"label": "Repo check",
|
||||
}
|
||||
trigger = msg.metadata[CRON_TRIGGER_META]
|
||||
assert trigger["job_id"] == "repo-check"
|
||||
assert trigger["job_name"] == "Repo check"
|
||||
assert trigger["persist_content"] == (
|
||||
"Scheduled cron job triggered: Repo check\n\nCheck repository health."
|
||||
)
|
||||
assert msg.metadata[CRON_DEFER_UNTIL_IDLE_META] is True
|
||||
statuses = [record["status"] for _run_id, record in seen["run_records"]]
|
||||
assert statuses == ["queued", "ok"]
|
||||
assert seen["run_records"][0][0] == seen["run_records"][1][0]
|
||||
|
||||
discord_job = CronJob(
|
||||
id="thread-check",
|
||||
name="Thread check",
|
||||
payload=CronPayload(
|
||||
message="Check the Discord thread.",
|
||||
session_key="discord:456:thread:777",
|
||||
origin_channel="discord",
|
||||
origin_chat_id="777",
|
||||
origin_metadata={
|
||||
"context_chat_id": "456",
|
||||
"parent_channel_id": "456",
|
||||
"thread_id": "777",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
response = asyncio.run(cron.on_job(discord_job))
|
||||
|
||||
assert response == "Checked the repo."
|
||||
msg = seen["cron_msg"]
|
||||
assert isinstance(msg, InboundMessage)
|
||||
assert msg.channel == "discord"
|
||||
assert msg.chat_id == "777"
|
||||
assert msg.session_key_override == "discord:456:thread:777"
|
||||
assert msg.metadata["context_chat_id"] == "456"
|
||||
assert msg.metadata["parent_channel_id"] == "456"
|
||||
assert msg.metadata["thread_id"] == "777"
|
||||
|
||||
telegram_job = CronJob(
|
||||
id="telegram-topic",
|
||||
name="Telegram topic",
|
||||
payload=CronPayload(
|
||||
message="Check the Telegram topic.",
|
||||
session_key="telegram:-100123:topic:42",
|
||||
origin_channel="telegram",
|
||||
origin_chat_id="-100123",
|
||||
origin_metadata={"message_thread_id": 42},
|
||||
),
|
||||
)
|
||||
|
||||
response = asyncio.run(cron.on_job(telegram_job))
|
||||
|
||||
assert response == "Checked the repo."
|
||||
msg = seen["cron_msg"]
|
||||
assert isinstance(msg, InboundMessage)
|
||||
assert msg.channel == "telegram"
|
||||
assert msg.chat_id == "-100123"
|
||||
assert msg.session_key_override == "telegram:-100123:topic:42"
|
||||
assert msg.metadata["message_thread_id"] == 42
|
||||
|
||||
feishu_job = CronJob(
|
||||
id="feishu-topic",
|
||||
name="Feishu topic",
|
||||
payload=CronPayload(
|
||||
message="Check the Feishu topic.",
|
||||
session_key="feishu:oc_abc:om_root123",
|
||||
origin_channel="feishu",
|
||||
origin_chat_id="oc_abc",
|
||||
origin_metadata={
|
||||
"chat_type": "group",
|
||||
"message_id": "om_root123",
|
||||
"thread_id": "om_root123",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
response = asyncio.run(cron.on_job(feishu_job))
|
||||
|
||||
assert response == "Checked the repo."
|
||||
msg = seen["cron_msg"]
|
||||
assert isinstance(msg, InboundMessage)
|
||||
assert msg.channel == "feishu"
|
||||
assert msg.chat_id == "oc_abc"
|
||||
assert msg.session_key_override == "feishu:oc_abc:om_root123"
|
||||
assert msg.metadata["message_id"] == "om_root123"
|
||||
assert msg.metadata["thread_id"] == "om_root123"
|
||||
|
||||
|
||||
def test_gateway_workspace_override_does_not_migrate_legacy_cron(
|
||||
|
||||
@ -4,7 +4,7 @@ import time
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.service import CronJobSkippedError, CronService
|
||||
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
|
||||
|
||||
|
||||
@ -43,7 +43,7 @@ def test_add_job_accepts_valid_timezone(tmp_path) -> None:
|
||||
assert job.state.next_run_at_ms is not None
|
||||
|
||||
|
||||
def test_add_job_preserves_channel_meta_and_session_key(tmp_path) -> None:
|
||||
def test_add_job_migrates_legacy_delivery_context(tmp_path) -> None:
|
||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
||||
meta = {"slack": {"thread_ts": "1234567890.123456", "channel_type": "channel"}}
|
||||
job = service.add_job(
|
||||
@ -56,13 +56,160 @@ def test_add_job_preserves_channel_meta_and_session_key(tmp_path) -> None:
|
||||
channel_meta=meta,
|
||||
session_key="slack:C123:1234567890.123456",
|
||||
)
|
||||
assert job.payload.channel_meta == meta
|
||||
assert job.payload.deliver is False
|
||||
assert job.payload.channel is None
|
||||
assert job.payload.to is None
|
||||
assert job.payload.channel_meta == {}
|
||||
assert job.payload.session_key == "slack:C123:1234567890.123456"
|
||||
assert job.payload.origin_channel == "slack"
|
||||
assert job.payload.origin_chat_id == "C123"
|
||||
assert job.payload.origin_metadata == meta
|
||||
|
||||
reloaded = service.get_job(job.id)
|
||||
assert reloaded is not None
|
||||
assert reloaded.payload.channel_meta == meta
|
||||
assert reloaded.payload.channel_meta == {}
|
||||
assert reloaded.payload.session_key == "slack:C123:1234567890.123456"
|
||||
assert reloaded.payload.origin_channel == "slack"
|
||||
assert reloaded.payload.origin_chat_id == "C123"
|
||||
assert reloaded.payload.origin_metadata == meta
|
||||
|
||||
|
||||
def test_load_store_migrates_legacy_delivery_context(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
store_path.parent.mkdir(parents=True)
|
||||
store_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"jobs": [
|
||||
{
|
||||
"id": "legacy-1",
|
||||
"name": "Legacy reminder",
|
||||
"enabled": True,
|
||||
"schedule": {"kind": "every", "everyMs": 60_000},
|
||||
"payload": {
|
||||
"kind": "agent_turn",
|
||||
"message": "check status",
|
||||
"deliver": True,
|
||||
"channel": "telegram",
|
||||
"to": "user-1",
|
||||
"channelMeta": {"message_thread_id": 42},
|
||||
"sessionKey": "telegram:user-1:topic:42",
|
||||
},
|
||||
"state": {},
|
||||
"createdAtMs": 1,
|
||||
"updatedAtMs": 1,
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
job = CronService(store_path).get_job("legacy-1")
|
||||
|
||||
assert job is not None
|
||||
assert job.payload.session_key == "telegram:user-1:topic:42"
|
||||
assert job.payload.origin_channel == "telegram"
|
||||
assert job.payload.origin_chat_id == "user-1"
|
||||
assert job.payload.origin_metadata == {"message_thread_id": 42}
|
||||
assert job.payload.deliver is False
|
||||
assert job.payload.channel is None
|
||||
assert job.payload.to is None
|
||||
assert job.payload.channel_meta == {}
|
||||
|
||||
|
||||
def test_load_store_disables_malformed_legacy_payload(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
store_path.parent.mkdir(parents=True)
|
||||
store_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"jobs": [
|
||||
{
|
||||
"id": "legacy-bad",
|
||||
"name": "Broken legacy",
|
||||
"enabled": True,
|
||||
"schedule": {"kind": "every", "everyMs": 60_000},
|
||||
"payload": {
|
||||
"kind": "agent_turn",
|
||||
"message": "check status",
|
||||
"deliver": True,
|
||||
},
|
||||
"state": {"nextRunAtMs": 123},
|
||||
"createdAtMs": 1,
|
||||
"updatedAtMs": 1,
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
job = CronService(store_path).get_job("legacy-bad")
|
||||
|
||||
assert job is not None
|
||||
assert job.enabled is False
|
||||
assert job.state.next_run_at_ms is None
|
||||
assert job.state.last_status == "error"
|
||||
assert "missing channel/to" in (job.state.last_error or "")
|
||||
assert job.payload.deliver is False
|
||||
|
||||
|
||||
def test_list_bound_agent_jobs_includes_migrated_legacy_delivery_payloads(tmp_path) -> None:
|
||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
||||
schedule = CronSchedule(kind="every", every_ms=60_000)
|
||||
bound = service.add_job(
|
||||
name="Bound",
|
||||
schedule=schedule,
|
||||
message="new bound job",
|
||||
session_key="websocket:chat-1",
|
||||
origin_channel="websocket",
|
||||
origin_chat_id="chat-1",
|
||||
)
|
||||
migrated = service.add_job(
|
||||
name="Legacy same session",
|
||||
schedule=schedule,
|
||||
message="legacy job",
|
||||
deliver=True,
|
||||
channel="websocket",
|
||||
to="chat-1",
|
||||
session_key="websocket:chat-1",
|
||||
)
|
||||
|
||||
assert service.list_bound_cron_jobs_for_session("websocket:chat-1") == [bound, migrated]
|
||||
|
||||
|
||||
def test_add_job_preserves_origin_delivery_context(tmp_path) -> None:
|
||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
||||
metadata = {"slack": {"thread_ts": "1234567890.123456", "channel_type": "channel"}}
|
||||
|
||||
job = service.add_job(
|
||||
name="bound thread",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
session_key="slack:C123:1234567890.123456",
|
||||
origin_channel="slack",
|
||||
origin_chat_id="C123",
|
||||
origin_metadata=metadata,
|
||||
)
|
||||
|
||||
assert job.payload.origin_channel == "slack"
|
||||
assert job.payload.origin_chat_id == "C123"
|
||||
assert job.payload.origin_metadata == metadata
|
||||
|
||||
raw = json.loads((tmp_path / "cron" / "action.jsonl").read_text(encoding="utf-8"))
|
||||
payload = raw["params"]["payload"]
|
||||
assert payload["origin_channel"] == "slack"
|
||||
assert payload["origin_chat_id"] == "C123"
|
||||
assert payload["origin_metadata"] == metadata
|
||||
|
||||
reloaded = service.get_job(job.id)
|
||||
assert reloaded is not None
|
||||
assert reloaded.payload.origin_channel == "slack"
|
||||
assert reloaded.payload.origin_chat_id == "C123"
|
||||
assert reloaded.payload.origin_metadata == metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -81,19 +228,31 @@ async def test_channel_meta_and_session_key_survive_store_reload(tmp_path) -> No
|
||||
to="C123",
|
||||
channel_meta=meta,
|
||||
session_key="slack:C123:1234567890.123456",
|
||||
origin_channel="slack",
|
||||
origin_chat_id="C123",
|
||||
origin_metadata=meta,
|
||||
)
|
||||
finally:
|
||||
service.stop()
|
||||
|
||||
raw = json.loads(store_path.read_text(encoding="utf-8"))
|
||||
payload = raw["jobs"][0]["payload"]
|
||||
assert payload["channelMeta"] == meta
|
||||
assert payload["deliver"] is False
|
||||
assert payload["channel"] is None
|
||||
assert payload["to"] is None
|
||||
assert payload["channelMeta"] == {}
|
||||
assert payload["sessionKey"] == "slack:C123:1234567890.123456"
|
||||
assert payload["originChannel"] == "slack"
|
||||
assert payload["originChatId"] == "C123"
|
||||
assert payload["originMetadata"] == meta
|
||||
|
||||
reloaded = CronService(store_path).get_job(job.id)
|
||||
assert reloaded is not None
|
||||
assert reloaded.payload.channel_meta == meta
|
||||
assert reloaded.payload.channel_meta == {}
|
||||
assert reloaded.payload.session_key == "slack:C123:1234567890.123456"
|
||||
assert reloaded.payload.origin_channel == "slack"
|
||||
assert reloaded.payload.origin_chat_id == "C123"
|
||||
assert reloaded.payload.origin_metadata == meta
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -137,6 +296,57 @@ async def test_run_history_records_errors(tmp_path) -> None:
|
||||
assert loaded.state.run_history[0].error == "boom"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_history_records_skipped_jobs(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
|
||||
async def skip(_):
|
||||
raise CronJobSkippedError("missing session binding")
|
||||
|
||||
service = CronService(store_path, on_job=skip)
|
||||
job = service.add_job(
|
||||
name="skip",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
)
|
||||
await service.run_job(job.id)
|
||||
|
||||
loaded = service.get_job(job.id)
|
||||
assert loaded is not None
|
||||
assert loaded.state.last_status == "skipped"
|
||||
assert loaded.state.last_error == "missing session binding"
|
||||
assert len(loaded.state.run_history) == 1
|
||||
assert loaded.state.run_history[0].status == "skipped"
|
||||
assert loaded.state.run_history[0].error == "missing session binding"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_history_records_job_cancellation(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
|
||||
async def cancel(_):
|
||||
raise asyncio.CancelledError("turn cancelled")
|
||||
|
||||
service = CronService(store_path, on_job=cancel)
|
||||
job = service.add_job(
|
||||
name="cancel",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
session_key="websocket:chat-1",
|
||||
)
|
||||
|
||||
assert await service.run_job(job.id) is True
|
||||
|
||||
loaded = service.get_job(job.id)
|
||||
assert loaded is not None
|
||||
assert loaded.state.last_status == "error"
|
||||
assert loaded.state.last_error == "turn cancelled"
|
||||
assert len(loaded.state.run_history) == 1
|
||||
assert loaded.state.run_history[0].status == "error"
|
||||
assert loaded.state.run_history[0].error == "turn cancelled"
|
||||
assert loaded.state.next_run_at_ms is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_history_trimmed_to_max(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
@ -559,28 +769,22 @@ def test_update_job_offline_writes_action(tmp_path) -> None:
|
||||
assert last["params"]["name"] == "updated-offline"
|
||||
|
||||
|
||||
def test_update_job_sentinel_channel_and_to(tmp_path) -> None:
|
||||
"""Passing None clears channel/to; omitting leaves them unchanged."""
|
||||
def test_update_job_migrates_legacy_delivery_target(tmp_path) -> None:
|
||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
||||
job = service.add_job(
|
||||
name="sentinel",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
channel="telegram",
|
||||
to="user123",
|
||||
)
|
||||
assert job.payload.channel == "telegram"
|
||||
assert job.payload.to == "user123"
|
||||
|
||||
result = service.update_job(job.id, name="renamed")
|
||||
assert isinstance(result, CronJob)
|
||||
assert result.payload.channel == "telegram"
|
||||
assert result.payload.to == "user123"
|
||||
|
||||
result = service.update_job(job.id, channel=None, to=None)
|
||||
result = service.update_job(job.id, channel="telegram", to="user123")
|
||||
assert isinstance(result, CronJob)
|
||||
assert result.payload.session_key == "telegram:user123"
|
||||
assert result.payload.origin_channel == "telegram"
|
||||
assert result.payload.origin_chat_id == "user123"
|
||||
assert result.payload.channel is None
|
||||
assert result.payload.to is None
|
||||
assert result.payload.channel_meta == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@ -303,7 +303,9 @@ def test_remove_protected_dream_job_returns_clear_feedback(tmp_path) -> None:
|
||||
|
||||
def test_add_cron_job_defaults_to_tool_timezone(tmp_path) -> None:
|
||||
tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai")
|
||||
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
|
||||
tool.set_context(
|
||||
RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1")
|
||||
)
|
||||
|
||||
result = tool._add_job(None, "Morning standup", None, "0 8 * * *", None, None)
|
||||
|
||||
@ -314,7 +316,9 @@ def test_add_cron_job_defaults_to_tool_timezone(tmp_path) -> None:
|
||||
|
||||
def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None:
|
||||
tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai")
|
||||
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
|
||||
tool.set_context(
|
||||
RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1")
|
||||
)
|
||||
|
||||
result = tool._add_job(None, "Morning reminder", None, None, None, "2026-03-25T08:00:00")
|
||||
|
||||
@ -324,26 +328,32 @@ def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None:
|
||||
assert job.schedule.at_ms == expected
|
||||
|
||||
|
||||
def test_add_job_delivers_by_default(tmp_path) -> None:
|
||||
def test_add_job_binds_current_session_key(tmp_path) -> None:
|
||||
tool = _make_tool(tmp_path)
|
||||
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
|
||||
tool.set_context(
|
||||
RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1")
|
||||
)
|
||||
|
||||
result = tool._add_job(None, "Morning standup", 60, None, None, None)
|
||||
|
||||
assert result.startswith("Created job")
|
||||
job = tool._cron.list_jobs()[0]
|
||||
assert job.payload.deliver is True
|
||||
assert job.payload.session_key == "telegram:chat-1"
|
||||
assert job.payload.origin_channel == "telegram"
|
||||
assert job.payload.origin_chat_id == "chat-1"
|
||||
assert job.payload.origin_metadata == {}
|
||||
assert job.payload.channel is None
|
||||
assert job.payload.to is None
|
||||
|
||||
|
||||
def test_add_job_can_disable_delivery(tmp_path) -> None:
|
||||
def test_add_job_requires_session_key(tmp_path) -> None:
|
||||
tool = _make_tool(tmp_path)
|
||||
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
|
||||
|
||||
result = tool._add_job(None, "Background refresh", 60, None, None, None, deliver=False)
|
||||
result = tool._add_job(None, "Background refresh", 60, None, None, None)
|
||||
|
||||
assert result.startswith("Created job")
|
||||
job = tool._cron.list_jobs()[0]
|
||||
assert job.payload.deliver is False
|
||||
assert result == "Error: scheduled cron jobs must be created from a chat session"
|
||||
assert tool._cron.list_jobs() == []
|
||||
|
||||
|
||||
def test_cron_schema_advertises_action_specific_requirements(tmp_path) -> None:
|
||||
@ -375,7 +385,9 @@ def test_validate_params_requires_message_only_for_add(tmp_path) -> None:
|
||||
|
||||
def test_add_job_empty_message_returns_actionable_error(tmp_path) -> None:
|
||||
tool = _make_tool(tmp_path)
|
||||
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
|
||||
tool.set_context(
|
||||
RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1")
|
||||
)
|
||||
|
||||
result = tool._add_job(None, "", 60, None, None, None)
|
||||
|
||||
@ -383,8 +395,8 @@ def test_add_job_empty_message_returns_actionable_error(tmp_path) -> None:
|
||||
assert "Retry including message=" in result
|
||||
|
||||
|
||||
def test_add_job_captures_metadata_and_session_key(tmp_path) -> None:
|
||||
"""CronTool stores channel metadata and session_key when adding a job."""
|
||||
def test_add_job_captures_owner_and_origin_without_legacy_delivery_fields(tmp_path) -> None:
|
||||
"""CronTool stores owner/session identity separately from origin delivery context."""
|
||||
tool = _make_tool(tmp_path)
|
||||
meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}}
|
||||
tool.set_context(RequestContext(
|
||||
@ -396,8 +408,13 @@ def test_add_job_captures_metadata_and_session_key(tmp_path) -> None:
|
||||
|
||||
jobs = tool._cron.list_jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0].payload.channel_meta == meta
|
||||
assert jobs[0].payload.session_key == "slack:C99:111.222"
|
||||
assert jobs[0].payload.origin_channel == "slack"
|
||||
assert jobs[0].payload.origin_chat_id == "C99"
|
||||
assert jobs[0].payload.origin_metadata == meta
|
||||
assert jobs[0].payload.channel is None
|
||||
assert jobs[0].payload.to is None
|
||||
assert jobs[0].payload.channel_meta == {}
|
||||
|
||||
|
||||
def test_list_excludes_disabled_jobs(tmp_path) -> None:
|
||||
|
||||
@ -41,7 +41,9 @@ class _SvcStub:
|
||||
@pytest.fixture
|
||||
def registry() -> ToolRegistry:
|
||||
tool = CronTool(_SvcStub(), default_timezone="UTC")
|
||||
tool.set_context(RequestContext(channel="channel", chat_id="chat-id"))
|
||||
tool.set_context(
|
||||
RequestContext(channel="channel", chat_id="chat-id", session_key="channel:chat-id")
|
||||
)
|
||||
reg = ToolRegistry()
|
||||
reg.register(tool)
|
||||
return reg
|
||||
|
||||
44
tests/cron/test_session_delivery.py
Normal file
44
tests/cron/test_session_delivery.py
Normal 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)
|
||||
@ -4,11 +4,13 @@ import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -99,14 +101,18 @@ async def test_cron_tool_keeps_task_local_context(tmp_path) -> None:
|
||||
release = asyncio.Event()
|
||||
|
||||
async def task_one() -> str:
|
||||
tool.set_context(RequestContext(channel="feishu", chat_id="chat-a"))
|
||||
tool.set_context(
|
||||
RequestContext(channel="feishu", chat_id="chat-a", session_key="feishu:chat-a")
|
||||
)
|
||||
entered.set()
|
||||
await release.wait()
|
||||
return await tool.execute(action="add", message="first", every_seconds=60)
|
||||
|
||||
async def task_two() -> str:
|
||||
await entered.wait()
|
||||
tool.set_context(RequestContext(channel="email", chat_id="chat-b"))
|
||||
tool.set_context(
|
||||
RequestContext(channel="email", chat_id="chat-b", session_key="email:chat-b")
|
||||
)
|
||||
release.set()
|
||||
return await tool.execute(action="add", message="second", every_seconds=60)
|
||||
|
||||
@ -116,8 +122,11 @@ async def test_cron_tool_keeps_task_local_context(tmp_path) -> None:
|
||||
assert result_two.startswith("Created job")
|
||||
|
||||
jobs = tool._cron.list_jobs()
|
||||
assert {job.payload.channel for job in jobs} == {"feishu", "email"}
|
||||
assert {job.payload.to for job in jobs} == {"chat-a", "chat-b"}
|
||||
assert {job.payload.session_key for job in jobs} == {"feishu:chat-a", "email:chat-b"}
|
||||
assert {(job.payload.origin_channel, job.payload.origin_chat_id) for job in jobs} == {
|
||||
("feishu", "chat-a"),
|
||||
("email", "chat-b"),
|
||||
}
|
||||
|
||||
|
||||
# --- Basic single-task regression tests ---
|
||||
@ -228,15 +237,74 @@ async def test_spawn_tool_default_values_without_set_context() -> None:
|
||||
async def test_cron_tool_basic_set_context_and_execute(tmp_path) -> None:
|
||||
"""Single task: set_context then add job should use correct target."""
|
||||
tool = CronTool(CronService(tmp_path / "jobs.json"))
|
||||
tool.set_context(RequestContext(channel="wechat", chat_id="user-789"))
|
||||
tool.set_context(
|
||||
RequestContext(channel="wechat", chat_id="user-789", session_key="wechat:user-789")
|
||||
)
|
||||
|
||||
result = await tool.execute(action="add", message="standup", every_seconds=300)
|
||||
assert result.startswith("Created job")
|
||||
|
||||
jobs = tool._cron.list_jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0].payload.channel == "wechat"
|
||||
assert jobs[0].payload.to == "user-789"
|
||||
assert jobs[0].payload.session_key == "wechat:user-789"
|
||||
assert jobs[0].payload.origin_channel == "wechat"
|
||||
assert jobs[0].payload.origin_chat_id == "user-789"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_cron_tool_uses_origin_session_when_unified_enabled(tmp_path) -> None:
|
||||
"""WebUI-created cron jobs stay attached to the creating chat."""
|
||||
tool = CronTool(CronService(tmp_path / "jobs.json"))
|
||||
|
||||
class _Tools:
|
||||
tool_names = ["cron"]
|
||||
|
||||
def get(self, name: str):
|
||||
return tool if name == "cron" else None
|
||||
|
||||
loop = object.__new__(AgentLoop)
|
||||
loop._unified_session = True
|
||||
loop.tools = _Tools()
|
||||
loop._set_tool_context(
|
||||
"websocket",
|
||||
"chat-123",
|
||||
metadata={"webui": True},
|
||||
session_key=UNIFIED_SESSION_KEY,
|
||||
)
|
||||
|
||||
result = await tool.execute(action="add", message="standup", every_seconds=300)
|
||||
assert result.startswith("Created job")
|
||||
|
||||
jobs = tool._cron.list_jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0].payload.session_key == "websocket:chat-123"
|
||||
assert jobs[0].payload.origin_channel == "websocket"
|
||||
assert jobs[0].payload.origin_chat_id == "chat-123"
|
||||
assert jobs[0].payload.origin_metadata == {"webui": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cron_tool_preserves_thread_scoped_session_key(tmp_path) -> None:
|
||||
"""Channel-provided thread session keys should remain the cron owner."""
|
||||
tool = CronTool(CronService(tmp_path / "jobs.json"))
|
||||
tool.set_context(
|
||||
RequestContext(
|
||||
channel="slack",
|
||||
chat_id="C123",
|
||||
metadata={"slack": {"thread_ts": "1700.42"}},
|
||||
session_key="slack:C123:1700.42",
|
||||
)
|
||||
)
|
||||
|
||||
result = await tool.execute(action="add", message="check thread", every_seconds=300)
|
||||
assert result.startswith("Created job")
|
||||
|
||||
jobs = tool._cron.list_jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0].payload.session_key == "slack:C123:1700.42"
|
||||
assert jobs[0].payload.origin_channel == "slack"
|
||||
assert jobs[0].payload.origin_chat_id == "C123"
|
||||
assert jobs[0].payload.origin_metadata == {"slack": {"thread_ts": "1700.42"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -245,4 +313,4 @@ async def test_cron_tool_no_context_returns_error(tmp_path) -> None:
|
||||
tool = CronTool(CronService(tmp_path / "jobs.json"))
|
||||
|
||||
result = await tool.execute(action="add", message="test", every_seconds=60)
|
||||
assert result == "Error: no session context (channel/chat_id)"
|
||||
assert result == "Error: scheduled cron jobs must be created from a chat session"
|
||||
|
||||
@ -290,6 +290,91 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
|
||||
assert msgs[1]["latencyMs"] == 42
|
||||
|
||||
|
||||
def test_thread_response_does_not_mark_completed_message_tool_tail_pending(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:cron-tail"
|
||||
turn_id = "cron:job:run"
|
||||
for ev in (
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "cron-tail",
|
||||
"text": 'message({"content":"Cron test"})',
|
||||
"kind": "tool_hint",
|
||||
"tool_events": [{
|
||||
"phase": "start",
|
||||
"call_id": "call-message",
|
||||
"name": "message",
|
||||
"arguments": {"content": "Cron test"},
|
||||
}],
|
||||
"turn_id": turn_id,
|
||||
"turn_phase": "activity",
|
||||
"turn_seq": 5,
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "cron-tail",
|
||||
"text": "Cron test",
|
||||
"source": {"kind": "cron", "label": "one-min-test"},
|
||||
"turn_id": turn_id,
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 6,
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "cron-tail",
|
||||
"text": "",
|
||||
"kind": "progress",
|
||||
"tool_events": [{
|
||||
"phase": "end",
|
||||
"call_id": "call-message",
|
||||
"name": "message",
|
||||
"arguments": {"content": "Cron test"},
|
||||
"result": "ok",
|
||||
}],
|
||||
"turn_id": turn_id,
|
||||
"turn_phase": "activity",
|
||||
"turn_seq": 7,
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "cron-tail",
|
||||
"turn_id": turn_id,
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 8,
|
||||
},
|
||||
):
|
||||
append_transcript_object(key, ev)
|
||||
|
||||
out = build_webui_thread_response(key)
|
||||
|
||||
assert out is not None
|
||||
assert out["has_pending_tool_calls"] is False
|
||||
assert out["messages"][-1]["kind"] == "trace"
|
||||
assert out["messages"][-2]["content"] == "Cron test"
|
||||
|
||||
|
||||
def test_thread_response_marks_unfinished_tool_tail_pending(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:active-tail"
|
||||
append_transcript_object(
|
||||
key,
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "active-tail",
|
||||
"text": 'exec({"command":"date"})',
|
||||
"kind": "tool_hint",
|
||||
},
|
||||
)
|
||||
|
||||
out = build_webui_thread_response(key)
|
||||
|
||||
assert out is not None
|
||||
assert out["has_pending_tool_calls"] is True
|
||||
|
||||
|
||||
def test_replay_preserves_turn_metadata(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:t-turn"
|
||||
|
||||
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
import nanobot.webui.session_list_index as session_list_index
|
||||
from nanobot.cron.session_turns import CRON_HISTORY_META
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
@ -71,5 +72,19 @@ def test_webui_session_list_drops_deleted_index_rows(tmp_path: Path) -> None:
|
||||
assert list_webui_sessions(manager) == []
|
||||
|
||||
|
||||
def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create("websocket:cron-preview")
|
||||
session.add_message(
|
||||
"user",
|
||||
"Scheduled cron job triggered: 30s-test\n\nInternal reminder prompt",
|
||||
**{CRON_HISTORY_META: True},
|
||||
)
|
||||
session.add_message("assistant", "提醒已经到期。")
|
||||
manager.save(session)
|
||||
|
||||
assert list_webui_sessions(manager)[0]["preview"] == "提醒已经到期。"
|
||||
|
||||
|
||||
def list_webui_sessions(manager: SessionManager) -> list[dict]:
|
||||
return session_list_index.list_webui_sessions(manager)
|
||||
|
||||
@ -36,6 +36,7 @@ import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||
import type {
|
||||
ChatSummary,
|
||||
RuntimeSurface,
|
||||
SessionAutomationJob,
|
||||
SettingsPayload,
|
||||
WorkspaceScopePayload,
|
||||
WorkspacesPayload,
|
||||
@ -527,7 +528,15 @@ function Shell({
|
||||
const { t, i18n } = useTranslation();
|
||||
const { client, token } = useClient();
|
||||
const { theme, toggle } = useTheme();
|
||||
const { sessions, loading, refresh, createChat, forkChat, deleteChat } = useSessions();
|
||||
const {
|
||||
sessions,
|
||||
loading,
|
||||
refresh,
|
||||
createChat,
|
||||
forkChat,
|
||||
deleteChat,
|
||||
getSessionAutomations,
|
||||
} = useSessions();
|
||||
const { state: sidebarState, update: updateSidebarState } =
|
||||
useSidebarState(sessions, !loading);
|
||||
const initialRouteRef = useRef<ShellRoute | null>(null);
|
||||
@ -546,6 +555,7 @@ function Shell({
|
||||
const [pendingDelete, setPendingDelete] = useState<{
|
||||
key: string;
|
||||
label: string;
|
||||
automations?: SessionAutomationJob[];
|
||||
} | null>(null);
|
||||
const [pendingRename, setPendingRename] = useState<{
|
||||
key: string;
|
||||
@ -1270,33 +1280,47 @@ function Shell({
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
if (!pendingDelete) return;
|
||||
const key = pendingDelete.key;
|
||||
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
|
||||
const deletingActive = activeKey === key;
|
||||
const currentIndex = sessions.findIndex((s) => s.key === key);
|
||||
const fallbackKey = deletingActive
|
||||
? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null)
|
||||
: activeKey;
|
||||
setPendingDelete(null);
|
||||
if (deletingActive) {
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: fallbackKey,
|
||||
settingsSection: "overview",
|
||||
}, { replace: true });
|
||||
}
|
||||
try {
|
||||
await deleteChat(key);
|
||||
} catch (e) {
|
||||
const result = await deleteChat(
|
||||
key,
|
||||
hasAutomations ? { deleteAutomations: true } : undefined,
|
||||
);
|
||||
if (result.blocked_by_automations) {
|
||||
setPendingDelete({
|
||||
...pendingDelete,
|
||||
automations: result.automations ?? [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPendingDelete(null);
|
||||
if (deletingActive) {
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: key,
|
||||
activeKey: fallbackKey,
|
||||
settingsSection: "overview",
|
||||
}, { replace: true });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to delete session", e);
|
||||
}
|
||||
}, [pendingDelete, deleteChat, activeKey, navigate, sessions]);
|
||||
|
||||
const onRequestDelete = useCallback(async (key: string, label: string) => {
|
||||
let automations: SessionAutomationJob[] = [];
|
||||
try {
|
||||
automations = await getSessionAutomations(key);
|
||||
} catch {
|
||||
// Delete remains protected by the backend block; prefetch only improves the first prompt.
|
||||
}
|
||||
setPendingDelete({ key, label, automations });
|
||||
}, [getSessionAutomations]);
|
||||
|
||||
const headerTitle = activeSession
|
||||
? sidebarState.title_overrides[activeSession.key] ||
|
||||
activeSession.title ||
|
||||
@ -1333,8 +1357,7 @@ function Shell({
|
||||
loading,
|
||||
onNewChat,
|
||||
onSelect: onSelectChat,
|
||||
onRequestDelete: (key: string, label: string) =>
|
||||
setPendingDelete({ key, label }),
|
||||
onRequestDelete,
|
||||
onTogglePin,
|
||||
onRequestRename,
|
||||
onToggleArchive,
|
||||
@ -1559,6 +1582,7 @@ function Shell({
|
||||
<DeleteConfirm
|
||||
open={!!pendingDelete}
|
||||
title={pendingDelete?.label ?? ""}
|
||||
automations={pendingDelete?.automations}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={onConfirmDelete}
|
||||
/>
|
||||
|
||||
@ -8,12 +8,17 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { currentLocale } from "@/i18n";
|
||||
import { fmtDateTime } from "@/lib/format";
|
||||
import type { SessionAutomationJob } from "@/lib/types";
|
||||
|
||||
interface DeleteConfirmProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
automations?: SessionAutomationJob[];
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
@ -21,14 +26,19 @@ interface DeleteConfirmProps {
|
||||
export function DeleteConfirm({
|
||||
open,
|
||||
title,
|
||||
automations = [],
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DeleteConfirmProps) {
|
||||
const { t } = useTranslation();
|
||||
const locale = currentLocale();
|
||||
const hasAutomations = automations.length > 0;
|
||||
const visibleAutomations = automations.slice(0, 4);
|
||||
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
|
||||
<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">
|
||||
<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 })}
|
||||
</AlertDialogTitle>
|
||||
<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>
|
||||
{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>
|
||||
<AlertDialogFooter className="mt-7 grid grid-cols-2 gap-3 space-x-0">
|
||||
<AlertDialogCancel
|
||||
@ -54,10 +91,72 @@ export function DeleteConfirm({
|
||||
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"
|
||||
>
|
||||
{t("deleteConfirm.confirm")}
|
||||
{hasAutomations
|
||||
? t("deleteConfirm.confirmWithAutomations")
|
||||
: t("deleteConfirm.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</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);
|
||||
}
|
||||
|
||||
@ -176,6 +176,9 @@ function formatNextRun(job: SessionAutomationJob, t: TFunction, now: number) {
|
||||
if (!job.enabled) {
|
||||
return { label: t("thread.sessionInfo.next.disabled"), title: "" };
|
||||
}
|
||||
if (job.state.pending) {
|
||||
return { label: t("thread.sessionInfo.next.pending"), title: "" };
|
||||
}
|
||||
const next = job.state.next_run_at_ms;
|
||||
if (!next) {
|
||||
return { label: t("thread.sessionInfo.next.none"), title: "" };
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
normalizeToolProgressEvents,
|
||||
toolTraceLinesFromEvents,
|
||||
} from "@/lib/tool-traces";
|
||||
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
|
||||
import type { StreamError } from "@/lib/nanobot-client";
|
||||
import type {
|
||||
InboundEvent,
|
||||
@ -450,12 +451,8 @@ export function useNanobotStream(
|
||||
} {
|
||||
const { client } = useClient();
|
||||
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
|
||||
/** If the last loaded message is a trace row (e.g. "Using 2 tools"),
|
||||
* the model was still processing when the page loaded — keep the
|
||||
* loading spinner alive so the user sees the model is active. */
|
||||
const initialStreaming = initialMessages.length > 0
|
||||
? initialMessages[initialMessages.length - 1].kind === "trace"
|
||||
: false;
|
||||
/** If history ends in unfinished agent activity, keep the loading spinner alive. */
|
||||
const initialStreaming = hasPendingAgentActivity(initialMessages);
|
||||
const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls);
|
||||
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
|
||||
const [runStartedAt, setRunStartedAt] = useState<number | null>(null);
|
||||
@ -694,9 +691,7 @@ export function useNanobotStream(
|
||||
useEffect(() => {
|
||||
setMessages(initialMessages);
|
||||
setIsStreaming(
|
||||
(initialMessages.length > 0
|
||||
? initialMessages[initialMessages.length - 1].kind === "trace"
|
||||
: false) || hasPendingToolCalls,
|
||||
hasPendingAgentActivity(initialMessages) || hasPendingToolCalls,
|
||||
);
|
||||
setStreamError(null);
|
||||
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
|
||||
|
||||
@ -5,15 +5,24 @@ import i18n from "@/i18n";
|
||||
import {
|
||||
ApiError,
|
||||
deleteSession as apiDeleteSession,
|
||||
fetchSessionAutomations,
|
||||
fetchWebuiThread,
|
||||
listSessions,
|
||||
} from "@/lib/api";
|
||||
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
|
||||
import { deriveTitle } from "@/lib/format";
|
||||
import type { ChatSummary, UIMessage, WorkspaceScopePayload } from "@/lib/types";
|
||||
import type {
|
||||
ChatSummary,
|
||||
SessionAutomationJob,
|
||||
SessionDeleteResult,
|
||||
UIMessage,
|
||||
WorkspaceScopePayload,
|
||||
} from "@/lib/types";
|
||||
|
||||
const EMPTY_MESSAGES: UIMessage[] = [];
|
||||
const INITIAL_HISTORY_PAGE_LIMIT = 160;
|
||||
const OLDER_HISTORY_PAGE_LIMIT = 120;
|
||||
const CHAT_CREATE_TIMEOUT_MS = 60_000;
|
||||
|
||||
function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
|
||||
return messages.map((m, idx) => ({
|
||||
@ -23,6 +32,16 @@ function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function hasPendingToolCallsFromThread(
|
||||
body: Awaited<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. */
|
||||
export function useSessions(): {
|
||||
sessions: ChatSummary[];
|
||||
@ -31,7 +50,11 @@ export function useSessions(): {
|
||||
refresh: () => Promise<void>;
|
||||
createChat: (workspaceScope?: WorkspaceScopePayload | null) => 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 [sessions, setSessions] = useState<ChatSummary[]>([]);
|
||||
@ -78,7 +101,7 @@ export function useSessions(): {
|
||||
}, [client, refresh]);
|
||||
|
||||
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}`;
|
||||
optimisticKeysRef.current.add(key);
|
||||
// Optimistic insert; a subsequent refresh will replace it with the
|
||||
@ -104,7 +127,12 @@ export function useSessions(): {
|
||||
beforeUserIndex: number,
|
||||
title?: string,
|
||||
): Promise<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}`;
|
||||
optimisticKeysRef.current.add(key);
|
||||
setSessions((prev) => [
|
||||
@ -124,15 +152,31 @@ export function useSessions(): {
|
||||
}, [client]);
|
||||
|
||||
const deleteChat = useCallback(
|
||||
async (key: string) => {
|
||||
await apiDeleteSession(tokenRef.current, key);
|
||||
async (key: string, options?: { deleteAutomations?: boolean }) => {
|
||||
const result = await apiDeleteSession(tokenRef.current, key, options);
|
||||
if (!result.deleted) return result;
|
||||
optimisticKeysRef.current.delete(key);
|
||||
setSessions((prev) => prev.filter((s) => s.key !== key));
|
||||
return result;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { sessions, loading, error, refresh, createChat, forkChat, deleteChat };
|
||||
const getSessionAutomations = useCallback(async (key: string) => {
|
||||
const result = await fetchSessionAutomations(tokenRef.current, key);
|
||||
return result.jobs;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
createChat,
|
||||
forkChat,
|
||||
deleteChat,
|
||||
getSessionAutomations,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lazy-load a session's on-disk messages the first time the UI displays it. */
|
||||
@ -241,8 +285,7 @@ export function useSessionHistory(key: string | null): {
|
||||
return;
|
||||
}
|
||||
const ui = persistedMessagesToUi(body.messages);
|
||||
const last = ui[ui.length - 1];
|
||||
const hasPending = last?.kind === "trace";
|
||||
const hasPending = hasPendingToolCallsFromThread(body, ui);
|
||||
const forkBoundary = typeof body.fork_boundary_message_count === "number"
|
||||
? Math.max(0, Math.min(body.fork_boundary_message_count, ui.length))
|
||||
: null;
|
||||
@ -326,13 +369,12 @@ export function useSessionHistory(key: string | null): {
|
||||
? null
|
||||
: prev.forkBoundaryMessageCount + older.length;
|
||||
const nextMessages = [...older, ...prev.messages];
|
||||
const last = nextMessages[nextMessages.length - 1];
|
||||
return {
|
||||
...prev,
|
||||
messages: nextMessages,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: last?.kind === "trace",
|
||||
hasPendingToolCalls: hasPendingAgentActivity(nextMessages),
|
||||
forkBoundaryMessageCount: olderBoundary ?? shiftedBoundary,
|
||||
beforeCursor: body.page?.before_cursor ?? null,
|
||||
hasMoreBefore: body.page?.has_more_before === true,
|
||||
|
||||
@ -551,7 +551,22 @@
|
||||
"title": "Delete this chat?",
|
||||
"description": "This action cannot be undone.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Delete"
|
||||
"confirm": "Delete",
|
||||
"automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.",
|
||||
"moreAutomations": "+ {{count}} more",
|
||||
"confirmWithAutomations": "Delete chat and automations",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "Every {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"unknown": "Custom schedule"
|
||||
},
|
||||
"next": {
|
||||
"label": "Next: {{time}}",
|
||||
"disabled": "Paused",
|
||||
"none": "No next run"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
"idle": "Idle",
|
||||
@ -648,6 +663,7 @@
|
||||
},
|
||||
"next": {
|
||||
"label": "{{time}}",
|
||||
"pending": "Runs shortly",
|
||||
"disabled": "Paused",
|
||||
"none": "No next run"
|
||||
}
|
||||
|
||||
@ -551,7 +551,22 @@
|
||||
"title": "¿Eliminar este chat?",
|
||||
"description": "Esta acción no se puede deshacer.",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Eliminar"
|
||||
"confirm": "Eliminar",
|
||||
"automationsDescription": "Este chat tiene automatizaciones programadas. Al eliminarlo también se eliminarán.",
|
||||
"moreAutomations": "+ {{count}} más",
|
||||
"confirmWithAutomations": "Eliminar chat y automatizaciones",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "Cada {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"unknown": "Programación personalizada"
|
||||
},
|
||||
"next": {
|
||||
"label": "Siguiente: {{time}}",
|
||||
"disabled": "Pausada",
|
||||
"none": "Sin próxima ejecución"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
"idle": "Inactivo",
|
||||
@ -648,6 +663,7 @@
|
||||
},
|
||||
"next": {
|
||||
"label": "Siguiente {{time}}",
|
||||
"pending": "Se ejecutará pronto",
|
||||
"disabled": "En pausa",
|
||||
"none": "Sin próxima ejecución"
|
||||
}
|
||||
|
||||
@ -551,7 +551,22 @@
|
||||
"title": "Supprimer cette discussion ?",
|
||||
"description": "Cette action est irréversible.",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Supprimer"
|
||||
"confirm": "Supprimer",
|
||||
"automationsDescription": "Cette discussion contient des automatisations planifiées. La supprimer les supprimera aussi.",
|
||||
"moreAutomations": "+ {{count}} autres",
|
||||
"confirmWithAutomations": "Supprimer la discussion et les automatisations",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "Tous les {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"unknown": "Planification personnalisée"
|
||||
},
|
||||
"next": {
|
||||
"label": "Prochaine exécution : {{time}}",
|
||||
"disabled": "En pause",
|
||||
"none": "Aucune prochaine exécution"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
"idle": "Inactif",
|
||||
@ -648,6 +663,7 @@
|
||||
},
|
||||
"next": {
|
||||
"label": "Prochaine {{time}}",
|
||||
"pending": "Exécution imminente",
|
||||
"disabled": "En pause",
|
||||
"none": "Aucune prochaine exécution"
|
||||
}
|
||||
|
||||
@ -551,7 +551,22 @@
|
||||
"title": "Hapus obrolan ini?",
|
||||
"description": "Tindakan ini tidak dapat dibatalkan.",
|
||||
"cancel": "Batal",
|
||||
"confirm": "Hapus"
|
||||
"confirm": "Hapus",
|
||||
"automationsDescription": "Obrolan ini memiliki automasi terjadwal. Menghapusnya juga akan menghapus automasi tersebut.",
|
||||
"moreAutomations": "+ {{count}} lagi",
|
||||
"confirmWithAutomations": "Hapus obrolan dan automasi",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "Setiap {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"unknown": "Jadwal khusus"
|
||||
},
|
||||
"next": {
|
||||
"label": "Berikutnya: {{time}}",
|
||||
"disabled": "Dijeda",
|
||||
"none": "Tidak ada jadwal berikutnya"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
"idle": "Idle",
|
||||
@ -648,6 +663,7 @@
|
||||
},
|
||||
"next": {
|
||||
"label": "Berikutnya {{time}}",
|
||||
"pending": "Segera berjalan",
|
||||
"disabled": "Dijeda",
|
||||
"none": "Tidak ada jadwal berikutnya"
|
||||
}
|
||||
|
||||
@ -551,7 +551,22 @@
|
||||
"title": "このチャットを削除しますか?",
|
||||
"description": "この操作は元に戻せません。",
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "削除"
|
||||
"confirm": "削除",
|
||||
"automationsDescription": "このチャットにはスケジュール済みの自動タスクがあります。削除するとそれらも削除されます。",
|
||||
"moreAutomations": "他 {{count}} 件",
|
||||
"confirmWithAutomations": "チャットと自動タスクを削除",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "{{duration}} ごと",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"unknown": "カスタムスケジュール"
|
||||
},
|
||||
"next": {
|
||||
"label": "次回: {{time}}",
|
||||
"disabled": "一時停止中",
|
||||
"none": "次回実行なし"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
"idle": "待機中",
|
||||
@ -648,6 +663,7 @@
|
||||
},
|
||||
"next": {
|
||||
"label": "次回 {{time}}",
|
||||
"pending": "まもなく実行",
|
||||
"disabled": "一時停止",
|
||||
"none": "次回実行なし"
|
||||
}
|
||||
|
||||
@ -551,7 +551,22 @@
|
||||
"title": "이 채팅을 삭제할까요?",
|
||||
"description": "이 작업은 되돌릴 수 없습니다.",
|
||||
"cancel": "취소",
|
||||
"confirm": "삭제"
|
||||
"confirm": "삭제",
|
||||
"automationsDescription": "이 채팅에는 예약된 자동화가 있습니다. 채팅을 삭제하면 자동화도 함께 삭제됩니다.",
|
||||
"moreAutomations": "+ {{count}}개 더",
|
||||
"confirmWithAutomations": "채팅과 자동화 삭제",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "{{duration}}마다",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"unknown": "사용자 지정 일정"
|
||||
},
|
||||
"next": {
|
||||
"label": "다음: {{time}}",
|
||||
"disabled": "일시 중지됨",
|
||||
"none": "다음 실행 없음"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
"idle": "대기 중",
|
||||
@ -648,6 +663,7 @@
|
||||
},
|
||||
"next": {
|
||||
"label": "다음 {{time}}",
|
||||
"pending": "곧 실행됨",
|
||||
"disabled": "일시 중지됨",
|
||||
"none": "다음 실행 없음"
|
||||
}
|
||||
|
||||
@ -551,7 +551,22 @@
|
||||
"title": "Xóa cuộc trò chuyện này?",
|
||||
"description": "Không thể hoàn tác thao tác này.",
|
||||
"cancel": "Hủy",
|
||||
"confirm": "Xóa"
|
||||
"confirm": "Xóa",
|
||||
"automationsDescription": "Cuộc trò chuyện này có các tự động hóa đã lên lịch. Xóa cuộc trò chuyện cũng sẽ xóa chúng.",
|
||||
"moreAutomations": "+ {{count}} mục nữa",
|
||||
"confirmWithAutomations": "Xóa trò chuyện và tự động hóa",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "Mỗi {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"unknown": "Lịch tùy chỉnh"
|
||||
},
|
||||
"next": {
|
||||
"label": "Tiếp theo: {{time}}",
|
||||
"disabled": "Đã tạm dừng",
|
||||
"none": "Không có lần chạy tiếp theo"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
"idle": "Rảnh",
|
||||
@ -648,6 +663,7 @@
|
||||
},
|
||||
"next": {
|
||||
"label": "Tiếp theo {{time}}",
|
||||
"pending": "Sắp chạy",
|
||||
"disabled": "Đã tạm dừng",
|
||||
"none": "Không có lần chạy tiếp theo"
|
||||
}
|
||||
|
||||
@ -551,7 +551,22 @@
|
||||
"title": "删除这个对话?",
|
||||
"description": "此操作无法撤销。",
|
||||
"cancel": "取消",
|
||||
"confirm": "删除"
|
||||
"confirm": "删除",
|
||||
"automationsDescription": "这个对话有关联的自动任务。删除对话也会删除这些自动任务。",
|
||||
"moreAutomations": "另有 {{count}} 个",
|
||||
"confirmWithAutomations": "删除对话和自动任务",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "每 {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"unknown": "自定义计划"
|
||||
},
|
||||
"next": {
|
||||
"label": "下次:{{time}}",
|
||||
"disabled": "已暂停",
|
||||
"none": "没有下次运行"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
"idle": "空闲",
|
||||
@ -648,6 +663,7 @@
|
||||
},
|
||||
"next": {
|
||||
"label": "下次 {{time}}",
|
||||
"pending": "即将执行",
|
||||
"disabled": "已暂停",
|
||||
"none": "没有下次运行"
|
||||
}
|
||||
|
||||
@ -551,7 +551,22 @@
|
||||
"title": "刪除這個對話?",
|
||||
"description": "此操作無法復原。",
|
||||
"cancel": "取消",
|
||||
"confirm": "刪除"
|
||||
"confirm": "刪除",
|
||||
"automationsDescription": "這個對話有關聯的自動任務。刪除對話也會刪除這些自動任務。",
|
||||
"moreAutomations": "另有 {{count}} 個",
|
||||
"confirmWithAutomations": "刪除對話和自動任務",
|
||||
"schedule": {
|
||||
"at": "{{time}}",
|
||||
"every": "每 {{duration}}",
|
||||
"cron": "Cron {{expr}}",
|
||||
"cronWithTz": "Cron {{expr}} · {{tz}}",
|
||||
"unknown": "自訂計畫"
|
||||
},
|
||||
"next": {
|
||||
"label": "下次:{{time}}",
|
||||
"disabled": "已暫停",
|
||||
"none": "沒有下次執行"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
"idle": "閒置",
|
||||
@ -648,6 +663,7 @@
|
||||
},
|
||||
"next": {
|
||||
"label": "下次 {{time}}",
|
||||
"pending": "即將執行",
|
||||
"disabled": "已暫停",
|
||||
"none": "沒有下次執行"
|
||||
}
|
||||
|
||||
@ -52,6 +52,38 @@ export function isAgentActivityMember(message: UIMessage): boolean {
|
||||
return isReasoningOnlyAssistant(message) || message.kind === "trace";
|
||||
}
|
||||
|
||||
export function hasPendingAgentActivity(messages: UIMessage[]): boolean {
|
||||
if (messages.length === 0) return false;
|
||||
const last = messages[messages.length - 1];
|
||||
if (!isAgentActivityMember(last)) return false;
|
||||
|
||||
let trailingStart = messages.length - 1;
|
||||
while (
|
||||
trailingStart > 0
|
||||
&& isAgentActivityMember(messages[trailingStart - 1])
|
||||
) {
|
||||
trailingStart -= 1;
|
||||
}
|
||||
|
||||
const trailing = messages.slice(trailingStart);
|
||||
if (trailing.some((message) => message.isStreaming || message.reasoningStreaming)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previous = messages[trailingStart - 1];
|
||||
if (!previous || previous.role !== "assistant" || isAgentActivityMember(previous)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const trailingTurnIds = new Set(
|
||||
trailing
|
||||
.map((message) => message.turnId)
|
||||
.filter((turnId): turnId is string => typeof turnId === "string" && turnId.length > 0),
|
||||
);
|
||||
if (!previous.turnId) return trailingTurnIds.size > 0;
|
||||
return trailingTurnIds.size > 0 && !trailingTurnIds.has(previous.turnId);
|
||||
}
|
||||
|
||||
export function normalizeActivityTimeline(
|
||||
messages: UIMessage[],
|
||||
options: NormalizeActivityTimelineOptions = {},
|
||||
|
||||
@ -9,6 +9,7 @@ import type {
|
||||
NetworkSafetySettingsUpdate,
|
||||
ProviderModelsPayload,
|
||||
ProviderSettingsUpdate,
|
||||
SessionDeleteResult,
|
||||
SessionAutomationsPayload,
|
||||
SettingsPayload,
|
||||
SettingsUpdate,
|
||||
@ -211,13 +212,18 @@ export async function fetchSkillDetail(
|
||||
export async function deleteSession(
|
||||
token: string,
|
||||
key: string,
|
||||
optionsOrBase?: { deleteAutomations?: boolean } | string,
|
||||
base: string = "",
|
||||
): Promise<boolean> {
|
||||
const body = await request<{ deleted: boolean }>(
|
||||
`${base}/api/sessions/${encodeURIComponent(key)}/delete`,
|
||||
): Promise<SessionDeleteResult> {
|
||||
const options = typeof optionsOrBase === "string" ? undefined : optionsOrBase;
|
||||
const resolvedBase = typeof optionsOrBase === "string" ? optionsOrBase : base;
|
||||
const query = new URLSearchParams();
|
||||
if (options?.deleteAutomations) query.set("delete_automations", "true");
|
||||
const suffix = query.toString() ? `?${query}` : "";
|
||||
return request<SessionDeleteResult>(
|
||||
`${resolvedBase}/api/sessions/${encodeURIComponent(key)}/delete${suffix}`,
|
||||
token,
|
||||
);
|
||||
return body.deleted;
|
||||
}
|
||||
|
||||
export async function fetchSettings(
|
||||
|
||||
@ -113,11 +113,18 @@ export interface SessionAutomationJob {
|
||||
state: {
|
||||
next_run_at_ms?: number | null;
|
||||
last_status?: "ok" | "error" | "skipped" | string | null;
|
||||
pending?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; }
|
||||
|
||||
export interface SessionDeleteResult {
|
||||
deleted: boolean;
|
||||
blocked_by_automations?: boolean;
|
||||
automations?: SessionAutomationJob[];
|
||||
}
|
||||
|
||||
export interface SkillSummary {
|
||||
name: string;
|
||||
description: string;
|
||||
@ -875,6 +882,7 @@ export interface WebuiThreadPersistedPayload {
|
||||
savedAt?: string;
|
||||
messages: UIMessage[];
|
||||
fork_boundary_message_count?: number;
|
||||
has_pending_tool_calls?: boolean;
|
||||
page?: WebuiThreadPagePayload;
|
||||
workspace_scope?: WorkspaceScopePayload;
|
||||
}
|
||||
|
||||
@ -131,6 +131,17 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the automation cascade flag when deleting a session", async () => {
|
||||
await deleteSession("tok", "websocket:chat-1", { deleteAutomations: true });
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions/websocket%3Achat-1/delete?delete_automations=true",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes settings updates as a narrow query string", async () => {
|
||||
await updateSettings("tok", {
|
||||
modelPreset: "default",
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
import i18n from "@/i18n";
|
||||
import type { ChatSummary, SessionAutomationJob } from "@/lib/types";
|
||||
|
||||
const connectSpy = vi.fn();
|
||||
const refreshSpy = vi.fn();
|
||||
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
|
||||
const deleteChatSpy = vi.fn();
|
||||
const getSessionAutomationsSpy = vi.fn<(key: string) => Promise<SessionAutomationJob[]>>();
|
||||
const toggleThemeSpy = vi.fn();
|
||||
const updateUrlSpy = vi.fn();
|
||||
const attachSpy = vi.fn();
|
||||
@ -146,9 +148,12 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||
refresh: refreshSpy,
|
||||
createChat: createChatSpy,
|
||||
forkChat: async () => "fork-chat",
|
||||
deleteChat: async (key: string) => {
|
||||
await deleteChatSpy(key);
|
||||
getSessionAutomations: getSessionAutomationsSpy,
|
||||
deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => {
|
||||
if (options === undefined) await deleteChatSpy(key);
|
||||
else await deleteChatSpy(key, options);
|
||||
setSessions((prev: ChatSummary[]) => prev.filter((s) => s.key !== key));
|
||||
return { deleted: true };
|
||||
},
|
||||
};
|
||||
},
|
||||
@ -210,13 +215,15 @@ import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
|
||||
import App from "@/App";
|
||||
|
||||
describe("App layout", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
mockSessions = [];
|
||||
connectSpy.mockClear();
|
||||
updateUrlSpy.mockClear();
|
||||
refreshSpy.mockReset();
|
||||
createChatSpy.mockClear();
|
||||
deleteChatSpy.mockReset();
|
||||
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
|
||||
toggleThemeSpy.mockReset();
|
||||
attachSpy.mockReset();
|
||||
runStatusHandlers.clear();
|
||||
@ -433,6 +440,74 @@ describe("App layout", () => {
|
||||
expect(document.body.style.pointerEvents).not.toBe("none");
|
||||
}, 15_000);
|
||||
|
||||
it("shows localized bound automations in the first delete confirmation", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
preview: "First chat",
|
||||
},
|
||||
{
|
||||
key: "websocket:chat-b",
|
||||
channel: "websocket",
|
||||
chatId: "chat-b",
|
||||
createdAt: "2026-04-16T11:00:00Z",
|
||||
updatedAt: "2026-04-16T11:00:00Z",
|
||||
preview: "Second chat",
|
||||
},
|
||||
];
|
||||
getSessionAutomationsSpy.mockResolvedValue([
|
||||
{
|
||||
id: "job-1",
|
||||
name: "Daily repo check",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", every_ms: 86_400_000 },
|
||||
payload: { message: "Check the repo" },
|
||||
state: { next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0) },
|
||||
},
|
||||
]);
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
|
||||
render(<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 () => {
|
||||
mockSessions = [
|
||||
{
|
||||
|
||||
@ -5,14 +5,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
|
||||
import { setAppLanguage } from "@/i18n";
|
||||
|
||||
function automationJob(nextRunAt = Date.now() + 3_600_000) {
|
||||
function automationJob(
|
||||
nextRunAt = Date.now() + 3_600_000,
|
||||
state: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
id: "job-1",
|
||||
name: "Morning check",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", every_ms: 3_600_000 },
|
||||
payload: { message: "Check the project status" },
|
||||
state: { next_run_at_ms: nextRunAt },
|
||||
state: { next_run_at_ms: nextRunAt, ...state },
|
||||
};
|
||||
}
|
||||
|
||||
@ -27,7 +30,8 @@ function automationsResponse(jobs: unknown[]) {
|
||||
}
|
||||
|
||||
describe("SessionInfoPopover", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await setAppLanguage("en");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(automationsResponse([automationJob()])),
|
||||
@ -86,6 +90,29 @@ describe("SessionInfoPopover", () => {
|
||||
expect(screen.queryByText("Automations")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a short pending label for deferred automations", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
automationsResponse([automationJob(Date.now() - 1000, { pending: true })]),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<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 () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@ -180,6 +180,36 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not start streaming from completed trailing activity after an answer", () => {
|
||||
const fake = fakeClient();
|
||||
const initialMessages = [
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant" as const,
|
||||
content: "Cron test",
|
||||
turnId: "cron:run",
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool" as const,
|
||||
kind: "trace" as const,
|
||||
content: "message({})",
|
||||
traces: ["message({})"],
|
||||
turnId: "cron:run",
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-cron-done", initialMessages),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
|
||||
expect(result.current.messages.at(-1)?.kind).toBe("trace");
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("drops pending stream work when switching chats", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result, rerender } = renderHook(
|
||||
|
||||
@ -103,7 +103,7 @@ describe("useSessions", () => {
|
||||
preview: "Beta",
|
||||
},
|
||||
]);
|
||||
vi.mocked(api.deleteSession).mockResolvedValue(true);
|
||||
vi.mocked(api.deleteSession).mockResolvedValue({ deleted: true });
|
||||
|
||||
const { result } = renderHook(() => useSessions(), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
@ -115,10 +115,42 @@ describe("useSessions", () => {
|
||||
await result.current.deleteChat("websocket:chat-a");
|
||||
});
|
||||
|
||||
expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a");
|
||||
expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a", undefined);
|
||||
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
|
||||
});
|
||||
|
||||
it("keeps a session when delete is blocked by bound automations", async () => {
|
||||
vi.mocked(api.listSessions).mockResolvedValue([
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
preview: "Alpha",
|
||||
},
|
||||
]);
|
||||
vi.mocked(api.deleteSession).mockResolvedValue({
|
||||
deleted: false,
|
||||
blocked_by_automations: true,
|
||||
automations: [],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessions(), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
|
||||
|
||||
let deleteResult: Awaited<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 () => {
|
||||
vi.mocked(api.listSessions)
|
||||
.mockResolvedValueOnce([
|
||||
@ -187,7 +219,7 @@ describe("useSessions", () => {
|
||||
await result.current.createChat();
|
||||
});
|
||||
|
||||
expect(client.newChat).toHaveBeenCalledWith(5000, undefined);
|
||||
expect(client.newChat).toHaveBeenCalledWith(60_000, undefined);
|
||||
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-new"]);
|
||||
|
||||
await act(async () => {
|
||||
@ -226,7 +258,7 @@ describe("useSessions", () => {
|
||||
await result.current.createChat(workspaceScope);
|
||||
});
|
||||
|
||||
expect(client.newChat).toHaveBeenCalledWith(5000, workspaceScope);
|
||||
expect(client.newChat).toHaveBeenCalledWith(60_000, workspaceScope);
|
||||
expect(result.current.sessions[0]?.workspaceScope).toEqual(workspaceScope);
|
||||
});
|
||||
|
||||
@ -384,6 +416,40 @@ describe("useSessions", () => {
|
||||
expect(result.current.hasPendingToolCalls).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the server pending flag for completed tails that still end with trace rows", async () => {
|
||||
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||
schemaVersion: 3,
|
||||
has_pending_tool_calls: false,
|
||||
messages: [
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "Cron test",
|
||||
turnId: "cron:run",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "message({})",
|
||||
traces: ["message({})"],
|
||||
turnId: "cron:run",
|
||||
createdAt: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:chat-cron-done"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.messages.at(-1)?.kind).toBe("trace");
|
||||
expect(result.current.hasPendingToolCalls).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag transcript as pending when last row is not a trace", async () => {
|
||||
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||
schemaVersion: 3,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user