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