mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 13:58:36 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d14bcaf72 | ||
|
|
47d83af0b6 |
@@ -356,7 +356,8 @@ Providers that use the Responses API can keep reasoning context across a
|
||||
conversation, which helps with multi-step tasks. Supported providers can also
|
||||
compact long conversations automatically.
|
||||
|
||||
nanobot preserves Responses conversation state automatically for OpenAI Responses, OpenAI Codex, Azure OpenAI, DeepSeek V4 Flash, and compatible GitHub Copilot models.
|
||||
nanobot preserves Responses conversation state automatically for OpenAI
|
||||
Responses, OpenAI Codex, Azure OpenAI, and compatible GitHub Copilot models.
|
||||
Native compaction is also automatic when the provider supports it. The
|
||||
threshold is derived from the active model's context window and reserved output
|
||||
headroom; no provider configuration is required.
|
||||
|
||||
@@ -231,8 +231,6 @@ Arbitrary custom provider names are OpenAI-compatible only; they do not use the
|
||||
|
||||
`providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. Direct OpenAI Responses, OpenAI Codex, Azure OpenAI Responses, and eligible GitHub Copilot models share [opaque Responses state retention](./configuration.md#responses-state-and-compaction); native compaction is enabled only where the backend supports it.
|
||||
|
||||
DeepSeek is the model-level exception in the OpenAI-compatible provider: `deepseek-v4-flash` automatically uses DeepSeek's native Responses API, while `deepseek-v4-pro` remains on Chat Completions.
|
||||
|
||||
### Custom OpenAI-Compatible Endpoint
|
||||
|
||||
The `custom` provider fits one OpenAI-compatible endpoint that is not represented by a named provider.
|
||||
|
||||
@@ -134,21 +134,10 @@ class AutoCompact:
|
||||
if entry:
|
||||
return session, self._format_summary(entry[0], entry[1])
|
||||
# Cold path: summary persisted in session metadata (process restarted).
|
||||
# Persisted metadata may outlive schema changes; a malformed summary must
|
||||
# not abort turn preparation.
|
||||
meta = session.metadata.get("_last_summary")
|
||||
if isinstance(meta, dict):
|
||||
summary_meta = cast(dict[str, object], meta)
|
||||
text = summary_meta.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
raw_last_active = summary_meta.get("last_active")
|
||||
try:
|
||||
last_active = (
|
||||
datetime.fromisoformat(raw_last_active)
|
||||
if isinstance(raw_last_active, str)
|
||||
else session.updated_at
|
||||
return session, self._format_summary(
|
||||
cast(str, meta["text"]),
|
||||
datetime.fromisoformat(cast(str, meta["last_active"])),
|
||||
)
|
||||
except ValueError:
|
||||
last_active = session.updated_at
|
||||
return session, self._format_summary(text, last_active)
|
||||
return session, None
|
||||
|
||||
@@ -217,18 +217,16 @@ class ContextBuilder:
|
||||
include_memory_recent_history: bool = True,
|
||||
session_key: str | None = None,
|
||||
unified_session: bool = False,
|
||||
conversation_only: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the complete message list for an LLM call."""
|
||||
messages = list(history)
|
||||
if not conversation_only:
|
||||
root = workspace or self.workspace
|
||||
active_skill_names = (
|
||||
self.skills.get_explicitly_invoked_skills(current_message)
|
||||
if current_role == "user"
|
||||
else []
|
||||
)
|
||||
messages.insert(0, {
|
||||
messages: list[dict[str, Any]] = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": self.build_system_prompt(
|
||||
active_skill_names=active_skill_names,
|
||||
@@ -239,14 +237,16 @@ class ContextBuilder:
|
||||
session_key=session_key,
|
||||
unified_session=unified_session,
|
||||
),
|
||||
})
|
||||
},
|
||||
*history,
|
||||
]
|
||||
current = self.build_current_message(
|
||||
current_message,
|
||||
media=media,
|
||||
current_role=current_role,
|
||||
runtime_context_blocks=runtime_context_blocks,
|
||||
)
|
||||
if messages and messages[-1].get("role") == current_role:
|
||||
if messages[-1].get("role") == current_role:
|
||||
last = dict(messages[-1])
|
||||
last["content"] = self._merge_message_content(
|
||||
last.get("content"),
|
||||
|
||||
+7
-31
@@ -723,7 +723,6 @@ class AgentLoop:
|
||||
include_memory_recent_history=not ctx.ephemeral,
|
||||
session_key=ctx.session.key,
|
||||
unified_session=self._unified_session,
|
||||
conversation_only=ctx.session.transient is True,
|
||||
)
|
||||
|
||||
def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext:
|
||||
@@ -751,12 +750,10 @@ class AgentLoop:
|
||||
self,
|
||||
ctx: TurnContext,
|
||||
) -> list[RuntimeContextBlock]:
|
||||
if ctx.require_session().transient is True:
|
||||
return []
|
||||
assert ctx.request_context is not None
|
||||
return await self._resolve_runtime_context_for_request(
|
||||
ctx.request_context,
|
||||
ctx.tools if ctx.tools is not None else self.tools,
|
||||
ctx.tools or self.tools,
|
||||
)
|
||||
|
||||
async def _resolve_runtime_context_for_request(
|
||||
@@ -787,24 +784,18 @@ class AgentLoop:
|
||||
else:
|
||||
logger.warning("Command '{}' matched but dispatch returned None", raw)
|
||||
|
||||
async def cancel_active_turn(self, key: str) -> int:
|
||||
"""Cancel active work and discard queued follow-ups for *key*.
|
||||
async def _cancel_active_tasks(self, key: str) -> int:
|
||||
"""Cancel and await all active tasks and subagents for *key*.
|
||||
|
||||
Returns the total number of cancelled tasks + subagents.
|
||||
"""
|
||||
pending = self._pending_queues.pop(key, None)
|
||||
queued = 0
|
||||
if pending is not None:
|
||||
while not pending.empty():
|
||||
pending.get_nowait()
|
||||
queued += 1
|
||||
tasks = tuple(self._active_tasks.pop(key, set()))
|
||||
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
||||
for t in tasks:
|
||||
with suppress(asyncio.CancelledError, Exception):
|
||||
await t
|
||||
sub_cancelled = await self.subagents.cancel_by_session(key)
|
||||
return queued + cancelled + sub_cancelled
|
||||
return cancelled + sub_cancelled
|
||||
|
||||
def _effective_session_key(self, msg: InboundMessage) -> str:
|
||||
"""Return the session key used for task routing and mid-turn injections."""
|
||||
@@ -931,10 +922,7 @@ class AgentLoop:
|
||||
if isinstance(metadata_value, dict)
|
||||
else {}
|
||||
)
|
||||
if (
|
||||
pending_msg.channel != "system"
|
||||
and not (session is not None and session.transient is True)
|
||||
):
|
||||
if pending_msg.channel != "system":
|
||||
scope = self.workspace_scopes.for_turn(
|
||||
channel=pending_msg.channel,
|
||||
message_metadata=metadata,
|
||||
@@ -1014,7 +1002,7 @@ class AgentLoop:
|
||||
message_metadata=metadata,
|
||||
session_metadata=session.metadata if session is not None else None,
|
||||
)
|
||||
effective_tools = tools if tools is not None else self.tools
|
||||
effective_tools = tools or self.tools
|
||||
request_ctx = request_context or RequestContext(
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
@@ -1172,11 +1160,6 @@ class AgentLoop:
|
||||
effective_key = self._effective_session_key(msg)
|
||||
if await agent_context.handle_runtime_control(self, msg, self.tools):
|
||||
continue
|
||||
if (
|
||||
msg.transient_session
|
||||
and not self.sessions.is_transient_active(effective_key)
|
||||
):
|
||||
continue
|
||||
if self.commands.is_priority(raw):
|
||||
await self._dispatch_command_inline(
|
||||
msg, effective_key, raw,
|
||||
@@ -1288,8 +1271,6 @@ class AgentLoop:
|
||||
session_key,
|
||||
exc_info=True,
|
||||
)
|
||||
if msg.transient_session:
|
||||
raise
|
||||
# Preserve partial context from the interrupted turn so
|
||||
# the user does not lose tool results and assistant
|
||||
# messages accumulated before /stop. The checkpoint was
|
||||
@@ -1592,16 +1573,13 @@ class AgentLoop:
|
||||
if ctx.session is None:
|
||||
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
||||
session = ctx.session
|
||||
if session.transient is True:
|
||||
ctx.ephemeral = True
|
||||
ctx.tools = ToolRegistry()
|
||||
self._remember_unified_session_route(
|
||||
session,
|
||||
msg,
|
||||
is_user_turn=ctx.original_user_text is not None,
|
||||
)
|
||||
await ctx.delivery.started()
|
||||
if ctx.kind is TurnKind.USER and not session.transient:
|
||||
if ctx.kind is TurnKind.USER:
|
||||
self.workspace_scopes.persist_message_scope(session, msg)
|
||||
|
||||
if self._restore_runtime_checkpoint(session):
|
||||
@@ -1611,8 +1589,6 @@ class AgentLoop:
|
||||
|
||||
async def _compact_session(self, ctx: TurnContext) -> None:
|
||||
session = ctx.require_session()
|
||||
if session.transient is True:
|
||||
return
|
||||
ctx.session, pending = self.auto_compact.prepare_session(
|
||||
session,
|
||||
ctx.session_key,
|
||||
|
||||
@@ -713,10 +713,11 @@ class MemoryStore:
|
||||
if tools_used
|
||||
else ""
|
||||
)
|
||||
raw_timestamp = message.get("timestamp")
|
||||
timestamp = str(raw_timestamp) if raw_timestamp is not None else "?"
|
||||
role = str(message.get("role") or "unknown")
|
||||
lines.append(f"[{timestamp[:16]}] {role.upper()}{tools}: {content}")
|
||||
timestamp = cast(str, message.get("timestamp", "?"))
|
||||
role = cast(str, message["role"])
|
||||
lines.append(
|
||||
f"[{timestamp[:16]}] {role.upper()}{tools}: {content}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def raw_archive(
|
||||
|
||||
@@ -660,7 +660,7 @@ class WriteStdinTool(Tool):
|
||||
close_stdin=close_stdin if first else False,
|
||||
terminate=terminate if first else False,
|
||||
yield_time_ms=step_ms,
|
||||
max_output_chars=MAX_OUTPUT_CHARS,
|
||||
max_output_chars=max_output_chars,
|
||||
owner_session_key=current_request_session_key(),
|
||||
)
|
||||
first = False
|
||||
|
||||
@@ -18,7 +18,6 @@ INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
|
||||
RUNTIME_CONTROL_ACK = "_ack"
|
||||
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
|
||||
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
|
||||
INBOUND_META_TRANSIENT_SESSION = "_transient_session"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -33,7 +32,6 @@ class InboundMessage:
|
||||
media: list[str] = field(default_factory=list) # Media URLs
|
||||
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
|
||||
session_key_override: str | None = None # Optional override for thread-scoped sessions
|
||||
transient_session: bool = False # In-memory session whose lifetime is owned by the channel
|
||||
|
||||
@property
|
||||
def session_key(self) -> str:
|
||||
|
||||
@@ -8,11 +8,7 @@ from typing import Any, cast
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.bus.events import (
|
||||
INBOUND_META_TRANSIENT_SESSION,
|
||||
InboundMessage,
|
||||
OutboundMessage,
|
||||
)
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.pairing import (
|
||||
PAIRING_CODE_META_KEY,
|
||||
@@ -281,8 +277,7 @@ class BaseChannel(ABC):
|
||||
)
|
||||
return
|
||||
|
||||
meta = dict(metadata or {})
|
||||
transient_session = meta.pop(INBOUND_META_TRANSIENT_SESSION, False) is True
|
||||
meta = metadata or {}
|
||||
if self.supports_streaming:
|
||||
meta = {**meta, "_wants_stream": True}
|
||||
|
||||
@@ -294,7 +289,6 @@ class BaseChannel(ABC):
|
||||
media=media or [],
|
||||
metadata=meta,
|
||||
session_key_override=session_key,
|
||||
transient_session=transient_session,
|
||||
)
|
||||
|
||||
await self.bus.publish_inbound(msg)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import hashlib
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
@@ -97,7 +97,6 @@ class ChannelManager:
|
||||
webui_runtime_model_name: Callable[[], str | None] | None = None,
|
||||
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
||||
webui_local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||
webui_cancel_active_turn: Callable[[str], Awaitable[int]] | None = None,
|
||||
webui_static_dist: bool = True,
|
||||
webui_runtime_surface: str = "browser",
|
||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||
@@ -111,7 +110,6 @@ class ChannelManager:
|
||||
self._webui_runtime_model_name = webui_runtime_model_name
|
||||
self._webui_cron_pending_job_ids = webui_cron_pending_job_ids
|
||||
self._webui_local_trigger_pending_ids = webui_local_trigger_pending_ids
|
||||
self._webui_cancel_active_turn = webui_cancel_active_turn
|
||||
self._webui_static_dist = webui_static_dist
|
||||
self._webui_runtime_surface = webui_runtime_surface
|
||||
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
||||
@@ -180,7 +178,6 @@ class ChannelManager:
|
||||
local_trigger_store=self._local_trigger_store,
|
||||
cron_pending_job_ids=self._webui_cron_pending_job_ids,
|
||||
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
|
||||
cancel_active_turn=self._webui_cancel_active_turn,
|
||||
channel_feature_action=self.apply_channel_feature_action,
|
||||
channel_runtime_status=self.get_status,
|
||||
skill_state_action=self._webui_skill_state_action,
|
||||
|
||||
@@ -493,11 +493,12 @@ class SlackChannel(BaseChannel):
|
||||
except Exception as e:
|
||||
self.logger.debug("reactions_add failed: {}", e)
|
||||
|
||||
# Thread-scoped session key whenever the turn lives in a thread: either the
|
||||
# message arrived inside one (raw_thread_ts) or reply_in_thread opens a new
|
||||
# thread for this channel message. DM roots have no thread_ts and keep the
|
||||
# default per-chat session, so context doesn't bleed across thread boundaries.
|
||||
session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts else None
|
||||
# Thread-scoped session key whenever the user is in a real thread
|
||||
# (raw_thread_ts is set). DM threads get their own session, separate
|
||||
# from the DM root, so context doesn't bleed across thread boundaries.
|
||||
session_key = (
|
||||
f"slack:{chat_id}:{thread_ts}" if thread_ts and raw_thread_ts else None
|
||||
)
|
||||
media_paths: list[str] = []
|
||||
file_markers: list[str] = []
|
||||
for file_info in _as_json_list(event.get("files")) or []:
|
||||
|
||||
@@ -555,113 +555,6 @@ async def test_dm_thread_message_keeps_thread_ts_and_threaded_session() -> None:
|
||||
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
|
||||
|
||||
|
||||
def _channel_mention_request(envelope_id: str, ts: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
type="events_api",
|
||||
envelope_id=envelope_id,
|
||||
payload={
|
||||
"event": {
|
||||
"type": "app_mention",
|
||||
"user": "U1",
|
||||
"channel": "C123",
|
||||
"text": "<@UBOT> hello",
|
||||
"ts": ts,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_root_message_uses_thread_scoped_session() -> None:
|
||||
"""A channel mention that opens a thread belongs to that thread's session."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
channel._web_client = _FakeAsyncWebClient()
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
||||
|
||||
req = _channel_mention_request("env-c1", "1700000000.000100")
|
||||
|
||||
await channel._on_socket_request(client, req)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
kwargs = channel._handle_message.await_args.kwargs
|
||||
assert kwargs["session_key"] == "slack:C123:1700000000.000100"
|
||||
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_root_messages_do_not_share_one_session() -> None:
|
||||
"""Two threads opened in the same channel must not collapse into one session."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
channel._web_client = _FakeAsyncWebClient()
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
||||
|
||||
first = _channel_mention_request("env-c1", "1700000000.000100")
|
||||
second = _channel_mention_request("env-c2", "1700000000.000200")
|
||||
|
||||
await channel._on_socket_request(client, first)
|
||||
await channel._on_socket_request(client, second)
|
||||
|
||||
session_keys = [call.kwargs["session_key"] for call in channel._handle_message.await_args_list]
|
||||
assert session_keys == [
|
||||
"slack:C123:1700000000.000100",
|
||||
"slack:C123:1700000000.000200",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_root_message_without_reply_in_thread_uses_channel_session() -> None:
|
||||
"""With reply_in_thread disabled no thread is opened, so the channel session is used."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True, reply_in_thread=False), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
channel._web_client = _FakeAsyncWebClient()
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
||||
|
||||
req = _channel_mention_request("env-c3", "1700000000.000300")
|
||||
|
||||
await channel._on_socket_request(client, req)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
kwargs = channel._handle_message.await_args.kwargs
|
||||
assert kwargs["session_key"] is None
|
||||
assert kwargs["metadata"]["slack"]["thread_ts"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_thread_reply_keeps_thread_session() -> None:
|
||||
"""A reply inside a channel thread stays in the session opened by the root message."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
channel._web_client = _FakeAsyncWebClient()
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
channel._with_thread_context = AsyncMock(return_value="hello") # type: ignore[method-assign]
|
||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
||||
req = SimpleNamespace(
|
||||
type="events_api",
|
||||
envelope_id="env-c4",
|
||||
payload={
|
||||
"event": {
|
||||
"type": "app_mention",
|
||||
"user": "U1",
|
||||
"channel": "C123",
|
||||
"text": "<@UBOT> follow up",
|
||||
"ts": "1700000000.000400",
|
||||
"thread_ts": "1700000000.000100",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await channel._on_socket_request(client, req)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
kwargs = channel._handle_message.await_args.kwargs
|
||||
assert kwargs["session_key"] == "slack:C123:1700000000.000100"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_slash_command_skips_thread_context() -> None:
|
||||
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
|
||||
|
||||
@@ -18,11 +18,7 @@ from websockets.asyncio.server import ServerConnection, serve, unix_serve
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
from websockets.http11 import Request as WsRequest
|
||||
|
||||
from nanobot.bus.events import (
|
||||
INBOUND_META_TRANSIENT_SESSION,
|
||||
OUTBOUND_META_AGENT_UI,
|
||||
OutboundMessage,
|
||||
)
|
||||
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
|
||||
from nanobot.bus.outbound_events import (
|
||||
GoalStateSyncEvent,
|
||||
GoalStatusEvent,
|
||||
@@ -36,10 +32,6 @@ from nanobot.bus.outbound_events import (
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.channels.websocket.temporary_chat import (
|
||||
TemporaryChatLifecycle,
|
||||
TemporaryChatLifecycleError,
|
||||
)
|
||||
from nanobot.command.builtin import builtin_command_starts_agent_turn
|
||||
from nanobot.config.schema import Base
|
||||
from nanobot.runtime_context import (
|
||||
@@ -84,8 +76,6 @@ from nanobot.webui.websocket_logging import websockets_server_logger
|
||||
|
||||
# Plain HTTP WebUI routes also run through websockets.process_request.
|
||||
_WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0
|
||||
_TEMPORARY_CHAT_ID_PREFIX = "temporary-"
|
||||
_TEMPORARY_COMMANDS = frozenset({"/model", "/stop"})
|
||||
|
||||
|
||||
class WebSocketConfig(Base):
|
||||
@@ -225,10 +215,6 @@ def _is_valid_chat_id(value: Any) -> TypeGuard[str]:
|
||||
return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None
|
||||
|
||||
|
||||
def _is_temporary_chat_id(value: Any) -> TypeGuard[str]:
|
||||
return _is_valid_chat_id(value) and value.startswith(_TEMPORARY_CHAT_ID_PREFIX)
|
||||
|
||||
|
||||
def _parse_envelope(raw: str) -> dict[str, Any] | None:
|
||||
"""Return a typed envelope dict if the frame is a new-style JSON envelope, else None.
|
||||
|
||||
@@ -300,13 +286,6 @@ class WebSocketChannel(BaseChannel):
|
||||
self._workspaces = gateway.workspaces
|
||||
|
||||
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
||||
self._temporary_chats = TemporaryChatLifecycle(
|
||||
sessions=gateway.session_manager,
|
||||
cancel_active_turn=gateway.cancel_active_turn,
|
||||
attach=self._attach,
|
||||
detach=self._detach,
|
||||
clear_stream_buffers=self._clear_stream_buffers,
|
||||
)
|
||||
|
||||
# -- Subscription bookkeeping -------------------------------------------
|
||||
|
||||
@@ -318,23 +297,6 @@ class WebSocketChannel(BaseChannel):
|
||||
self._subs.setdefault(chat_id, set()).add(connection)
|
||||
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
||||
|
||||
def _detach(self, connection: ServerConnection, chat_id: str) -> None:
|
||||
chats = self._conn_chats.get(connection)
|
||||
if chats is not None:
|
||||
chats.discard(chat_id)
|
||||
if not chats:
|
||||
self._conn_chats.pop(connection, None)
|
||||
subscribers = self._subs.get(chat_id)
|
||||
if subscribers is not None:
|
||||
subscribers.discard(connection)
|
||||
if not subscribers:
|
||||
self._subs.pop(chat_id, None)
|
||||
|
||||
def _clear_stream_buffers(self, chat_id: str) -> None:
|
||||
for key in tuple(self._stream_text_buffers):
|
||||
if key[0] == chat_id:
|
||||
self._stream_text_buffers.pop(key, None)
|
||||
|
||||
async def send_webui_protocol_error(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
@@ -363,13 +325,16 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
await self._hydrate_after_subscribe(fork_id)
|
||||
|
||||
async def _cleanup_connection(self, connection: ServerConnection) -> None:
|
||||
def _cleanup_connection(self, connection: ServerConnection) -> None:
|
||||
"""Remove *connection* from every subscription set; safe to call multiple times."""
|
||||
try:
|
||||
await self._temporary_chats.discard_owner(connection)
|
||||
finally:
|
||||
for chat_id in tuple(self._conn_chats.get(connection, ())):
|
||||
self._detach(connection, chat_id)
|
||||
chat_ids = self._conn_chats.pop(connection, set())
|
||||
for cid in chat_ids:
|
||||
subs = self._subs.get(cid)
|
||||
if subs is None:
|
||||
continue
|
||||
subs.discard(connection)
|
||||
if not subs:
|
||||
self._subs.pop(cid, None)
|
||||
self._conn_default.pop(connection, None)
|
||||
self._webui_connections.discard(connection)
|
||||
|
||||
@@ -422,7 +387,7 @@ class WebSocketChannel(BaseChannel):
|
||||
try:
|
||||
await connection.send(raw)
|
||||
except ConnectionClosed:
|
||||
await self._cleanup_connection(connection)
|
||||
self._cleanup_connection(connection)
|
||||
except Exception as e:
|
||||
self.logger.warning("failed to send {} event: {}", event, e)
|
||||
|
||||
@@ -644,7 +609,7 @@ class WebSocketChannel(BaseChannel):
|
||||
except Exception as e:
|
||||
self.logger.debug("connection ended: {}", e)
|
||||
finally:
|
||||
await self._cleanup_connection(connection)
|
||||
self._cleanup_connection(connection)
|
||||
|
||||
# -- Inbound WebSocket envelopes ---------------------------------------
|
||||
|
||||
@@ -682,36 +647,11 @@ class WebSocketChannel(BaseChannel):
|
||||
if t == "fork_chat":
|
||||
await handle_webui_fork_chat(self, connection, envelope)
|
||||
return
|
||||
if t == "discard_temporary_chat":
|
||||
cid = envelope.get("chat_id")
|
||||
if not _is_temporary_chat_id(cid):
|
||||
await self._send_event(connection, "error", detail="invalid temporary chat_id")
|
||||
return
|
||||
try:
|
||||
await self._temporary_chats.discard(connection, cid)
|
||||
except TemporaryChatLifecycleError as exc:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail=exc.detail,
|
||||
chat_id=cid,
|
||||
)
|
||||
return
|
||||
await self._send_event(connection, "temporary_chat_discarded", chat_id=cid)
|
||||
return
|
||||
if t == "attach":
|
||||
cid = envelope.get("chat_id")
|
||||
if not _is_valid_chat_id(cid):
|
||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||
return
|
||||
if _is_temporary_chat_id(cid):
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail="temporary_chat_cannot_attach",
|
||||
chat_id=cid,
|
||||
)
|
||||
return
|
||||
self._attach(connection, cid)
|
||||
await self._send_event(connection, "attached", chat_id=cid)
|
||||
await self._hydrate_after_subscribe(cid)
|
||||
@@ -721,14 +661,6 @@ class WebSocketChannel(BaseChannel):
|
||||
if not _is_valid_chat_id(cid):
|
||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||
return
|
||||
if _is_temporary_chat_id(cid):
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail="temporary_chat_has_no_workspace",
|
||||
chat_id=cid,
|
||||
)
|
||||
return
|
||||
scope = await self._workspace_scope_or_error(
|
||||
connection,
|
||||
lambda: self._workspaces.scope_for_set_request(
|
||||
@@ -760,15 +692,6 @@ class WebSocketChannel(BaseChannel):
|
||||
if not _is_valid_chat_id(cid):
|
||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||
return
|
||||
temporary = envelope.get("temporary") is True
|
||||
if _is_temporary_chat_id(cid) != temporary:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail="temporary_chat_mismatch",
|
||||
chat_id=cid,
|
||||
)
|
||||
return
|
||||
raw_turn_id = envelope.get("turn_id")
|
||||
turn_id = raw_turn_id if isinstance(raw_turn_id, str) and raw_turn_id else None
|
||||
rejection_fields = {
|
||||
@@ -805,17 +728,6 @@ class WebSocketChannel(BaseChannel):
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
if temporary:
|
||||
await self._dispatch_temporary_message(
|
||||
connection,
|
||||
client_id=client_id,
|
||||
chat_id=cid,
|
||||
content=content,
|
||||
turn_id=turn_id,
|
||||
envelope=envelope,
|
||||
rejection_fields=rejection_fields,
|
||||
)
|
||||
return
|
||||
|
||||
raw_media = envelope.get("media")
|
||||
media_paths: list[str] = []
|
||||
@@ -937,103 +849,6 @@ class WebSocketChannel(BaseChannel):
|
||||
return
|
||||
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
||||
|
||||
async def _dispatch_temporary_message(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
*,
|
||||
client_id: str,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
turn_id: str | None,
|
||||
envelope: dict[str, Any],
|
||||
rejection_fields: dict[str, str],
|
||||
) -> None:
|
||||
"""Admit a WebUI-only message without durable or local-agent capabilities."""
|
||||
if connection not in self._webui_connections:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail="temporary_chat_unavailable",
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
forbidden = (
|
||||
"media",
|
||||
"cli_apps",
|
||||
"mcp_presets",
|
||||
"quoted_context",
|
||||
"workspace_scope",
|
||||
)
|
||||
if any(field in envelope for field in forbidden):
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail="temporary_chat_capability_rejected",
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
if not content.strip():
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail="missing content",
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
command = content.strip().partition(" ")[0].lower()
|
||||
if command.startswith("/") and command not in _TEMPORARY_COMMANDS:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail="temporary_chat_command_rejected",
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
session_key = self._temporary_chats.claim(connection, chat_id)
|
||||
except TemporaryChatLifecycleError as exc:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail=exc.detail,
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
|
||||
metadata: dict[str, Any] = {
|
||||
"remote": getattr(connection, "remote_address", None),
|
||||
"webui": True,
|
||||
INBOUND_META_TRANSIENT_SESSION: True,
|
||||
**self._transcripts.client_turn_metadata(turn_id),
|
||||
}
|
||||
queued_owner = None
|
||||
if builtin_command_starts_agent_turn(content):
|
||||
queued_owner = register_queued_websocket_turn_if_idle(chat_id, turn_id)
|
||||
if queued_owner is not None:
|
||||
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
|
||||
accepted = False
|
||||
try:
|
||||
await self._handle_message(
|
||||
sender_id=client_id,
|
||||
chat_id=chat_id,
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
session_key=session_key,
|
||||
is_dm=False,
|
||||
)
|
||||
accepted = True
|
||||
finally:
|
||||
if not accepted and queued_owner is not None:
|
||||
clear_websocket_turn_if_current(chat_id, queued_owner)
|
||||
if turn_id:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"message_accepted",
|
||||
chat_id=chat_id,
|
||||
turn_id=turn_id,
|
||||
)
|
||||
|
||||
async def _workspace_scope_or_error(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
@@ -1074,8 +889,6 @@ class WebSocketChannel(BaseChannel):
|
||||
except Exception as e:
|
||||
self.logger.warning("server task error during shutdown: {}", e)
|
||||
self._server_task = None
|
||||
for connection in tuple(self._conn_chats):
|
||||
await self._temporary_chats.discard_owner(connection)
|
||||
self._subs.clear()
|
||||
self._conn_chats.clear()
|
||||
self._conn_default.clear()
|
||||
@@ -1093,7 +906,7 @@ class WebSocketChannel(BaseChannel):
|
||||
try:
|
||||
await connection.send(raw)
|
||||
except ConnectionClosed:
|
||||
await self._cleanup_connection(connection)
|
||||
self._cleanup_connection(connection)
|
||||
self.logger.warning("connection gone{}", label)
|
||||
except Exception:
|
||||
self.logger.exception("send failed{}", label)
|
||||
@@ -1110,8 +923,6 @@ class WebSocketChannel(BaseChannel):
|
||||
transcript_overrides: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Persist one canonical turn event and retain unsafe owners on failure."""
|
||||
if _is_temporary_chat_id(chat_id):
|
||||
return True
|
||||
persisted = self._transcripts.prepare_and_append(
|
||||
chat_id,
|
||||
event,
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Connection-owned lifecycle for WebUI Temporary Chat sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from websockets.asyncio.server import ServerConnection
|
||||
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.webui_turns import clear_websocket_turns
|
||||
|
||||
|
||||
class TemporaryChatLifecycleError(RuntimeError):
|
||||
"""A stable WebSocket protocol error raised by the temporary-chat lifecycle."""
|
||||
|
||||
def __init__(self, detail: str) -> None:
|
||||
self.detail = detail
|
||||
super().__init__(detail)
|
||||
|
||||
|
||||
class TemporaryChatLifecycle:
|
||||
"""Own temporary session identity, cancellation, and cleanup ordering."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sessions: SessionManager | None,
|
||||
cancel_active_turn: Callable[[str], Awaitable[int]] | None,
|
||||
attach: Callable[[ServerConnection, str], None],
|
||||
detach: Callable[[ServerConnection, str], None],
|
||||
clear_stream_buffers: Callable[[str], None],
|
||||
) -> None:
|
||||
self._sessions = sessions
|
||||
self._cancel_active_turn = cancel_active_turn
|
||||
self._attach = attach
|
||||
self._detach = detach
|
||||
self._clear_stream_buffers = clear_stream_buffers
|
||||
self._owners: dict[str, ServerConnection] = {}
|
||||
|
||||
def claim(self, owner: ServerConnection, chat_id: str) -> str:
|
||||
"""Claim *chat_id* for *owner* and return its in-memory session key."""
|
||||
if self._sessions is None or self._cancel_active_turn is None:
|
||||
raise TemporaryChatLifecycleError("temporary_chat_unavailable")
|
||||
current = self._owners.get(chat_id)
|
||||
if current is not None and current is not owner:
|
||||
raise TemporaryChatLifecycleError("temporary_chat_not_owned")
|
||||
|
||||
session_key = f"websocket:{chat_id}"
|
||||
self._sessions.get_or_create_transient(session_key)
|
||||
self._owners[chat_id] = owner
|
||||
self._attach(owner, chat_id)
|
||||
return session_key
|
||||
|
||||
async def discard(self, owner: ServerConnection, chat_id: str) -> None:
|
||||
"""Discard an owned chat; an unused chat is already discarded."""
|
||||
current = self._owners.get(chat_id)
|
||||
if current is None:
|
||||
return
|
||||
if current is not owner:
|
||||
raise TemporaryChatLifecycleError("temporary_chat_not_owned")
|
||||
await self._discard_owned(owner, chat_id)
|
||||
|
||||
async def discard_owner(self, owner: ServerConnection) -> None:
|
||||
"""Discard every temporary chat held by a disconnected owner."""
|
||||
chat_ids = (
|
||||
chat_id
|
||||
for chat_id, current in self._owners.items()
|
||||
if current is owner
|
||||
)
|
||||
for chat_id in tuple(chat_ids):
|
||||
await self._discard_owned(owner, chat_id)
|
||||
|
||||
async def _discard_owned(self, owner: ServerConnection, chat_id: str) -> None:
|
||||
self._owners.pop(chat_id, None)
|
||||
self._detach(owner, chat_id)
|
||||
|
||||
session_key = f"websocket:{chat_id}"
|
||||
assert self._sessions is not None
|
||||
assert self._cancel_active_turn is not None
|
||||
self._sessions.discard_transient(session_key)
|
||||
try:
|
||||
await self._cancel_active_turn(session_key)
|
||||
finally:
|
||||
clear_websocket_turns(chat_id)
|
||||
self._clear_stream_buffers(chat_id)
|
||||
@@ -111,7 +111,6 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
|
||||
runtime_model_name=None,
|
||||
runtime_surface=kw.get("runtime_surface", "browser"),
|
||||
runtime_capabilities_overrides=kw.get("runtime_capabilities_overrides"),
|
||||
cancel_active_turn=kw.get("cancel_active_turn"),
|
||||
)
|
||||
|
||||
|
||||
@@ -191,182 +190,6 @@ def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
|
||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_message_registers_in_memory_session(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
cancel = AsyncMock(return_value=0)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(
|
||||
bus,
|
||||
session_manager=sessions,
|
||||
cancel_active_turn=cancel,
|
||||
),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
connection.remote_address = None
|
||||
channel._webui_connections.add(connection)
|
||||
chat_id = "temporary-test"
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": chat_id,
|
||||
"content": "hello",
|
||||
"turn_id": "turn-1",
|
||||
"temporary": True,
|
||||
"webui": True,
|
||||
},
|
||||
)
|
||||
|
||||
inbound = bus.publish_inbound.await_args.args[0]
|
||||
assert inbound.session_key == f"websocket:{chat_id}"
|
||||
assert inbound.transient_session is True
|
||||
assert sessions.is_transient_active(inbound.session_key) is True
|
||||
assert sessions.get_cached(inbound.session_key).transient is True
|
||||
assert read_transcript_lines(inbound.session_key) == []
|
||||
assert json.loads(connection.send.await_args.args[0])["event"] == "message_accepted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"envelope",
|
||||
[
|
||||
{"type": "attach", "chat_id": "temporary-test"},
|
||||
{
|
||||
"type": "set_workspace_scope",
|
||||
"chat_id": "temporary-test",
|
||||
"workspace_scope": {},
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": "temporary-test",
|
||||
"content": "hello",
|
||||
"temporary": True,
|
||||
"media": [],
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": "temporary-test",
|
||||
"content": "/history",
|
||||
"temporary": True,
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_temporary_chat_rejects_persistent_capabilities(
|
||||
bus,
|
||||
tmp_path,
|
||||
envelope,
|
||||
) -> None:
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(
|
||||
bus,
|
||||
session_manager=SessionManager(tmp_path),
|
||||
cancel_active_turn=AsyncMock(return_value=0),
|
||||
),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
connection.remote_address = None
|
||||
channel._webui_connections.add(connection)
|
||||
|
||||
await channel._dispatch_envelope(connection, "client", envelope)
|
||||
|
||||
payload = json.loads(connection.send.await_args.args[0])
|
||||
assert payload["event"] == "error"
|
||||
bus.publish_inbound.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_temporary_chat_cancels_then_forgets_session(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
cancel = AsyncMock(return_value=1)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(
|
||||
bus,
|
||||
session_manager=sessions,
|
||||
cancel_active_turn=cancel,
|
||||
),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
connection.remote_address = None
|
||||
channel._webui_connections.add(connection)
|
||||
chat_id = "temporary-test"
|
||||
session_key = channel._temporary_chats.claim(connection, chat_id)
|
||||
sessions.get_cached(session_key).add_message("user", "private")
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"client",
|
||||
{"type": "discard_temporary_chat", "chat_id": chat_id},
|
||||
)
|
||||
|
||||
cancel.assert_awaited_once_with(session_key)
|
||||
assert sessions.get_cached(session_key) is None
|
||||
assert chat_id not in channel._subs
|
||||
assert json.loads(connection.send.await_args.args[0]) == {
|
||||
"event": "temporary_chat_discarded",
|
||||
"chat_id": chat_id,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_unused_temporary_chat_is_idempotent(bus, tmp_path) -> None:
|
||||
cancel = AsyncMock(return_value=0)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(
|
||||
bus,
|
||||
session_manager=SessionManager(tmp_path),
|
||||
cancel_active_turn=cancel,
|
||||
),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"client",
|
||||
{"type": "discard_temporary_chat", "chat_id": "temporary-unused"},
|
||||
)
|
||||
|
||||
cancel.assert_not_awaited()
|
||||
assert json.loads(connection.send.await_args.args[0]) == {
|
||||
"event": "temporary_chat_discarded",
|
||||
"chat_id": "temporary-unused",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_discards_owned_temporary_chat(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
cancel = AsyncMock(return_value=1)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(
|
||||
bus,
|
||||
session_manager=sessions,
|
||||
cancel_active_turn=cancel,
|
||||
),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
chat_id = "temporary-disconnect"
|
||||
session_key = channel._temporary_chats.claim(connection, chat_id)
|
||||
|
||||
await channel._cleanup_connection(connection)
|
||||
|
||||
cancel.assert_awaited_once_with(session_key)
|
||||
assert sessions.get_cached(session_key) is None
|
||||
assert chat_id not in channel._subs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
|
||||
class Conn:
|
||||
|
||||
@@ -230,30 +230,9 @@ class WeixinChannel(BaseChannel):
|
||||
self.logger.error("Failed to load Weixin account state", exc_info=True)
|
||||
return False
|
||||
|
||||
def _save_state(self, *, force: bool = False) -> None:
|
||||
def _save_state(self) -> None:
|
||||
state_file = self._get_state_dir() / "account.json"
|
||||
with suppress(Exception):
|
||||
if not force and state_file.exists():
|
||||
persisted: object = None
|
||||
try:
|
||||
persisted = json.loads(state_file.read_text())
|
||||
except Exception:
|
||||
persisted = None
|
||||
persisted_token = ""
|
||||
if isinstance(persisted, dict):
|
||||
persisted_mapping = cast(dict[str, object], persisted)
|
||||
persisted_token = str(persisted_mapping.get("token", "") or "")
|
||||
configured_token_is_authoritative: bool = bool(self.config.token) and (
|
||||
self._token == self.config.token
|
||||
)
|
||||
if (
|
||||
persisted_token
|
||||
and persisted_token != self._token
|
||||
and not configured_token_is_authoritative
|
||||
):
|
||||
# A concurrent QR login may have committed a newer token.
|
||||
# Never let an older runtime snapshot overwrite it.
|
||||
return
|
||||
data = {
|
||||
"token": self._token,
|
||||
"get_updates_buf": self._get_updates_buf,
|
||||
@@ -510,7 +489,7 @@ class WeixinChannel(BaseChannel):
|
||||
self._token = token
|
||||
if base_url:
|
||||
self.config.base_url = base_url
|
||||
self._save_state(force=True)
|
||||
self._save_state()
|
||||
|
||||
async def connect_close_client(self) -> None:
|
||||
self._running = False
|
||||
@@ -634,8 +613,6 @@ class WeixinChannel(BaseChannel):
|
||||
remaining = self._session_pause_remaining_s()
|
||||
if remaining > 0:
|
||||
await asyncio.sleep(remaining)
|
||||
if not self.config.token:
|
||||
self._load_state()
|
||||
return
|
||||
|
||||
body: dict[str, Any] = {
|
||||
|
||||
@@ -98,80 +98,6 @@ def test_save_and_load_state_persists_context_tokens(tmp_path) -> None:
|
||||
assert restored._context_tokens == {"wx-user": "ctx-1"}
|
||||
|
||||
|
||||
def test_save_state_preserves_token_committed_by_another_instance(tmp_path) -> None:
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._token = "old-token"
|
||||
channel._save_state()
|
||||
|
||||
replacement = {
|
||||
"token": "new-token",
|
||||
"base_url": "https://new.example",
|
||||
"get_updates_buf": "",
|
||||
"context_tokens": {},
|
||||
"typing_tickets": {},
|
||||
}
|
||||
(tmp_path / "account.json").write_text(json.dumps(replacement), encoding="utf-8")
|
||||
|
||||
channel._get_updates_buf = "stale-cursor"
|
||||
channel._save_state()
|
||||
|
||||
assert json.loads((tmp_path / "account.json").read_text()) == replacement
|
||||
|
||||
|
||||
def test_save_state_force_overwrites_replaced_token(tmp_path) -> None:
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
||||
MessageBus(),
|
||||
)
|
||||
(tmp_path / "account.json").write_text(json.dumps({"token": "old-token"}), encoding="utf-8")
|
||||
|
||||
channel.connect_commit_account(token="new-token", base_url="https://new.example")
|
||||
|
||||
saved = json.loads((tmp_path / "account.json").read_text())
|
||||
assert saved["token"] == "new-token"
|
||||
assert saved["base_url"] == "https://new.example"
|
||||
|
||||
|
||||
def test_save_state_persists_explicit_config_token_over_stale_state(tmp_path) -> None:
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(
|
||||
enabled=True,
|
||||
allow_from=["*"],
|
||||
token="configured-token",
|
||||
state_dir=str(tmp_path),
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._token = "configured-token"
|
||||
channel._get_updates_buf = "current-cursor"
|
||||
(tmp_path / "account.json").write_text(
|
||||
json.dumps({"token": "stale-token", "get_updates_buf": "stale-cursor"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
channel._save_state()
|
||||
|
||||
saved = json.loads((tmp_path / "account.json").read_text())
|
||||
assert saved["token"] == "configured-token"
|
||||
assert saved["get_updates_buf"] == "current-cursor"
|
||||
|
||||
|
||||
def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_path) -> None:
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
||||
MessageBus(),
|
||||
)
|
||||
persisted = {"token": "persisted-token", "get_updates_buf": "persisted-cursor"}
|
||||
(tmp_path / "account.json").write_text(json.dumps(persisted), encoding="utf-8")
|
||||
|
||||
channel._save_state()
|
||||
|
||||
assert json.loads((tmp_path / "account.json").read_text()) == persisted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_deduplicates_inbound_ids() -> None:
|
||||
channel, bus = _make_channel()
|
||||
@@ -536,56 +462,6 @@ async def test_poll_once_pauses_session_on_expired_errcode() -> None:
|
||||
assert channel._session_pause_remaining_s() > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_once_reloads_refreshed_state_after_session_pause(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._token = "old-token"
|
||||
channel._save_state()
|
||||
(tmp_path / "account.json").write_text(
|
||||
json.dumps({"token": "new-token", "base_url": "https://new.example"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
channel._session_pause_until = time.time() + 10
|
||||
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
|
||||
|
||||
await channel._poll_once()
|
||||
|
||||
assert channel._token == "new-token"
|
||||
assert channel.config.base_url == "https://new.example"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_once_keeps_explicit_token_after_session_pause(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(
|
||||
enabled=True,
|
||||
allow_from=["*"],
|
||||
token="configured-token",
|
||||
state_dir=str(tmp_path),
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._token = "configured-token"
|
||||
(tmp_path / "account.json").write_text(
|
||||
json.dumps({"token": "stale-token", "base_url": "https://stale.example"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
channel._session_pause_until = time.time() + 10
|
||||
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
|
||||
|
||||
await channel._poll_once()
|
||||
|
||||
assert channel._token == "configured-token"
|
||||
assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_login_refreshes_expired_qr_and_then_succeeds(
|
||||
no_qr_poll_delay,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Typer commands for foreground and background gateway control."""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
@@ -133,9 +135,8 @@ def create_gateway_app(
|
||||
console.print()
|
||||
console.print(result.content)
|
||||
|
||||
# Typer consumes these callbacks through decorator registration.
|
||||
@gateway_app.callback(invoke_without_command=True)
|
||||
def gateway( # pyright: ignore[reportUnusedFunction]
|
||||
def gateway(
|
||||
ctx: typer.Context,
|
||||
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||
@@ -190,7 +191,7 @@ def create_gateway_app(
|
||||
)
|
||||
|
||||
@gateway_app.command("status")
|
||||
def gateway_status( # pyright: ignore[reportUnusedFunction]
|
||||
def gateway_status(
|
||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
) -> None:
|
||||
@@ -198,7 +199,7 @@ def create_gateway_app(
|
||||
print_status(runtime_for_instance(workspace=workspace, config=config).status())
|
||||
|
||||
@gateway_app.command("logs")
|
||||
def gateway_logs( # pyright: ignore[reportUnusedFunction]
|
||||
def gateway_logs(
|
||||
tail: int = typer.Option(200, "--tail", help="Number of recent lines to show"),
|
||||
follow: bool = typer.Option(True, "--follow/--no-follow", help="Follow new log output"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||
@@ -216,7 +217,7 @@ def create_gateway_app(
|
||||
console.print(line)
|
||||
|
||||
@gateway_app.command("stop")
|
||||
def gateway_stop( # pyright: ignore[reportUnusedFunction]
|
||||
def gateway_stop(
|
||||
timeout: int = typer.Option(20, "--timeout", help="Stop timeout in seconds"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
@@ -232,7 +233,7 @@ def create_gateway_app(
|
||||
raise typer.Exit(1)
|
||||
|
||||
@gateway_app.command("restart")
|
||||
def gateway_restart( # pyright: ignore[reportUnusedFunction]
|
||||
def gateway_restart(
|
||||
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
@@ -265,7 +266,7 @@ def create_gateway_app(
|
||||
raise typer.Exit(1)
|
||||
|
||||
@gateway_app.command("install-service")
|
||||
def gateway_install_service( # pyright: ignore[reportUnusedFunction]
|
||||
def gateway_install_service(
|
||||
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
@@ -301,7 +302,7 @@ def create_gateway_app(
|
||||
raise typer.Exit(1)
|
||||
|
||||
@gateway_app.command("uninstall-service")
|
||||
def gateway_uninstall_service( # pyright: ignore[reportUnusedFunction]
|
||||
def gateway_uninstall_service(
|
||||
name: str = typer.Option("nanobot-gateway", "--name", help="Service name"),
|
||||
manager: ServiceManagerKind = typer.Option("auto", "--manager", help="auto, systemd, or launchd"),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Print actions without uninstalling"),
|
||||
|
||||
@@ -581,7 +581,6 @@ def _run_gateway(
|
||||
webui_runtime_model_name=_webui_runtime_model_name,
|
||||
webui_cron_pending_job_ids=agent.pending_cron_job_ids_for_session,
|
||||
webui_local_trigger_pending_ids=agent.pending_local_trigger_ids_for_session,
|
||||
webui_cancel_active_turn=getattr(agent, "cancel_active_turn", None),
|
||||
webui_static_dist=webui_static_dist,
|
||||
webui_runtime_surface=webui_runtime_surface,
|
||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||
|
||||
+11
-16
@@ -1,5 +1,7 @@
|
||||
"""Interactive onboarding questionnaire for nanobot."""
|
||||
|
||||
# pyright: reportMissingTypeStubs=false, reportUnusedFunction=false
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import types
|
||||
@@ -204,36 +206,35 @@ def _select_with_back(
|
||||
# Key bindings
|
||||
bindings = KeyBindings()
|
||||
|
||||
# KeyBindings consumes these handlers through decorator registration.
|
||||
@bindings.add(Keys.Up)
|
||||
def _up(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
|
||||
def _up(event: KeyPressEvent) -> None:
|
||||
nonlocal selected_index
|
||||
selected_index = (selected_index - 1) % len(choices)
|
||||
event.app.invalidate()
|
||||
|
||||
@bindings.add(Keys.Down)
|
||||
def _down(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
|
||||
def _down(event: KeyPressEvent) -> None:
|
||||
nonlocal selected_index
|
||||
selected_index = (selected_index + 1) % len(choices)
|
||||
event.app.invalidate()
|
||||
|
||||
@bindings.add(Keys.Enter)
|
||||
def _enter(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
|
||||
def _enter(event: KeyPressEvent) -> None:
|
||||
state["result"] = choices[selected_index]
|
||||
event.app.exit()
|
||||
|
||||
@bindings.add("escape")
|
||||
def _escape(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
|
||||
def _escape(event: KeyPressEvent) -> None:
|
||||
state["result"] = _BACK_PRESSED
|
||||
event.app.exit()
|
||||
|
||||
@bindings.add(Keys.Left)
|
||||
def _left(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
|
||||
def _left(event: KeyPressEvent) -> None:
|
||||
state["result"] = _BACK_PRESSED
|
||||
event.app.exit()
|
||||
|
||||
@bindings.add(Keys.ControlC)
|
||||
def _ctrl_c(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
|
||||
def _ctrl_c(event: KeyPressEvent) -> None:
|
||||
state["result"] = None
|
||||
event.app.exit()
|
||||
|
||||
@@ -531,9 +532,8 @@ def _input_back_key_bindings() -> KeyBindings:
|
||||
"""Return key bindings that make Escape behave like a local back action."""
|
||||
bindings = KeyBindings()
|
||||
|
||||
# KeyBindings consumes this handler through decorator registration.
|
||||
@bindings.add("escape")
|
||||
def _escape(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
|
||||
def _escape(event: KeyPressEvent) -> None:
|
||||
event.app.exit(result=_BACK_PRESSED)
|
||||
|
||||
return bindings
|
||||
@@ -1668,11 +1668,7 @@ def _quick_start_oauth_login(config: Config, provider_name: str) -> bool:
|
||||
return False
|
||||
|
||||
try:
|
||||
# oauth-cli-kit does not publish type information.
|
||||
from oauth_cli_kit import ( # pyright: ignore[reportMissingTypeStubs]
|
||||
get_token,
|
||||
login_oauth_interactive,
|
||||
)
|
||||
from oauth_cli_kit import get_token, login_oauth_interactive
|
||||
except ImportError:
|
||||
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
|
||||
return False
|
||||
@@ -1713,8 +1709,7 @@ def _quick_start_oauth_is_authenticated(config: Config, provider_name: str) -> b
|
||||
if provider_name != "openai_codex":
|
||||
return False
|
||||
try:
|
||||
# oauth-cli-kit does not publish type information.
|
||||
from oauth_cli_kit import get_token # pyright: ignore[reportMissingTypeStubs]
|
||||
from oauth_cli_kit import get_token
|
||||
|
||||
proxy = _quick_start_codex_proxy(config)
|
||||
token = get_token(proxy=proxy)
|
||||
|
||||
@@ -203,7 +203,16 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
||||
"""Cancel all active tasks and subagents for the session."""
|
||||
loop = ctx.loop
|
||||
msg = ctx.msg
|
||||
total = await loop.cancel_active_turn(ctx.key)
|
||||
total = await loop._cancel_active_tasks(ctx.key) # pyright: ignore[reportPrivateUsage]
|
||||
# Also drain pending queue to prevent mid-turn injection deadlock
|
||||
pending = loop._pending_queues.pop(ctx.key, None) # pyright: ignore[reportPrivateUsage]
|
||||
if pending is not None:
|
||||
while not pending.empty():
|
||||
try:
|
||||
pending.get_nowait()
|
||||
total += 1
|
||||
except Exception:
|
||||
break
|
||||
content = f"Stopped {total} task(s)." if total else "No active task to stop."
|
||||
return OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
||||
@@ -292,7 +301,7 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
|
||||
async def cmd_new(ctx: CommandContext) -> OutboundMessage:
|
||||
"""Stop active task and start a fresh session."""
|
||||
loop = ctx.loop
|
||||
await loop.cancel_active_turn(ctx.key)
|
||||
await loop._cancel_active_tasks(ctx.key) # pyright: ignore[reportPrivateUsage]
|
||||
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
||||
snapshot = session.messages[session.last_consolidated:]
|
||||
runtime = None
|
||||
|
||||
@@ -504,7 +504,6 @@ class Config(BaseSettings):
|
||||
model_normalized = model_lower.replace("-", "_")
|
||||
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
||||
normalized_prefix = model_prefix.replace("-", "_")
|
||||
prefixed_provider = find_by_name(model_prefix) if model_prefix else None
|
||||
|
||||
def _kw_matches(kw: str) -> bool:
|
||||
kw = kw.lower()
|
||||
@@ -534,22 +533,6 @@ class Config(BaseSettings):
|
||||
continue
|
||||
p = getattr(self.providers, spec.name, None)
|
||||
if p and any(_kw_matches(kw) for kw in spec.keywords):
|
||||
# Local providers (Ollama, vLLM, …) keep model-family keywords
|
||||
# like "nemotron" or "llama" to enable bare-model auto-routing,
|
||||
# but those keywords collide with cloud-hosted variants of the
|
||||
# same family (e.g. `nvidia/nemotron-...` via OpenRouter). Only
|
||||
# honor a local keyword match when the user has actually
|
||||
# configured that local endpoint via `api_base` — mirrors the
|
||||
# gate already used by the local-fallback loop below.
|
||||
if spec.is_local:
|
||||
# A qualified model belongs to its explicit provider or a
|
||||
# gateway fallback, never to a different local provider
|
||||
# whose model-family keyword happens to match.
|
||||
foreign_prefix = bool(
|
||||
prefixed_provider is not None and prefixed_provider.name != spec.name
|
||||
)
|
||||
if not p.api_base or foreign_prefix:
|
||||
continue
|
||||
if spec.is_oauth or spec.is_local or spec.is_direct or p.api_key:
|
||||
return p, spec.name
|
||||
|
||||
@@ -558,7 +541,6 @@ class Config(BaseSettings):
|
||||
# Prefer providers whose detect_by_base_keyword matches the configured api_base
|
||||
# (e.g. Ollama's "11434" in "http://localhost:11434") over plain registry order.
|
||||
local_fallback: tuple[ProviderConfig, str] | None = None
|
||||
if prefixed_provider is None:
|
||||
for spec in PROVIDERS:
|
||||
if not spec.is_local:
|
||||
continue
|
||||
|
||||
+23
-29
@@ -163,13 +163,9 @@ class CronService:
|
||||
self._store: CronStore | None = None
|
||||
self._timer_task: asyncio.Task[None] | None = None
|
||||
self._running = False
|
||||
self._active_executions = 0
|
||||
self._timer_active = False
|
||||
self.max_sleep_ms = max_sleep_ms
|
||||
|
||||
def _should_persist_store(self) -> bool:
|
||||
"""Return whether this instance currently owns the live store."""
|
||||
return self._running or self._active_executions > 0
|
||||
|
||||
def _is_unbound_agent_job(self, job: CronJob) -> bool:
|
||||
return job.payload.kind == "agent_turn" and not is_bound_cron_job(job)
|
||||
|
||||
@@ -282,24 +278,23 @@ class CronService:
|
||||
logger.exception("load action line error")
|
||||
continue
|
||||
self._store.jobs = list(jobs_map.values()) # pyright: ignore[reportOptionalMemberAccess]
|
||||
if self._should_persist_store() and changed:
|
||||
if self._running and changed:
|
||||
self._action_path.write_text("", encoding="utf-8")
|
||||
self._save_store()
|
||||
return
|
||||
|
||||
def _load_store(self, *, reload_during_execution: bool = False) -> CronStore | None:
|
||||
def _load_store(self) -> CronStore | None:
|
||||
"""Load jobs from disk. Reloads automatically if file was modified externally.
|
||||
- Reload every time because it needs to merge operations on the jobs object from other instances.
|
||||
- During job execution, return the existing store to prevent concurrent
|
||||
- During _on_timer execution, return the existing store to prevent concurrent
|
||||
_load_store calls (e.g. from list_jobs polling) from replacing it mid-execution.
|
||||
The first execution explicitly reloads once when it takes ownership.
|
||||
- When the on-disk store exists but is unreadable: keep using the
|
||||
previous in-memory ``self._store`` if we already have one (so a
|
||||
transient corruption does not drop live jobs); only the very first
|
||||
load (during ``start``) can return ``None`` to signal an unrecoverable
|
||||
state to the caller.
|
||||
"""
|
||||
if self._active_executions > 0 and self._store and not reload_during_execution:
|
||||
if self._timer_active and self._store:
|
||||
return self._store
|
||||
loaded = self._load_jobs()
|
||||
if loaded is None:
|
||||
@@ -312,12 +307,12 @@ class CronService:
|
||||
jobs, version = loaded
|
||||
self._store = CronStore(version=version, jobs=jobs)
|
||||
self._merge_action()
|
||||
if self._enforce_store_agent_bindings() and self._should_persist_store():
|
||||
if self._enforce_store_agent_bindings() and self._running:
|
||||
self._save_store()
|
||||
|
||||
return self._store
|
||||
|
||||
def _require_store(self, *, reload_during_execution: bool = False) -> CronStore:
|
||||
def _require_store(self) -> CronStore:
|
||||
"""Return a usable store or raise a clear error.
|
||||
|
||||
``_load_store`` deliberately returns ``None`` when the first load sees
|
||||
@@ -327,7 +322,7 @@ class CronService:
|
||||
``AttributeError`` and, more importantly, prevents follow-up saves from
|
||||
treating a corrupt store as an empty one.
|
||||
"""
|
||||
store = self._load_store(reload_during_execution=reload_during_execution)
|
||||
store = self._load_store()
|
||||
if store is None:
|
||||
raise RuntimeError(
|
||||
f"cron store at {self.store_path} could not be loaded and was preserved "
|
||||
@@ -509,20 +504,19 @@ class CronService:
|
||||
|
||||
async def _on_timer(self) -> None:
|
||||
"""Handle timer tick - run due jobs."""
|
||||
reload_store = self._active_executions == 0
|
||||
self._active_executions += 1
|
||||
try:
|
||||
store = self._load_store(reload_during_execution=reload_store)
|
||||
self._load_store()
|
||||
# If a hot reload found a corrupt store on disk, ``self._store`` may
|
||||
# still hold the previous, known-good in-memory snapshot. Keep using
|
||||
# it rather than crashing the timer or wiping live jobs.
|
||||
if store is None:
|
||||
if not self._store:
|
||||
self._arm_timer()
|
||||
return
|
||||
|
||||
self._timer_active = True
|
||||
try:
|
||||
now = _now_ms()
|
||||
due_jobs = [
|
||||
j for j in store.jobs
|
||||
j for j in self._store.jobs
|
||||
if j.enabled and j.state.next_run_at_ms and now >= j.state.next_run_at_ms
|
||||
]
|
||||
|
||||
@@ -531,7 +525,7 @@ class CronService:
|
||||
|
||||
self._save_store()
|
||||
finally:
|
||||
self._active_executions -= 1
|
||||
self._timer_active = False
|
||||
self._arm_timer()
|
||||
|
||||
async def _execute_job(self, job: CronJob) -> None:
|
||||
@@ -663,7 +657,7 @@ class CronService:
|
||||
)
|
||||
_normalize_agent_turn_job(job)
|
||||
self._enforce_agent_binding(job)
|
||||
if self._should_persist_store():
|
||||
if self._running:
|
||||
store = self._require_store()
|
||||
store.jobs.append(job)
|
||||
self._save_store()
|
||||
@@ -703,7 +697,7 @@ class CronService:
|
||||
removed = len(store.jobs) < before
|
||||
|
||||
if removed:
|
||||
if self._should_persist_store():
|
||||
if self._running:
|
||||
self._save_store()
|
||||
self._arm_timer()
|
||||
else:
|
||||
@@ -725,7 +719,7 @@ class CronService:
|
||||
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
|
||||
else:
|
||||
job.state.next_run_at_ms = None
|
||||
if self._should_persist_store():
|
||||
if self._running:
|
||||
self._save_store()
|
||||
self._arm_timer()
|
||||
else:
|
||||
@@ -781,7 +775,7 @@ class CronService:
|
||||
else:
|
||||
job.state.next_run_at_ms = None
|
||||
|
||||
if self._should_persist_store():
|
||||
if self._running:
|
||||
self._save_store()
|
||||
self._arm_timer()
|
||||
else:
|
||||
@@ -792,10 +786,10 @@ class CronService:
|
||||
|
||||
async def run_job(self, job_id: str, force: bool = False) -> bool:
|
||||
"""Manually run a job without disturbing the service's running state."""
|
||||
reload_store = self._active_executions == 0
|
||||
self._active_executions += 1
|
||||
was_running = self._running
|
||||
self._running = True
|
||||
try:
|
||||
store = self._require_store(reload_during_execution=reload_store)
|
||||
store = self._require_store()
|
||||
for job in store.jobs:
|
||||
if job.id == job_id:
|
||||
if self._is_unbound_agent_job(job):
|
||||
@@ -809,8 +803,8 @@ class CronService:
|
||||
return True
|
||||
return False
|
||||
finally:
|
||||
self._active_executions -= 1
|
||||
if self._running and self._active_executions == 0:
|
||||
self._running = was_running
|
||||
if was_running:
|
||||
self._arm_timer()
|
||||
|
||||
def get_job(self, job_id: str) -> CronJob | None:
|
||||
|
||||
@@ -958,34 +958,22 @@ class OpenAICompatProvider(LLMProvider):
|
||||
model: str | None,
|
||||
reasoning_effort: str | None,
|
||||
) -> bool:
|
||||
"""Choose Responses for providers/models that explicitly support it."""
|
||||
"""Use Responses API only for direct OpenAI requests that benefit from it."""
|
||||
if self._api_type == "chat_completions":
|
||||
return False
|
||||
spec_name = self._spec.name if self._spec is not None else None
|
||||
model_name = self._request_model_name(model or self.default_model).lower()
|
||||
supported_models = {
|
||||
supported.lower()
|
||||
for supported in getattr(self._spec, "responses_models", ())
|
||||
}
|
||||
model_responses = any(
|
||||
model_name == supported or model_name.endswith(f"/{supported}")
|
||||
for supported in supported_models
|
||||
)
|
||||
provider_responses = spec_name in ("openai", "github_copilot")
|
||||
if not provider_responses and not model_responses:
|
||||
if self._spec and self._spec.name not in ("openai", "github_copilot"):
|
||||
return False
|
||||
if self._api_type == "responses":
|
||||
# Explicit configuration means Responses is mandatory; do not
|
||||
# consult the circuit breaker or fall back to Chat Completions.
|
||||
return True
|
||||
if provider_responses and (self._spec is None or self._spec.name != "github_copilot"):
|
||||
if self._spec is None or self._spec.name != "github_copilot":
|
||||
if not _is_direct_openai_base(self._effective_base):
|
||||
return False
|
||||
|
||||
model_name = (model or self.default_model).lower()
|
||||
wants = False
|
||||
if model_responses:
|
||||
wants = True
|
||||
elif reasoning_effort and reasoning_effort.lower() != "none":
|
||||
if reasoning_effort and reasoning_effort.lower() != "none":
|
||||
wants = True
|
||||
elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")):
|
||||
wants = True
|
||||
@@ -1111,13 +1099,11 @@ class OpenAICompatProvider(LLMProvider):
|
||||
self._sanitize_empty_content(sanitized_state.pending_messages)
|
||||
)
|
||||
)
|
||||
preserve_reasoning = bool(self._spec and self._spec.name == "deepseek")
|
||||
instructions, input_items, replayed = prepare_responses_input(
|
||||
sanitized_messages,
|
||||
state=sanitized_state,
|
||||
provider=self._responses_state_provider(),
|
||||
model=model_name,
|
||||
preserve_reasoning=preserve_reasoning,
|
||||
)
|
||||
|
||||
body: dict[str, Any] = {
|
||||
@@ -1145,7 +1131,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if self._supports_temperature(model_name, reasoning_effort):
|
||||
body["temperature"] = temperature
|
||||
|
||||
if not self._supports_temperature(model_name, reasoning_effort) and not preserve_reasoning:
|
||||
if not self._supports_temperature(model_name, reasoning_effort):
|
||||
body["include"] = ["reasoning.encrypted_content"]
|
||||
if reasoning_effort and reasoning_effort.lower() != "none":
|
||||
body["reasoning"] = {"effort": reasoning_effort}
|
||||
@@ -1841,7 +1827,6 @@ class OpenAICompatProvider(LLMProvider):
|
||||
_timed_stream(),
|
||||
on_content_delta,
|
||||
on_tool_call_delta=on_tool_call_delta,
|
||||
on_reasoning_delta=on_thinking_delta,
|
||||
capture=capture,
|
||||
)
|
||||
self._record_responses_success(model, reasoning_effort)
|
||||
|
||||
@@ -12,11 +12,7 @@ def _as_json_object(value: object) -> dict[str, Any] | None:
|
||||
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def convert_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
preserve_reasoning: bool = False,
|
||||
) -> tuple[str, list[dict[str, Any]]]:
|
||||
def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
||||
"""Convert Chat Completions messages to Responses API input items.
|
||||
|
||||
Returns ``(system_prompt, input_items)`` where *system_prompt* is extracted
|
||||
@@ -40,13 +36,6 @@ def convert_messages(
|
||||
continue
|
||||
|
||||
if role == "assistant":
|
||||
if preserve_reasoning:
|
||||
reasoning = msg.get("reasoning_content")
|
||||
if isinstance(reasoning, str) and reasoning:
|
||||
input_items.append({
|
||||
"type": "reasoning",
|
||||
"content": reasoning,
|
||||
})
|
||||
if isinstance(content, str) and content:
|
||||
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
|
||||
input_items.append({
|
||||
|
||||
@@ -69,9 +69,7 @@ def _response_object(value: object) -> dict[str, Any] | None:
|
||||
return object_value
|
||||
dump = getattr(value, "model_dump", None)
|
||||
if callable(dump):
|
||||
dumped = _as_json_object(dump())
|
||||
if dumped is not None:
|
||||
return dumped
|
||||
return _as_json_object(dump())
|
||||
try:
|
||||
return _as_json_object(vars(value))
|
||||
except TypeError:
|
||||
@@ -446,14 +444,6 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
|
||||
for item in _response_object_list(output):
|
||||
if item.get("type") != "reasoning":
|
||||
continue
|
||||
content = item.get("content")
|
||||
if isinstance(content, str) and content:
|
||||
parts.append(content)
|
||||
elif isinstance(content, list):
|
||||
for block in _response_object_list(cast(list[object], content)):
|
||||
text = block.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
parts.append(text)
|
||||
for summary in _response_object_list(item.get("summary")):
|
||||
if summary.get("type") == "summary_text" and summary.get("text"):
|
||||
text = summary.get("text")
|
||||
@@ -493,8 +483,10 @@ def parse_response_output(
|
||||
if isinstance(refusal, str):
|
||||
content_parts.append(refusal)
|
||||
elif item_type == "reasoning":
|
||||
text = _extract_reasoning_summary_from_output([item])
|
||||
if text:
|
||||
for s in _response_object_list(item.get("summary")):
|
||||
if s.get("type") == "summary_text" and s.get("text"):
|
||||
text = s.get("text")
|
||||
if isinstance(text, str):
|
||||
reasoning_content = (reasoning_content or "") + text
|
||||
elif item_type == "function_call":
|
||||
call_id = item.get("call_id") or ""
|
||||
@@ -540,7 +532,6 @@ async def consume_sdk_stream(
|
||||
stream: Any,
|
||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
capture: ResponsesStreamCapture | None = None,
|
||||
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
||||
"""Consume an SDK async stream from ``client.responses.create(stream=True)``."""
|
||||
@@ -551,7 +542,6 @@ async def consume_sdk_stream(
|
||||
finish_reason = "stop"
|
||||
usage: dict[str, int] = {}
|
||||
reasoning_content: str | None = None
|
||||
streamed_reasoning = False
|
||||
refusal_seen = False
|
||||
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
|
||||
emitted_refusal_text = ""
|
||||
@@ -582,19 +572,6 @@ async def consume_sdk_stream(
|
||||
content += delta_text
|
||||
if on_content_delta and delta_text:
|
||||
await on_content_delta(delta_text)
|
||||
elif event_type == "response.reasoning_text.delta":
|
||||
delta_text = getattr(event, "delta", "") or ""
|
||||
if delta_text:
|
||||
reasoning_content = (reasoning_content or "") + delta_text
|
||||
streamed_reasoning = True
|
||||
if on_reasoning_delta:
|
||||
await on_reasoning_delta(delta_text)
|
||||
elif event_type == "response.reasoning_text.done":
|
||||
text = getattr(event, "text", "") or ""
|
||||
if text and not streamed_reasoning and not reasoning_content:
|
||||
reasoning_content = text
|
||||
if on_reasoning_delta:
|
||||
await on_reasoning_delta(text)
|
||||
elif event_type == "response.refusal.delta":
|
||||
refusal_seen = True
|
||||
delta_text = getattr(event, "delta", None)
|
||||
@@ -712,12 +689,13 @@ async def consume_sdk_stream(
|
||||
"completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0),
|
||||
"total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0),
|
||||
}
|
||||
if not reasoning_content:
|
||||
reasoning_content = _extract_reasoning_summary_from_output(
|
||||
getattr(resp, "output", None)
|
||||
)
|
||||
if reasoning_content and on_reasoning_delta:
|
||||
await on_reasoning_delta(reasoning_content)
|
||||
for out_item in cast(list[Any], getattr(resp, "output", None) or []):
|
||||
if getattr(out_item, "type", None) == "reasoning":
|
||||
for s in cast(list[Any], getattr(out_item, "summary", None) or []):
|
||||
if getattr(s, "type", None) == "summary_text":
|
||||
text = getattr(s, "text", None)
|
||||
if text:
|
||||
reasoning_content = (reasoning_content or "") + text
|
||||
elif event_type in {"error", "response.failed"}:
|
||||
detail = getattr(event, "error", None) or getattr(event, "message", None) or event
|
||||
raise RuntimeError(f"Response failed: {str(detail)[:500]}")
|
||||
|
||||
@@ -43,7 +43,6 @@ def prepare_responses_input(
|
||||
state: ProviderConversationState | None,
|
||||
provider: str,
|
||||
model: str,
|
||||
preserve_reasoning: bool = False,
|
||||
) -> tuple[str, list[dict[str, Any]], bool]:
|
||||
"""Build a request from exact prior items plus only newly appended messages.
|
||||
|
||||
@@ -51,10 +50,7 @@ def prepare_responses_input(
|
||||
When no compatible state exists, it is converted normally as a safe
|
||||
fallback.
|
||||
"""
|
||||
instructions, fallback_items = convert_messages(
|
||||
messages,
|
||||
preserve_reasoning=preserve_reasoning,
|
||||
)
|
||||
instructions, fallback_items = convert_messages(messages)
|
||||
if state is None or not responses_state_matches(
|
||||
state,
|
||||
provider=provider,
|
||||
@@ -66,10 +62,7 @@ def prepare_responses_input(
|
||||
if prior_items is None:
|
||||
return instructions, fallback_items, False
|
||||
|
||||
_, delta_items = convert_messages(
|
||||
state.pending_messages,
|
||||
preserve_reasoning=preserve_reasoning,
|
||||
)
|
||||
_, delta_items = convert_messages(state.pending_messages)
|
||||
logger.debug(
|
||||
"Replaying Responses state: prior_items={} pending_messages={}",
|
||||
len(prior_items),
|
||||
|
||||
@@ -111,11 +111,6 @@ class ProviderSpec:
|
||||
# Substring match against the wire model name (lowercased).
|
||||
implicit_reasoning_models: tuple[str, ...] = ()
|
||||
|
||||
# Models that expose the OpenAI Responses wire format. This is model-level
|
||||
# because providers may add Responses support incrementally (DeepSeek V4
|
||||
# Flash is supported before V4 Pro).
|
||||
responses_models: tuple[str, ...] = ()
|
||||
|
||||
# When the model returns content as a list of {"type":"thinking",...} +
|
||||
# {"type":"text",...} blocks, extract the thinking text into
|
||||
# reasoning_content. Mistral's Magistral / reasoning-enabled responses use
|
||||
@@ -466,7 +461,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
backend="openai_compat",
|
||||
default_api_base="https://api.deepseek.com",
|
||||
thinking_style="thinking_type",
|
||||
responses_models=("deepseek-v4-flash",),
|
||||
),
|
||||
# Gemini: Google's OpenAI-compatible endpoint
|
||||
ProviderSpec(
|
||||
|
||||
@@ -157,7 +157,6 @@ class Session:
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
last_consolidated: int = 0 # Number of messages already consolidated to files
|
||||
provider_state: ProviderConversationState | None = field(default=None, repr=False)
|
||||
transient: bool = field(default=False, repr=False, compare=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(cast(object, self.metadata), dict):
|
||||
@@ -965,7 +964,6 @@ class SessionManager:
|
||||
self._cache: OrderedDict[str, Session] = OrderedDict()
|
||||
# Preserve identity for sessions held by active callers without retaining idle ones.
|
||||
self._overflow_cache: WeakValueDictionary[str, Session] = WeakValueDictionary()
|
||||
self._transient_sessions: dict[str, Session] = {}
|
||||
self._max_cached_sessions = SESSION_CACHE_MAX_SIZE
|
||||
self._file_cap_archiver: Callable[..., None] | None = None
|
||||
|
||||
@@ -979,10 +977,6 @@ class SessionManager:
|
||||
self._overflow_cache[key] = evicted
|
||||
|
||||
def _cached(self, key: str) -> Session | None:
|
||||
transient = self._transient_sessions.get(key)
|
||||
if transient is not None:
|
||||
return transient
|
||||
|
||||
session = self._cache.get(key)
|
||||
if session is not None:
|
||||
self._cache.move_to_end(key)
|
||||
@@ -1059,24 +1053,6 @@ class SessionManager:
|
||||
self._remember(session)
|
||||
return session
|
||||
|
||||
def get_or_create_transient(self, key: str) -> Session:
|
||||
"""Return an active in-memory session that can never reach the store."""
|
||||
session = self._transient_sessions.get(key)
|
||||
if session is None:
|
||||
self._cache.pop(key, None)
|
||||
self._overflow_cache.pop(key, None)
|
||||
session = Session(key=key, transient=True)
|
||||
self._transient_sessions[key] = session
|
||||
return session
|
||||
|
||||
def is_transient_active(self, key: str) -> bool:
|
||||
"""Return whether *key* still accepts transient turns."""
|
||||
return key in self._transient_sessions
|
||||
|
||||
def discard_transient(self, key: str) -> bool:
|
||||
"""Forget all transient contents without retaining a discarded-key tombstone."""
|
||||
return self._transient_sessions.pop(key, None) is not None
|
||||
|
||||
def _load(self, key: str) -> Session | None:
|
||||
return self._store.load(key)
|
||||
|
||||
@@ -1090,9 +1066,6 @@ class SessionManager:
|
||||
|
||||
def save(self, session: Session, *, fsync: bool = False) -> None:
|
||||
"""Persist a session and retain it in the cache."""
|
||||
if session.transient is True:
|
||||
return
|
||||
|
||||
archiver = self._file_cap_archiver
|
||||
if archiver is not None:
|
||||
session.enforce_file_cap(
|
||||
@@ -1125,7 +1098,6 @@ class SessionManager:
|
||||
|
||||
def invalidate(self, key: str) -> None:
|
||||
"""Remove a session from the in-memory cache."""
|
||||
self._transient_sessions.pop(key, None)
|
||||
self._cache.pop(key, None)
|
||||
self._overflow_cache.pop(key, None)
|
||||
|
||||
|
||||
@@ -334,16 +334,6 @@ def clear_websocket_turn_if_current(
|
||||
return False
|
||||
|
||||
|
||||
def clear_websocket_turns(chat_id: str) -> int:
|
||||
"""Clear every in-memory lifecycle owner for a discarded chat."""
|
||||
turns = _WEBSOCKET_ACTIVE_TURNS.pop(chat_id, None)
|
||||
count = len(turns) if turns is not None else 0
|
||||
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(chat_id, None)
|
||||
_WEBSOCKET_TURN_IDS.pop(chat_id, None)
|
||||
_WEBSOCKET_TURN_OWNERS.pop(chat_id, None)
|
||||
return count
|
||||
|
||||
|
||||
def build_bus_progress_callback(
|
||||
bus: MessageBus,
|
||||
msg: InboundMessage,
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from loguru import logger as default_logger
|
||||
|
||||
@@ -39,7 +38,6 @@ class GatewayServices:
|
||||
local_trigger_store: LocalTriggerStore | None
|
||||
cron_pending_job_ids: Callable[[str], set[str]] | None
|
||||
local_trigger_pending_ids: Callable[[str], set[str]] | None
|
||||
cancel_active_turn: Callable[[str], Awaitable[int]] | None
|
||||
|
||||
|
||||
def build_gateway_services(
|
||||
@@ -58,7 +56,6 @@ def build_gateway_services(
|
||||
local_trigger_store: LocalTriggerStore | None = None,
|
||||
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
||||
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||
cancel_active_turn: Callable[[str], Awaitable[int]] | None = None,
|
||||
channel_feature_action: Callable[..., Any] | None = None,
|
||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||
@@ -120,5 +117,4 @@ def build_gateway_services(
|
||||
local_trigger_store=local_trigger_store,
|
||||
cron_pending_job_ids=cron_pending_job_ids,
|
||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
||||
cancel_active_turn=cancel_active_turn,
|
||||
)
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ dependencies = [
|
||||
"filelock>=3.25.2",
|
||||
"watchfiles>=1.1.1,<2.0.0",
|
||||
"packaging>=24.0",
|
||||
"tzdata>=2025.2",
|
||||
"tzdata>=2025.2; sys_platform == 'win32'",
|
||||
"defusedxml>=0.7.1,<1.0.0",
|
||||
"pypdf>=5.0.0,<6.0.0",
|
||||
"python-docx>=1.1.0,<2.0.0",
|
||||
|
||||
@@ -592,58 +592,6 @@ class TestPrepareSession:
|
||||
assert summary is not None
|
||||
assert "Cold summary." in summary
|
||||
|
||||
def test_cold_path_tolerates_malformed_last_active(self):
|
||||
"""A malformed persisted last_active must not raise on the turn path.
|
||||
|
||||
prepare_session runs from _compact_session on every turn. Persisted
|
||||
_last_summary can be hand-edited or written by another version, so a bad
|
||||
last_active should degrade gracefully (mirror estimate_session_prompt_tokens
|
||||
and _archive) instead of crashing the turn.
|
||||
"""
|
||||
ac = _make_autocompact(ttl=0)
|
||||
fallback = datetime(2026, 1, 2, 3, 4, 5)
|
||||
session = _make_session(
|
||||
metadata={
|
||||
"_last_summary": {"text": "Cold summary.", "last_active": "not-a-date"},
|
||||
},
|
||||
updated_at=fallback,
|
||||
)
|
||||
|
||||
result_session, summary = ac.prepare_session(session, "cli:test")
|
||||
|
||||
assert result_session is session
|
||||
assert summary is not None
|
||||
assert "Cold summary." in summary
|
||||
assert fallback.isoformat() in summary
|
||||
|
||||
def test_cold_path_tolerates_missing_last_active(self):
|
||||
"""A _last_summary dict without last_active must not raise."""
|
||||
ac = _make_autocompact(ttl=0)
|
||||
fallback = datetime(2026, 1, 2, 3, 4, 5)
|
||||
session = _make_session(
|
||||
metadata={"_last_summary": {"text": "Cold summary."}},
|
||||
updated_at=fallback,
|
||||
)
|
||||
|
||||
result_session, summary = ac.prepare_session(session, "cli:test")
|
||||
|
||||
assert result_session is session
|
||||
assert summary is not None
|
||||
assert "Cold summary." in summary
|
||||
assert fallback.isoformat() in summary
|
||||
|
||||
def test_cold_path_missing_text_returns_none(self):
|
||||
"""A _last_summary without a non-empty string text yields no summary."""
|
||||
ac = _make_autocompact()
|
||||
session = _make_session(metadata={
|
||||
"_last_summary": {"last_active": datetime(2026, 1, 1).isoformat()},
|
||||
})
|
||||
|
||||
result_session, summary = ac.prepare_session(session, "cli:test")
|
||||
|
||||
assert result_session is session
|
||||
assert summary is None
|
||||
|
||||
def test_no_summary_available_returns_none(self):
|
||||
"""When no summary is available, should return (session, None)."""
|
||||
ac = _make_autocompact()
|
||||
|
||||
@@ -15,20 +15,6 @@ def _builder(tmp_path: Path, **kw) -> ContextBuilder:
|
||||
return ContextBuilder(workspace=tmp_path, **kw)
|
||||
|
||||
|
||||
def test_conversation_only_messages_omit_the_system_prompt(tmp_path) -> None:
|
||||
(tmp_path / "AGENTS.md").write_text("SECRET PROJECT INSTRUCTIONS", encoding="utf-8")
|
||||
builder = _builder(tmp_path)
|
||||
|
||||
messages = builder.build_messages(
|
||||
[],
|
||||
"hello",
|
||||
conversation_only=True,
|
||||
)
|
||||
|
||||
assert messages == [{"role": "user", "content": "hello"}]
|
||||
assert "SECRET PROJECT INSTRUCTIONS" not in str(messages)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _merge_message_content (static)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -579,21 +579,3 @@ def test_history_skips_non_dict_jsonl_lines(tmp_path: Path) -> None:
|
||||
}]
|
||||
next_cursor = memory.append_history("next", session_key="cli:t")
|
||||
assert next_cursor == 2
|
||||
|
||||
def test_raw_archive_handles_none_timestamp_and_missing_role(tmp_path: Path) -> None:
|
||||
"""raw_archive and _format_messages must safely format messages with None timestamp or missing role.
|
||||
|
||||
Prevents TypeError on NoneType[:16] slicing and KeyError on missing 'role'
|
||||
when raw-dumping unconsolidated history entries without timestamps or role fields.
|
||||
"""
|
||||
memory = MemoryStore(tmp_path)
|
||||
messages = [
|
||||
{"content": "message with none timestamp", "timestamp": None, "role": "user"},
|
||||
{"content": "message with int timestamp", "timestamp": 1720000000, "role": "assistant"},
|
||||
{"content": "message with missing role", "timestamp": "2026-07-28T12:00:00"},
|
||||
]
|
||||
memory.raw_archive(messages, session_key="cli:test")
|
||||
raw_history = memory.history_file.read_text(encoding="utf-8")
|
||||
assert "[?] USER: message with none timestamp" in raw_history
|
||||
assert "[1720000000] ASSISTANT: message with int timestamp" in raw_history
|
||||
assert "[2026-07-28T12:00] UNKNOWN: message with missing role" in raw_history
|
||||
|
||||
@@ -111,50 +111,8 @@ class TestHandleStop:
|
||||
assert all(e.is_set() for e in events)
|
||||
assert "2 task" in out.content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_active_turn_discards_pending_followups(self):
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
loop, _ = _make_loop()
|
||||
pending = asyncio.Queue()
|
||||
pending.put_nowait(
|
||||
InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="next")
|
||||
)
|
||||
loop._pending_queues["test:c1"] = pending
|
||||
|
||||
assert await loop.cancel_active_turn("test:c1") == 1
|
||||
assert "test:c1" not in loop._pending_queues
|
||||
|
||||
|
||||
class TestDispatch:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_drops_deactivated_transient_message(self):
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
loop, bus = _make_loop()
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u1",
|
||||
chat_id="temporary-test",
|
||||
content="private",
|
||||
session_key_override="websocket:temporary-test",
|
||||
transient_session=True,
|
||||
)
|
||||
|
||||
async def consume_once():
|
||||
loop.stop()
|
||||
return msg
|
||||
|
||||
bus.consume_inbound = AsyncMock(side_effect=consume_once)
|
||||
loop.sessions.is_transient_active.return_value = False
|
||||
loop._dispatch = AsyncMock()
|
||||
loop.close_mcp = AsyncMock()
|
||||
loop._running = True
|
||||
|
||||
await loop.run()
|
||||
|
||||
loop._dispatch.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_logs_and_continues_after_leaked_cancelled_error(self, monkeypatch):
|
||||
loop, bus = _make_loop()
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import GenerationSettings, LLMResponse
|
||||
from nanobot.runtime_context import RuntimeContextBlock
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_chat_reuses_memory_only_history_without_tools(tmp_path) -> None:
|
||||
(tmp_path / "AGENTS.md").write_text("private project instruction", encoding="utf-8")
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation = GenerationSettings()
|
||||
provider.chat_with_retry = AsyncMock(
|
||||
side_effect=[
|
||||
LLMResponse(content="first answer", usage={}),
|
||||
LLMResponse(content="second answer", usage={}),
|
||||
]
|
||||
)
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
unified_session=True,
|
||||
)
|
||||
key = "websocket:temporary-test"
|
||||
loop.sessions.get_or_create_transient(key)
|
||||
|
||||
for content in ("first question", "second question"):
|
||||
response = await loop._process_message(
|
||||
InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="temporary-test",
|
||||
content=content,
|
||||
session_key_override=key,
|
||||
transient_session=True,
|
||||
)
|
||||
)
|
||||
assert response is not None
|
||||
|
||||
first_call, second_call = provider.chat_with_retry.await_args_list
|
||||
assert first_call.kwargs["tools"] == []
|
||||
assert second_call.kwargs["tools"] == []
|
||||
assert all(
|
||||
message["role"] != "system"
|
||||
for call in (first_call, second_call)
|
||||
for message in call.kwargs["messages"]
|
||||
)
|
||||
assert "private project instruction" not in str(first_call.kwargs["messages"])
|
||||
assert str(tmp_path) not in str(first_call.kwargs["messages"])
|
||||
assert "first answer" in str(second_call.kwargs["messages"])
|
||||
|
||||
transient = loop.sessions.get_cached(key)
|
||||
assert transient is not None
|
||||
assert [message["role"] for message in transient.messages] == [
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
]
|
||||
assert loop.sessions.read_session_file(key) is None
|
||||
assert SessionManager(tmp_path).read_session_file(key) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_follow_up_does_not_resolve_runtime_context(tmp_path) -> None:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation = GenerationSettings()
|
||||
provider.chat_with_retry = AsyncMock(
|
||||
side_effect=[
|
||||
LLMResponse(content="first answer", usage={}),
|
||||
LLMResponse(content="second answer", usage={}),
|
||||
]
|
||||
)
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
runtime_context_provider = AsyncMock(
|
||||
return_value=RuntimeContextBlock(
|
||||
source="project",
|
||||
content="SECRET LOCAL PROJECT CONTEXT",
|
||||
)
|
||||
)
|
||||
loop.register_runtime_context_provider(runtime_context_provider)
|
||||
|
||||
key = "websocket:temporary-follow-up"
|
||||
session = loop.sessions.get_or_create_transient(key)
|
||||
pending_queue: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
||||
await pending_queue.put(
|
||||
InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="temporary-follow-up",
|
||||
content="follow up",
|
||||
session_key_override=key,
|
||||
transient_session=True,
|
||||
)
|
||||
)
|
||||
|
||||
_, _, messages, _, _ = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "first question"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
session=session,
|
||||
channel="websocket",
|
||||
chat_id="temporary-follow-up",
|
||||
session_key=key,
|
||||
pending_queue=pending_queue,
|
||||
tools=ToolRegistry(),
|
||||
)
|
||||
|
||||
runtime_context_provider.assert_not_awaited()
|
||||
assert "SECRET LOCAL PROJECT CONTEXT" not in str(messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discarding_active_temporary_chat_does_not_create_durable_session(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
provider_started = asyncio.Event()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation = GenerationSettings()
|
||||
|
||||
async def block_provider(**_kwargs):
|
||||
provider_started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
provider.chat_with_retry = AsyncMock(side_effect=block_provider)
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
key = "websocket:temporary-cancelled"
|
||||
loop.sessions.get_or_create_transient(key)
|
||||
message = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="temporary-cancelled",
|
||||
content="private",
|
||||
session_key_override=key,
|
||||
transient_session=True,
|
||||
)
|
||||
task = asyncio.create_task(loop._dispatch(message))
|
||||
active_tasks = loop._active_tasks.setdefault(key, set())
|
||||
active_tasks.add(task)
|
||||
task.add_done_callback(active_tasks.discard)
|
||||
|
||||
await provider_started.wait()
|
||||
assert loop.sessions.discard_transient(key)
|
||||
assert await loop.cancel_active_turn(key) == 1
|
||||
|
||||
assert loop.sessions.get_cached(key) is None
|
||||
assert loop.sessions.flush_all() == 0
|
||||
assert loop.sessions.read_session_file(key) is None
|
||||
@@ -253,7 +253,7 @@ class TestCmdNewUnifiedSession:
|
||||
loop = SimpleNamespace(
|
||||
sessions=sessions,
|
||||
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
||||
cancel_active_turn=AsyncMock(return_value=0),
|
||||
_cancel_active_tasks=AsyncMock(return_value=0),
|
||||
llm_runtime=MagicMock(return_value=MagicMock()),
|
||||
schedule_background=lambda coro: asyncio.ensure_future(coro),
|
||||
)
|
||||
@@ -301,7 +301,7 @@ class TestCmdNewUnifiedSession:
|
||||
loop = SimpleNamespace(
|
||||
sessions=sessions,
|
||||
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
||||
cancel_active_turn=AsyncMock(return_value=0),
|
||||
_cancel_active_tasks=AsyncMock(return_value=0),
|
||||
runtime_for_session=MagicMock(return_value=MagicMock()),
|
||||
schedule_background=lambda coro: asyncio.ensure_future(coro),
|
||||
)
|
||||
|
||||
@@ -2558,7 +2558,7 @@ def test_optional_dependency_metadata_for_enable():
|
||||
):
|
||||
assert not any(dep.startswith(dep_name) for dep in required)
|
||||
for dependency in (
|
||||
"tzdata>=2025.2",
|
||||
"tzdata>=2025.2; sys_platform == 'win32'",
|
||||
"defusedxml>=0.7.1,<1.0.0",
|
||||
"pypdf>=5.0.0,<6.0.0",
|
||||
"python-docx>=1.1.0,<2.0.0",
|
||||
|
||||
@@ -1160,63 +1160,6 @@ def test_config_falls_back_to_vllm_when_ollama_not_configured():
|
||||
assert config.get_api_base() == "http://localhost:8000"
|
||||
|
||||
|
||||
def test_config_cloud_nemotron_is_not_hijacked_by_unconfigured_ollama():
|
||||
"""`nvidia/nemotron-*` via a gateway must not route to Ollama when no
|
||||
Ollama endpoint is configured. Ollama keeps "nemotron" in its keywords
|
||||
for bare-model auto-routing (PR #1863), which previously hijacked
|
||||
cloud-hosted nemotron variants and silently sent traffic to
|
||||
http://localhost:11434/v1."""
|
||||
config = Config.model_validate(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "auto",
|
||||
"model": "nvidia/nemotron-3-super-120b-a12b",
|
||||
}
|
||||
},
|
||||
"providers": {"openrouter": {"apiKey": "sk-or-test"}},
|
||||
}
|
||||
)
|
||||
|
||||
assert config.get_provider_name() == "openrouter"
|
||||
assert config.get_api_base() == "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
def test_config_bare_nemotron_still_auto_routes_to_configured_ollama():
|
||||
"""Preserves PR #1863 intent: when the user has actually configured an
|
||||
Ollama endpoint, a bare nemotron model still auto-routes there."""
|
||||
config = Config.model_validate(
|
||||
{
|
||||
"agents": {"defaults": {"provider": "auto", "model": "nemotron-3-nano"}},
|
||||
"providers": {"ollama": {"apiBase": "http://localhost:11434/v1"}},
|
||||
}
|
||||
)
|
||||
|
||||
assert config.get_provider_name() == "ollama"
|
||||
assert config.get_api_base() == "http://localhost:11434/v1"
|
||||
|
||||
|
||||
def test_config_cloud_nemotron_is_not_hijacked_by_configured_ollama():
|
||||
"""An explicit cloud namespace takes precedence over local keywords."""
|
||||
config = Config.model_validate(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "auto",
|
||||
"model": "nvidia/nemotron-3-super-120b-a12b",
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"ollama": {"apiBase": "http://localhost:11434/v1"},
|
||||
"openrouter": {"apiKey": "sk-or-test"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert config.get_provider_name() == "openrouter"
|
||||
assert config.get_api_base() == "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
def test_openai_compat_provider_passes_model_through():
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ class TestMidTurnCommandDispatchedDirectly:
|
||||
loop.sessions.save = MagicMock()
|
||||
loop.sessions.invalidate = MagicMock()
|
||||
loop.schedule_background = MagicMock()
|
||||
loop.cancel_active_turn = AsyncMock(return_value=0)
|
||||
loop._cancel_active_tasks = AsyncMock(return_value=0)
|
||||
return loop
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test cmd_stop drains pending queue to prevent mid-turn injection deadlock."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -13,7 +14,13 @@ from nanobot.command.router import CommandContext
|
||||
async def test_cmd_stop_drains_pending_queue():
|
||||
"""cmd_stop should drain pending queue in addition to cancelling active tasks."""
|
||||
mock_loop = MagicMock()
|
||||
mock_loop.cancel_active_turn = AsyncMock(return_value=3)
|
||||
mock_loop._cancel_active_tasks = AsyncMock(return_value=1)
|
||||
mock_loop._pending_queues = {}
|
||||
|
||||
pending = asyncio.Queue()
|
||||
await pending.put("msg1")
|
||||
await pending.put("msg2")
|
||||
mock_loop._pending_queues["test-session"] = pending
|
||||
|
||||
ctx = CommandContext(
|
||||
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
||||
@@ -27,14 +34,18 @@ async def test_cmd_stop_drains_pending_queue():
|
||||
|
||||
assert isinstance(result, OutboundMessage)
|
||||
assert "Stopped 3 task(s)" in result.content # 1 cancelled + 2 drained
|
||||
mock_loop.cancel_active_turn.assert_awaited_once_with("test-session")
|
||||
assert "test-session" not in mock_loop._pending_queues
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cmd_stop_with_empty_pending_queue():
|
||||
"""cmd_stop should work correctly when pending queue is empty."""
|
||||
mock_loop = MagicMock()
|
||||
mock_loop.cancel_active_turn = AsyncMock(return_value=2)
|
||||
mock_loop._cancel_active_tasks = AsyncMock(return_value=2)
|
||||
mock_loop._pending_queues = {}
|
||||
|
||||
pending = asyncio.Queue()
|
||||
mock_loop._pending_queues["test-session"] = pending
|
||||
|
||||
ctx = CommandContext(
|
||||
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
||||
@@ -47,14 +58,15 @@ async def test_cmd_stop_with_empty_pending_queue():
|
||||
result = await cmd_stop(ctx)
|
||||
|
||||
assert "Stopped 2 task(s)" in result.content
|
||||
mock_loop.cancel_active_turn.assert_awaited_once_with("test-session")
|
||||
assert "test-session" not in mock_loop._pending_queues
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cmd_stop_no_pending_queue():
|
||||
"""cmd_stop should work when no pending queue exists."""
|
||||
mock_loop = MagicMock()
|
||||
mock_loop.cancel_active_turn = AsyncMock(return_value=0)
|
||||
mock_loop._cancel_active_tasks = AsyncMock(return_value=0)
|
||||
mock_loop._pending_queues = {}
|
||||
|
||||
ctx = CommandContext(
|
||||
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
@@ -46,32 +42,6 @@ def test_agent_timezone_rejects_unknown_iana_name() -> None:
|
||||
Config.model_validate({"agents": {"defaults": {"timezone": "Not/AZone"}}})
|
||||
|
||||
|
||||
def test_agent_timezones_use_packaged_data_without_system_database() -> None:
|
||||
script = textwrap.dedent(
|
||||
"""\
|
||||
from zoneinfo import TZPATH
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
assert not TZPATH
|
||||
for name in ("UTC", "Asia/Shanghai"):
|
||||
config = Config.model_validate({"agents": {"defaults": {"timezone": name}}})
|
||||
serialized = config.model_dump(mode="json", by_alias=True)
|
||||
restored = Config.model_validate(serialized)
|
||||
assert restored.agents.defaults.timezone == name
|
||||
"""
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
env=os.environ | {"PYTHONTZPATH": ""},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_provider_api_type_accepts_exact_values_only() -> None:
|
||||
config = Config.model_validate({
|
||||
"providers": {
|
||||
|
||||
@@ -600,117 +600,6 @@ async def test_run_job_preserves_running_service_state(tmp_path) -> None:
|
||||
service.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_run_persists_completion_when_callback_lists_jobs(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
|
||||
async def on_job(_job) -> None:
|
||||
service.list_jobs(include_disabled=True)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
service = CronService(store_path, on_job=on_job)
|
||||
job = service.add_job(
|
||||
name="manual",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
|
||||
assert await service.run_job(job.id) is True
|
||||
|
||||
state = json.loads(store_path.read_text())["jobs"][0]["state"]
|
||||
assert state["lastStatus"] == "ok"
|
||||
assert state["lastError"] is None
|
||||
assert len(state["runHistory"]) == 1
|
||||
assert state["runHistory"][0]["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overlapping_manual_runs_preserve_stopped_service_state(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
entered = [asyncio.Event(), asyncio.Event()]
|
||||
release = [asyncio.Event(), asyncio.Event()]
|
||||
call_count = 0
|
||||
|
||||
async def on_job(_job) -> None:
|
||||
nonlocal call_count
|
||||
call_index = call_count
|
||||
call_count += 1
|
||||
entered[call_index].set()
|
||||
await release[call_index].wait()
|
||||
|
||||
service = CronService(store_path, on_job=on_job)
|
||||
jobs = [
|
||||
service.add_job(
|
||||
name=f"manual-{index}",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
**_bound_chat(str(index)),
|
||||
)
|
||||
for index in range(2)
|
||||
]
|
||||
|
||||
first = asyncio.create_task(service.run_job(jobs[0].id))
|
||||
await entered[0].wait()
|
||||
second = asyncio.create_task(service.run_job(jobs[1].id))
|
||||
try:
|
||||
await entered[1].wait()
|
||||
release[0].set()
|
||||
assert await first is True
|
||||
assert service._running is False
|
||||
|
||||
release[1].set()
|
||||
assert await second is True
|
||||
assert service._running is False
|
||||
assert service._timer_task is None
|
||||
|
||||
states = {
|
||||
item["name"]: item["state"]
|
||||
for item in json.loads(store_path.read_text())["jobs"]
|
||||
}
|
||||
assert states["manual-0"]["lastStatus"] == "ok"
|
||||
assert states["manual-1"]["lastStatus"] == "ok"
|
||||
finally:
|
||||
release[0].set()
|
||||
release[1].set()
|
||||
await asyncio.gather(first, second, return_exceptions=True)
|
||||
service.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_run_does_not_restart_service_stopped_during_execution(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
entered = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def on_job(_job) -> None:
|
||||
entered.set()
|
||||
await release.wait()
|
||||
|
||||
service = CronService(store_path, on_job=on_job)
|
||||
job = service.add_job(
|
||||
name="manual-stop",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
**_bound_chat(),
|
||||
)
|
||||
await service.start()
|
||||
|
||||
run = asyncio.create_task(service.run_job(job.id))
|
||||
try:
|
||||
await entered.wait()
|
||||
service.stop()
|
||||
release.set()
|
||||
|
||||
assert await run is True
|
||||
assert service._running is False
|
||||
assert service._timer_task is None
|
||||
finally:
|
||||
release.set()
|
||||
await asyncio.gather(run, return_exceptions=True)
|
||||
service.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_running_service_honors_external_disable(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
|
||||
@@ -150,22 +150,6 @@ class TestConvertMessages:
|
||||
assert items[0]["content"][0]["type"] == "output_text"
|
||||
assert items[0]["content"][0]["text"] == "I'll help"
|
||||
|
||||
def test_preserves_deepseek_reasoning_content(self):
|
||||
_, items = convert_messages([
|
||||
{"role": "assistant", "reasoning_content": "think first", "content": "answer"},
|
||||
], preserve_reasoning=True)
|
||||
|
||||
assert items == [
|
||||
{"type": "reasoning", "content": "think first"},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "answer"}],
|
||||
"status": "completed",
|
||||
"id": "msg_0",
|
||||
},
|
||||
]
|
||||
|
||||
def test_assistant_empty_content_skipped(self):
|
||||
_, items = convert_messages([{"role": "assistant", "content": ""}])
|
||||
assert len(items) == 0
|
||||
@@ -555,22 +539,6 @@ class TestParseResponseOutput:
|
||||
assert result.content == "42"
|
||||
assert result.reasoning_content == "I think therefore I am."
|
||||
|
||||
def test_deepseek_reasoning_content_extracted(self):
|
||||
resp = {
|
||||
"output": [
|
||||
{"type": "reasoning", "content": "think first"},
|
||||
{"type": "message", "content": [
|
||||
{"type": "output_text", "text": "answer"},
|
||||
]},
|
||||
],
|
||||
"status": "completed", "usage": {},
|
||||
}
|
||||
|
||||
result = parse_response_output(resp)
|
||||
|
||||
assert result.content == "answer"
|
||||
assert result.reasoning_content == "think first"
|
||||
|
||||
def test_empty_output(self):
|
||||
resp = {"output": [], "status": "completed", "usage": {}}
|
||||
result = parse_response_output(resp)
|
||||
@@ -1665,30 +1633,6 @@ class TestConsumeSdkStream:
|
||||
_, _, _, _, reasoning = await consume_sdk_stream(stream())
|
||||
assert reasoning == "thinking..."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_reasoning_text_streamed(self):
|
||||
events = [
|
||||
MagicMock(type="response.reasoning_text.delta", delta="step 1 "),
|
||||
MagicMock(type="response.reasoning_text.delta", delta="step 2"),
|
||||
MagicMock(type="response.reasoning_text.done", text="step 1 step 2"),
|
||||
]
|
||||
emitted: list[str] = []
|
||||
|
||||
async def stream():
|
||||
for event in events:
|
||||
yield event
|
||||
|
||||
async def on_reasoning_delta(delta: str) -> None:
|
||||
emitted.append(delta)
|
||||
|
||||
_, _, _, _, reasoning = await consume_sdk_stream(
|
||||
stream(),
|
||||
on_reasoning_delta=on_reasoning_delta,
|
||||
)
|
||||
|
||||
assert reasoning == "step 1 step 2"
|
||||
assert emitted == ["step 1 ", "step 2"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_event_raises(self):
|
||||
ev = MagicMock(type="error", error="rate_limit_exceeded")
|
||||
|
||||
@@ -29,32 +29,6 @@ def test_responses_api_available_by_default(provider):
|
||||
assert provider._should_use_responses_api("gpt-5", None) is True
|
||||
|
||||
|
||||
def test_deepseek_v4_flash_uses_responses_by_model(provider):
|
||||
provider._spec = type("Spec", (), {
|
||||
"name": "deepseek",
|
||||
"responses_models": ("deepseek-v4-flash",),
|
||||
"strip_model_prefix": False,
|
||||
"strip_model_prefixes": (),
|
||||
})()
|
||||
provider._effective_base = "https://api.deepseek.com"
|
||||
provider.default_model = "deepseek-v4-flash"
|
||||
|
||||
assert provider._should_use_responses_api("deepseek-v4-flash", None) is True
|
||||
assert provider._should_use_responses_api("deepseek-v4-pro", None) is False
|
||||
|
||||
|
||||
def test_deepseek_v4_flash_matches_provider_prefixed_model(provider):
|
||||
provider._spec = type("Spec", (), {
|
||||
"name": "deepseek",
|
||||
"responses_models": ("deepseek-v4-flash",),
|
||||
"strip_model_prefix": False,
|
||||
"strip_model_prefixes": (),
|
||||
})()
|
||||
provider._effective_base = "https://api.deepseek.com"
|
||||
|
||||
assert provider._should_use_responses_api("deepseek/deepseek-v4-flash", None) is True
|
||||
|
||||
|
||||
def test_direct_openai_enables_server_compaction(provider):
|
||||
provider._extra_body = {}
|
||||
|
||||
|
||||
@@ -73,23 +73,3 @@ def test_flush_all_includes_live_sessions_outside_strong_cache(tmp_path, monkeyp
|
||||
|
||||
assert manager.flush_all() == 2
|
||||
assert set(saved) == {("test:active", True), ("test:other", True)}
|
||||
|
||||
|
||||
def test_transient_session_never_reaches_store(tmp_path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create_transient("websocket:temporary-test")
|
||||
session.add_message("user", "private")
|
||||
|
||||
manager.save(session, fsync=True)
|
||||
|
||||
assert manager.get_cached(session.key) is session
|
||||
assert manager.read_session_file(session.key) is None
|
||||
|
||||
|
||||
def test_transient_session_becomes_inactive_when_discarded(tmp_path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create_transient("websocket:temporary-test")
|
||||
|
||||
assert manager.discard_transient(session.key) is True
|
||||
assert manager.is_transient_active(session.key) is False
|
||||
assert manager.get_cached(session.key) is None
|
||||
|
||||
@@ -16,13 +16,11 @@ from nanobot.agent import context as agent_context
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
|
||||
from nanobot.agent.tools.exec_session import (
|
||||
MAX_OUTPUT_CHARS,
|
||||
ExecSessionManager,
|
||||
ListExecSessionsTool,
|
||||
WriteStdinTool,
|
||||
_BoundedOutputBuffer,
|
||||
_SessionPoll,
|
||||
_truncate_output,
|
||||
)
|
||||
from nanobot.agent.tools.registry import is_tool_error_result
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
@@ -229,52 +227,6 @@ def test_write_stdin_wait_for_keeps_aggregate_within_output_budget():
|
||||
assert len(result) < 1100
|
||||
|
||||
|
||||
def test_write_stdin_wait_for_searches_before_response_truncation():
|
||||
async def run() -> tuple[str, list[int]]:
|
||||
output = "A" * 1500 + "TARGET" + "B" * 1500
|
||||
observed_limits: list[int] = []
|
||||
|
||||
async def write(
|
||||
*,
|
||||
session_id: str,
|
||||
chars: str | None,
|
||||
close_stdin: bool,
|
||||
terminate: bool,
|
||||
yield_time_ms: int,
|
||||
max_output_chars: int,
|
||||
owner_session_key: str | None,
|
||||
) -> _SessionPoll:
|
||||
del session_id, chars, close_stdin, terminate, yield_time_ms, owner_session_key
|
||||
observed_limits.append(max_output_chars)
|
||||
visible, truncated = _truncate_output(output, max_output_chars)
|
||||
return _SessionPoll(
|
||||
output=visible,
|
||||
done=True,
|
||||
exit_code=0,
|
||||
truncated_chars=truncated,
|
||||
)
|
||||
|
||||
manager = SimpleNamespace(write=AsyncMock(side_effect=write))
|
||||
tool = WriteStdinTool(manager=manager)
|
||||
result = await tool._wait_for_output(
|
||||
session_id="session",
|
||||
chars=None,
|
||||
close_stdin=False,
|
||||
terminate=False,
|
||||
wait_for="TARGET",
|
||||
wait_timeout_ms=1000,
|
||||
max_output_chars=1000,
|
||||
)
|
||||
return result, observed_limits
|
||||
|
||||
result, observed_limits = asyncio.run(run())
|
||||
|
||||
assert observed_limits == [MAX_OUTPUT_CHARS]
|
||||
assert "Wait target not observed" not in result
|
||||
assert "(2,006 chars truncated from output)" in result
|
||||
assert len(result) < 1100
|
||||
|
||||
|
||||
def test_exec_one_shot_accepts_max_output_tokens_alias(tmp_path):
|
||||
async def run() -> str:
|
||||
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
|
||||
|
||||
+8
-87
@@ -8,7 +8,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Ghost, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
|
||||
import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { channelUiPresentation } from "@/channel-plugins/registry";
|
||||
import { Sidebar } from "@/components/Sidebar";
|
||||
@@ -38,7 +38,6 @@ import { displayTitle } from "@/lib/chat-groups";
|
||||
import { deriveTitle } from "@/lib/format";
|
||||
import { NanobotClient } from "@/lib/nanobot-client";
|
||||
import {
|
||||
createTemporaryChatSession,
|
||||
isQuickChatKey,
|
||||
QUICK_CHAT_ID,
|
||||
QUICK_CHAT_KEY,
|
||||
@@ -974,8 +973,6 @@ function Shell({
|
||||
initialRouteRef.current.activeKey,
|
||||
);
|
||||
const [view, setView] = useState<ShellView>(initialRouteRef.current.view);
|
||||
const [temporarySession, setTemporarySession] = useState<ChatSummary | null>(null);
|
||||
const temporarySessionRef = useRef<ChatSummary | null>(null);
|
||||
const [settingsInitialSection, setSettingsInitialSection] =
|
||||
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
|
||||
const [hostSidebarOpen, setHostSidebarOpen] =
|
||||
@@ -1025,33 +1022,19 @@ function Shell({
|
||||
const showHostChrome = effectiveRuntimeSurface === "native";
|
||||
const showMainSidebar = view !== "settings";
|
||||
|
||||
const discardTemporaryChat = useCallback(() => {
|
||||
const current = temporarySessionRef.current;
|
||||
if (!current) return;
|
||||
temporarySessionRef.current = null;
|
||||
client.discardTemporaryChat(current.chatId);
|
||||
setTemporarySession(null);
|
||||
}, [client]);
|
||||
|
||||
const navigate = useCallback(
|
||||
(route: ShellRoute, options?: { replace?: boolean }) => {
|
||||
if (route.view !== "chat" || route.activeKey !== QUICK_CHAT_KEY) {
|
||||
discardTemporaryChat();
|
||||
}
|
||||
setActiveKey(route.activeKey);
|
||||
setView(route.view);
|
||||
setSettingsInitialSection(route.settingsSection);
|
||||
writeShellRoute(route, options?.replace);
|
||||
},
|
||||
[discardTemporaryChat],
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const applyRoute = () => {
|
||||
const route = readShellRoute();
|
||||
if (route.view !== "chat" || route.activeKey !== QUICK_CHAT_KEY) {
|
||||
discardTemporaryChat();
|
||||
}
|
||||
setActiveKey(route.activeKey);
|
||||
setView(route.view);
|
||||
setSettingsInitialSection(route.settingsSection);
|
||||
@@ -1062,15 +1045,7 @@ function Shell({
|
||||
};
|
||||
window.addEventListener("hashchange", applyRoute);
|
||||
return () => window.removeEventListener("hashchange", applyRoute);
|
||||
}, [discardTemporaryChat]);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onStatus((status) => {
|
||||
if (status !== "open") discardTemporaryChat();
|
||||
});
|
||||
}, [client, discardTemporaryChat]);
|
||||
|
||||
useEffect(() => () => discardTemporaryChat(), [discardTemporaryChat]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -1157,11 +1132,10 @@ function Shell({
|
||||
|
||||
const activeSession = useMemo<ChatSummary | null>(() => {
|
||||
if (!activeKey) return null;
|
||||
if (isQuickChatKey(activeKey)) return temporarySession ?? quickSession;
|
||||
if (isQuickChatKey(activeKey)) return quickSession;
|
||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||
}, [sessions, activeKey, quickSession, temporarySession]);
|
||||
}, [sessions, activeKey, quickSession]);
|
||||
const quickChatActive = isQuickChatKey(activeKey);
|
||||
const temporaryChatActive = quickChatActive && temporarySession !== null;
|
||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||
const activeChatId = activeSession?.chatId ?? null;
|
||||
@@ -1176,9 +1150,6 @@ function Shell({
|
||||
});
|
||||
}, [activeChatId]);
|
||||
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
|
||||
if (temporaryChatActive) {
|
||||
return null;
|
||||
}
|
||||
if (quickChatActive) {
|
||||
return workspaces?.default_scope ?? null;
|
||||
}
|
||||
@@ -1194,7 +1165,6 @@ function Shell({
|
||||
activeSession?.workspaceScope,
|
||||
draftWorkspaceScope,
|
||||
quickChatActive,
|
||||
temporaryChatActive,
|
||||
workspaceOverrides,
|
||||
workspaces?.default_scope,
|
||||
]);
|
||||
@@ -1487,16 +1457,6 @@ function Shell({
|
||||
setMobileSidebarOpen(false);
|
||||
}, [navigate]);
|
||||
|
||||
const onToggleTemporaryChat = useCallback(() => {
|
||||
if (temporarySessionRef.current) {
|
||||
discardTemporaryChat();
|
||||
return;
|
||||
}
|
||||
const session = createTemporaryChatSession();
|
||||
temporarySessionRef.current = session;
|
||||
setTemporarySession(session);
|
||||
}, [discardTemporaryChat]);
|
||||
|
||||
const onNewChatInProject = useCallback(
|
||||
(projectPath: string, projectName: string) => {
|
||||
const base = workspaces?.default_scope ?? activeWorkspaceScope;
|
||||
@@ -1947,9 +1907,7 @@ function Shell({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const headerTitle = temporaryChatActive
|
||||
? t("quickChat.temporary.title")
|
||||
: quickChatActive
|
||||
const headerTitle = quickChatActive
|
||||
? t("sidebar.quickChat")
|
||||
: activeSession
|
||||
? sidebarState.title_overrides[activeSession.key] ||
|
||||
@@ -1957,30 +1915,6 @@ function Shell({
|
||||
deriveTitle(activeSession.preview, t("chat.newChat"))
|
||||
: t("app.brand");
|
||||
|
||||
const temporaryChatAction = quickChatActive ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-pressed={temporaryChatActive}
|
||||
aria-label={
|
||||
temporaryChatActive
|
||||
? t("quickChat.temporary.exit")
|
||||
: t("quickChat.temporary.enter")
|
||||
}
|
||||
onClick={onToggleTemporaryChat}
|
||||
className={cn(
|
||||
"host-no-drag h-8 rounded-full px-2.5 text-xs text-muted-foreground",
|
||||
temporaryChatActive && "bg-foreground text-background hover:bg-foreground/90 hover:text-background",
|
||||
)}
|
||||
>
|
||||
<Ghost className="mr-1.5 h-3.5 w-3.5" />
|
||||
{temporaryChatActive
|
||||
? t("quickChat.temporary.active")
|
||||
: t("quickChat.temporary.enter")}
|
||||
</Button>
|
||||
) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "settings") {
|
||||
document.title = t("app.documentTitle.chat", {
|
||||
@@ -2213,9 +2147,7 @@ function Shell({
|
||||
hostChromeTitleInset={hostSidebarCollapsed}
|
||||
hideHeader={false}
|
||||
workspaceScope={activeWorkspaceScope}
|
||||
workspaceDefaultScope={
|
||||
temporaryChatActive ? null : workspaces?.default_scope ?? null
|
||||
}
|
||||
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
||||
workspaceControls={
|
||||
quickChatActive ? null : (workspaces?.controls ?? null)
|
||||
}
|
||||
@@ -2228,19 +2160,8 @@ function Shell({
|
||||
allowConversationReset={!quickChatActive}
|
||||
showSessionInfo={!quickChatActive}
|
||||
emptyStateGreeting={
|
||||
temporaryChatActive
|
||||
? t("quickChat.temporary.greeting")
|
||||
: quickChatActive
|
||||
? t("quickChat.greeting")
|
||||
: undefined
|
||||
quickChatActive ? t("quickChat.greeting") : undefined
|
||||
}
|
||||
emptyStateDescription={
|
||||
temporaryChatActive
|
||||
? t("quickChat.temporary.description")
|
||||
: undefined
|
||||
}
|
||||
temporary={temporaryChatActive}
|
||||
headerAction={temporaryChatAction}
|
||||
/>
|
||||
</div>
|
||||
{view !== "chat" && (
|
||||
|
||||
@@ -107,6 +107,7 @@ export const ChatList = memo(function ChatList({
|
||||
}: ChatListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
|
||||
const listContentRef = useRef<HTMLDivElement>(null);
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
const labels = useMemo<ChatGroupLabels>(() => ({
|
||||
pinned: t("chat.groups.pinned"),
|
||||
@@ -187,10 +188,8 @@ export const ChatList = memo(function ChatList({
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
|
||||
<SidebarSelectionHighlight
|
||||
targetRef={activeRowRef}
|
||||
activeId={activeKey}
|
||||
scope="sessions"
|
||||
<div
|
||||
ref={listContentRef}
|
||||
data-chat-list-content
|
||||
className="relative min-w-0 space-y-3 px-2 py-1.5"
|
||||
>
|
||||
@@ -409,7 +408,13 @@ export const ChatList = memo(function ChatList({
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</SidebarSelectionHighlight>
|
||||
<SidebarSelectionHighlight
|
||||
containerRef={listContentRef}
|
||||
targetRef={activeRowRef}
|
||||
activeId={activeKey}
|
||||
scope="sessions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -95,6 +95,7 @@ export function Sidebar(props: SidebarProps) {
|
||||
const collapsed = Boolean(props.collapsed);
|
||||
const toggleLabel = t("thread.header.toggleSidebar");
|
||||
const newChatShortcut = newChatShortcutLabel();
|
||||
const actionListRef = useRef<HTMLDivElement>(null);
|
||||
const activeActionRef = useRef<HTMLButtonElement>(null);
|
||||
const activeActionId = props.quickChatActive
|
||||
? "quick-chat"
|
||||
@@ -154,10 +155,8 @@ export function Sidebar(props: SidebarProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SidebarSelectionHighlight
|
||||
targetRef={activeActionRef}
|
||||
activeId={activeActionId}
|
||||
scope="actions"
|
||||
<div
|
||||
ref={actionListRef}
|
||||
className={cn(
|
||||
"relative space-y-1.5 px-2 pb-2",
|
||||
collapsed && "flex w-14 flex-col items-center px-0",
|
||||
@@ -222,7 +221,13 @@ export function Sidebar(props: SidebarProps) {
|
||||
icon={<Archive className="h-4 w-4" />}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarSelectionHighlight>
|
||||
<SidebarSelectionHighlight
|
||||
containerRef={actionListRef}
|
||||
targetRef={activeActionRef}
|
||||
activeId={activeActionId}
|
||||
scope="actions"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden transition-opacity duration-200",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
type HTMLAttributes,
|
||||
type RefObject,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
interface SidebarSelectionHighlightProps extends HTMLAttributes<HTMLDivElement> {
|
||||
interface SidebarSelectionHighlightProps {
|
||||
containerRef: RefObject<HTMLElement>;
|
||||
targetRef: RefObject<HTMLElement>;
|
||||
activeId: string | null;
|
||||
scope: string;
|
||||
@@ -18,13 +18,11 @@ export const SIDEBAR_SELECTION_ACTION_ITEM_CLASS =
|
||||
"relative z-[1] transition-[width,padding,color] [transition-duration:300ms,300ms,150ms] ease-out motion-reduce:transition-none";
|
||||
|
||||
export function SidebarSelectionHighlight({
|
||||
containerRef,
|
||||
targetRef,
|
||||
activeId,
|
||||
scope,
|
||||
children,
|
||||
...containerProps
|
||||
}: SidebarSelectionHighlightProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const highlightRef = useRef<HTMLDivElement>(null);
|
||||
const positionedRef = useRef(false);
|
||||
|
||||
@@ -32,19 +30,11 @@ export function SidebarSelectionHighlight({
|
||||
const highlight = highlightRef.current;
|
||||
const container = containerRef.current;
|
||||
const target = targetRef.current;
|
||||
if (!highlight) return;
|
||||
if (!activeId || !container || !target) {
|
||||
highlight.style.opacity = "0";
|
||||
positionedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let restoreTransitionFrame: number | null = null;
|
||||
|
||||
const position = () => {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
if (targetRect.width === 0 || targetRect.height === 0) {
|
||||
if (!highlight) return;
|
||||
if (!activeId || !container || !target) {
|
||||
highlight.style.opacity = "0";
|
||||
positionedRef.current = false;
|
||||
return;
|
||||
@@ -53,6 +43,8 @@ export function SidebarSelectionHighlight({
|
||||
const firstPosition = !positionedRef.current;
|
||||
if (firstPosition) highlight.style.transitionProperty = "none";
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
highlight.style.width = `${targetRect.width}px`;
|
||||
highlight.style.height = `${targetRect.height}px`;
|
||||
highlight.style.transform = `translate3d(${targetRect.left - containerRect.left}px, ${
|
||||
@@ -72,8 +64,8 @@ export function SidebarSelectionHighlight({
|
||||
position();
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(position);
|
||||
resizeObserver?.observe(container);
|
||||
resizeObserver?.observe(target);
|
||||
if (container) resizeObserver?.observe(container);
|
||||
if (target) resizeObserver?.observe(target);
|
||||
window.addEventListener("resize", position);
|
||||
|
||||
return () => {
|
||||
@@ -87,8 +79,6 @@ export function SidebarSelectionHighlight({
|
||||
});
|
||||
|
||||
return (
|
||||
<div {...containerProps} ref={containerRef}>
|
||||
{children}
|
||||
<div
|
||||
ref={highlightRef}
|
||||
data-testid={`${scope}-selection-highlight`}
|
||||
@@ -96,6 +86,5 @@ export function SidebarSelectionHighlight({
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[transform,width,height] duration-300 ease-out will-change-transform motion-reduce:transition-none dark:bg-white/[0.07]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2501,6 +2501,7 @@ function SettingsSidebar({
|
||||
hostChromeInset?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const desktopNavRef = useRef<HTMLDivElement>(null);
|
||||
const activeNavItemRef = useRef<HTMLButtonElement>(null);
|
||||
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
|
||||
?? SETTINGS_NAV_ITEMS[0];
|
||||
@@ -2574,12 +2575,7 @@ function SettingsSidebar({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<SidebarSelectionHighlight
|
||||
targetRef={activeNavItemRef}
|
||||
activeId={activeSection}
|
||||
scope="settings"
|
||||
className="relative hidden space-y-1 lg:block"
|
||||
>
|
||||
<div ref={desktopNavRef} className="relative hidden space-y-1 lg:block">
|
||||
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
|
||||
const active = key === activeSection;
|
||||
return (
|
||||
@@ -2604,7 +2600,13 @@ function SettingsSidebar({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</SidebarSelectionHighlight>
|
||||
<SidebarSelectionHighlight
|
||||
containerRef={desktopNavRef}
|
||||
targetRef={activeNavItemRef}
|
||||
activeId={activeSection}
|
||||
scope="settings"
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="hidden lg:mt-auto lg:block lg:pt-4">
|
||||
|
||||
@@ -202,7 +202,6 @@ interface ThreadComposerProps {
|
||||
quotedContext?: string | null;
|
||||
focusRequest?: number;
|
||||
onQuotedContextChange?: (text: string | null) => void;
|
||||
allowAttachments?: boolean;
|
||||
}
|
||||
|
||||
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
||||
@@ -851,7 +850,6 @@ export function ThreadComposer({
|
||||
quotedContext = null,
|
||||
focusRequest = 0,
|
||||
onQuotedContextChange,
|
||||
allowAttachments = true,
|
||||
}: ThreadComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
@@ -915,10 +913,6 @@ export function ThreadComposer({
|
||||
const { images, enqueue, remove, clear, restoreReadyImages, encoding, full } =
|
||||
useAttachedImages({ ingressLimits });
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowAttachments) clear();
|
||||
}, [allowAttachments, clear]);
|
||||
|
||||
const formatRejection = useCallback(
|
||||
(reason: AttachmentError): string => {
|
||||
const key = `thread.composer.imageRejected.${reason}`;
|
||||
@@ -948,7 +942,6 @@ export function ThreadComposer({
|
||||
|
||||
const addFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
if (!allowAttachments) return;
|
||||
if (files.length === 0) return;
|
||||
secondEnterPromptIdRef.current = null;
|
||||
const { rejected } = enqueue(files);
|
||||
@@ -958,7 +951,7 @@ export function ThreadComposer({
|
||||
setInlineError(null);
|
||||
}
|
||||
},
|
||||
[allowAttachments, enqueue, formatRejection],
|
||||
[enqueue, formatRejection],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -1881,10 +1874,10 @@ export function ThreadComposer({
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
onDragEnter={allowAttachments ? onDragEnter : undefined}
|
||||
onDragOver={allowAttachments ? onDragOver : undefined}
|
||||
onDragLeave={allowAttachments ? onDragLeave : undefined}
|
||||
onDrop={allowAttachments ? onDrop : undefined}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
|
||||
>
|
||||
{showSlashMenu ? (
|
||||
@@ -1914,9 +1907,7 @@ export function ThreadComposer({
|
||||
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
|
||||
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
|
||||
disabled && "opacity-60",
|
||||
allowAttachments
|
||||
&& isDragging
|
||||
&& "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
||||
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
||||
goalState?.active &&
|
||||
"goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45",
|
||||
)}
|
||||
@@ -2023,7 +2014,7 @@ export function ThreadComposer({
|
||||
onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||
onSelect={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||
onPaste={allowAttachments ? onPaste : undefined}
|
||||
onPaste={onPaste}
|
||||
rows={1}
|
||||
placeholder={resolvedPlaceholder}
|
||||
disabled={disabled}
|
||||
@@ -2066,8 +2057,6 @@ export function ThreadComposer({
|
||||
isHero ? "gap-1.5" : "gap-2",
|
||||
)}
|
||||
>
|
||||
{allowAttachments ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -2092,8 +2081,6 @@ export function ThreadComposer({
|
||||
>
|
||||
<Plus className={cn(isHero ? "h-[18px] w-[18px]" : "h-4 w-4")} />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{voiceRecorder.isRecording ? (
|
||||
<VoiceRecordingMeter
|
||||
ariaLabel={voiceRecordingStatusLabel}
|
||||
|
||||
@@ -16,7 +16,6 @@ interface ThreadHeaderProps {
|
||||
minimal?: boolean;
|
||||
promptNavigatorAction?: ReactNode;
|
||||
sessionInfoAction?: ReactNode;
|
||||
headerAction?: ReactNode;
|
||||
}
|
||||
|
||||
export function ThreadHeader({
|
||||
@@ -30,7 +29,6 @@ export function ThreadHeader({
|
||||
minimal = false,
|
||||
promptNavigatorAction,
|
||||
sessionInfoAction,
|
||||
headerAction,
|
||||
}: ThreadHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -63,7 +61,6 @@ export function ThreadHeader({
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{headerAction}
|
||||
{sessionInfoAction}
|
||||
{promptNavigatorAction}
|
||||
{!hideThemeButton ? (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
} from "@/lib/mcp-preset-events";
|
||||
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
|
||||
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
||||
import { TEMPORARY_CHAT_ID_PREFIX } from "@/lib/quick-chat";
|
||||
import type {
|
||||
ChatSummary,
|
||||
SettingsPayload,
|
||||
@@ -319,9 +318,6 @@ interface ThreadShellProps {
|
||||
allowConversationReset?: boolean;
|
||||
showSessionInfo?: boolean;
|
||||
emptyStateGreeting?: string;
|
||||
emptyStateDescription?: string;
|
||||
temporary?: boolean;
|
||||
headerAction?: ReactNode;
|
||||
}
|
||||
|
||||
function toModelBadgeLabel(modelName: string | null): string | null {
|
||||
@@ -607,13 +603,10 @@ export function ThreadShell({
|
||||
allowConversationReset = true,
|
||||
showSessionInfo = true,
|
||||
emptyStateGreeting,
|
||||
emptyStateDescription,
|
||||
temporary = false,
|
||||
headerAction,
|
||||
}: ThreadShellProps) {
|
||||
const { t } = useTranslation();
|
||||
const chatId = session?.chatId ?? null;
|
||||
const historyKey = temporary ? null : session?.key ?? null;
|
||||
const historyKey = session?.key ?? null;
|
||||
const {
|
||||
messages: historical,
|
||||
loading,
|
||||
@@ -636,14 +629,10 @@ export function ThreadShell({
|
||||
const [booting, setBooting] = useState(false);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const availableSlashCommands = useMemo(
|
||||
() => temporary
|
||||
? slashCommands.filter((command) =>
|
||||
command.command === "/model" || command.command === "/stop",
|
||||
)
|
||||
: allowConversationReset
|
||||
() => allowConversationReset
|
||||
? slashCommands
|
||||
: slashCommands.filter((command) => command.command !== "/new"),
|
||||
[allowConversationReset, slashCommands, temporary],
|
||||
[allowConversationReset, slashCommands],
|
||||
);
|
||||
const cliApps = useInstalledSettingItems({
|
||||
getToken,
|
||||
@@ -692,9 +681,8 @@ export function ThreadShell({
|
||||
|
||||
const initial = useMemo(() => {
|
||||
if (!chatId) return historical;
|
||||
if (temporary) return historical;
|
||||
return messageCacheRef.current.get(chatId) ?? historical;
|
||||
}, [chatId, historical, temporary]);
|
||||
}, [chatId, historical]);
|
||||
const handleTurnEnd = useCallback(() => {
|
||||
if (chatId) activeViewportTurnByChatIdRef.current.delete(chatId);
|
||||
setSubmittedViewportTurnId(null);
|
||||
@@ -714,13 +702,7 @@ export function ThreadShell({
|
||||
setMessages,
|
||||
streamError,
|
||||
dismissStreamError,
|
||||
} = useNanobotStream(
|
||||
chatId,
|
||||
initial,
|
||||
hasPendingToolCalls,
|
||||
handleTurnEnd,
|
||||
{ temporary },
|
||||
);
|
||||
} = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (currentUiMessagesRef.current === messages) return;
|
||||
@@ -849,12 +831,9 @@ export function ThreadShell({
|
||||
const handleModelPresetChange = useCallback((name: string) => {
|
||||
setLocalModelPreset(name);
|
||||
if (chatId) {
|
||||
const request = temporary
|
||||
? client.sendSystemCommand(chatId, `/model ${name}`, 5_000, { temporary: true })
|
||||
: client.sendSystemCommand(chatId, `/model ${name}`);
|
||||
void request.catch(() => {});
|
||||
void client.sendSystemCommand(chatId, `/model ${name}`).catch(() => {});
|
||||
}
|
||||
}, [chatId, client, temporary]);
|
||||
}, [chatId, client]);
|
||||
const modelPresetOptions = useMemo(
|
||||
() => modelPresetOptionsFromSettings(settings),
|
||||
[settings],
|
||||
@@ -875,16 +854,13 @@ export function ThreadShell({
|
||||
|
||||
const withWorkspaceScope = useCallback(
|
||||
(options?: SendOptions): SendOptions | undefined => {
|
||||
if (temporary) {
|
||||
return { ...(options ?? {}), temporary: true };
|
||||
}
|
||||
if (!workspaceScope) return options;
|
||||
return {
|
||||
...(options ?? {}),
|
||||
workspaceScope,
|
||||
};
|
||||
},
|
||||
[temporary, workspaceScope],
|
||||
[workspaceScope],
|
||||
);
|
||||
|
||||
const refreshModelSettings = useCallback(async () => {
|
||||
@@ -918,11 +894,11 @@ export function ThreadShell({
|
||||
return client.onChat(chatId, (event) => {
|
||||
if (event.event !== "turn_model_updated") return;
|
||||
setFallbackModelName(event.model_name);
|
||||
}, { temporary });
|
||||
}, [chatId, client, temporary]);
|
||||
});
|
||||
}, [chatId, client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId || loading || temporary) return;
|
||||
if (!chatId || loading) return;
|
||||
const cached = messageCacheRef.current.get(chatId);
|
||||
const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId);
|
||||
const hasNewCanonicalHistory = (
|
||||
@@ -1052,7 +1028,6 @@ export function ThreadShell({
|
||||
historyLineage,
|
||||
historyActiveTurnId,
|
||||
hasPendingToolCalls,
|
||||
temporary,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -1104,7 +1079,7 @@ export function ThreadShell({
|
||||
}, [chatId, hasPendingToolCalls, historyVersion, messages, reconcileTurnComplete]);
|
||||
|
||||
const refreshCanonicalHistory = useCallback(() => {
|
||||
if (!chatId || temporary) return;
|
||||
if (!chatId) return;
|
||||
pendingCanonicalHydrateRef.current.set(chatId, {
|
||||
historyLineage,
|
||||
historyVersion,
|
||||
@@ -1114,7 +1089,7 @@ export function ThreadShell({
|
||||
uiRevision: uiRevisionRef.current,
|
||||
});
|
||||
refreshHistory();
|
||||
}, [chatId, client, historyLineage, historyVersion, refreshHistory, temporary]);
|
||||
}, [chatId, client, historyLineage, historyVersion, refreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
@@ -1181,22 +1156,16 @@ export function ThreadShell({
|
||||
if (chatId) {
|
||||
const prev = prevChatIdForCacheRef.current;
|
||||
if (prev && prev !== chatId) {
|
||||
if (prev.startsWith(TEMPORARY_CHAT_ID_PREFIX)) {
|
||||
messageCacheRef.current.delete(prev);
|
||||
} else {
|
||||
messageCacheRef.current.set(prev, displayMessages);
|
||||
}
|
||||
skipLayoutCacheRef.current = true;
|
||||
}
|
||||
prevChatIdForCacheRef.current = chatId;
|
||||
} else {
|
||||
if (prevChatIdForCacheRef.current) {
|
||||
const prev = prevChatIdForCacheRef.current;
|
||||
if (prev.startsWith(TEMPORARY_CHAT_ID_PREFIX)) {
|
||||
messageCacheRef.current.delete(prev);
|
||||
} else {
|
||||
messageCacheRef.current.set(prev, displayMessages);
|
||||
}
|
||||
messageCacheRef.current.set(
|
||||
prevChatIdForCacheRef.current,
|
||||
displayMessages,
|
||||
);
|
||||
skipLayoutCacheRef.current = true;
|
||||
}
|
||||
prevChatIdForCacheRef.current = null;
|
||||
@@ -1207,7 +1176,7 @@ export function ThreadShell({
|
||||
// ``useEffect`` reset has flushed; ``skipLayoutCacheRef`` drops the first run that still
|
||||
// sees the *previous* chat's ``messages`` (avoids stale rows leaking across sessions).
|
||||
useEffect(() => {
|
||||
if (!chatId || temporary) {
|
||||
if (!chatId) {
|
||||
return;
|
||||
}
|
||||
if (skipLayoutCacheRef.current) {
|
||||
@@ -1218,7 +1187,7 @@ export function ThreadShell({
|
||||
return;
|
||||
}
|
||||
messageCacheRef.current.set(chatId, displayMessages);
|
||||
}, [chatId, displayMessages, loading, temporary]);
|
||||
}, [chatId, displayMessages, loading]);
|
||||
|
||||
// The landing composer queues the first message while `new_chat` is in flight.
|
||||
// Only the chat created for that send may consume it; selecting another chat
|
||||
@@ -1418,11 +1387,11 @@ export function ThreadShell({
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
slashCommands={availableSlashCommands}
|
||||
cliApps={temporary ? [] : cliApps}
|
||||
mcpPresets={temporary ? [] : mcpPresets}
|
||||
skills={temporary ? [] : skills}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
skills={skills}
|
||||
onStop={stop}
|
||||
onTranscribeAudio={temporary ? undefined : transcribeAudio}
|
||||
onTranscribeAudio={transcribeAudio}
|
||||
runStartedAt={currentRunStartedAt}
|
||||
goalState={currentGoalState}
|
||||
workspaceScope={workspaceScope}
|
||||
@@ -1437,7 +1406,6 @@ export function ThreadShell({
|
||||
quotedContext={quotedContext}
|
||||
focusRequest={composerFocusSignal}
|
||||
onQuotedContextChange={setQuotedContext}
|
||||
allowAttachments={!temporary}
|
||||
/>
|
||||
) : (
|
||||
<ThreadComposer
|
||||
@@ -1487,11 +1455,6 @@ export function ThreadShell({
|
||||
) : (
|
||||
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
||||
<HeroGreeting text={emptyStateGreeting ?? t(heroGreetingKey)} />
|
||||
{emptyStateDescription ? (
|
||||
<p className="mt-3 max-w-xl text-sm text-muted-foreground">
|
||||
{emptyStateDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
const sessionInfoAction = historyKey && showSessionInfo ? (
|
||||
@@ -1519,7 +1482,6 @@ export function ThreadShell({
|
||||
minimal={!session && !loading}
|
||||
promptNavigatorAction={promptNavigatorAction}
|
||||
sessionInfoAction={sessionInfoAction}
|
||||
headerAction={headerAction}
|
||||
/>
|
||||
) : null}
|
||||
<FilePreviewAvailabilityProvider
|
||||
@@ -1536,8 +1498,8 @@ export function ThreadShell({
|
||||
conversationKey={historyKey}
|
||||
conversationReady={messagesReady}
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={temporary ? [] : cliApps}
|
||||
mcpPresets={temporary ? [] : mcpPresets}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={availableSlashCommands}
|
||||
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
||||
hasMoreBefore={hasMoreBefore}
|
||||
@@ -1545,8 +1507,8 @@ export function ThreadShell({
|
||||
userMessageOffset={userMessageOffset}
|
||||
onLoadOlder={loadOlder}
|
||||
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
|
||||
onForkFromMessage={!temporary && onForkChat ? handleForkFromMessage : undefined}
|
||||
onQuoteSelection={session && !temporary ? handleQuoteSelection : undefined}
|
||||
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
|
||||
onQuoteSelection={session ? handleQuoteSelection : undefined}
|
||||
/>
|
||||
</FilePreviewAvailabilityProvider>
|
||||
</div>
|
||||
|
||||
@@ -542,7 +542,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
const near = distance < NEAR_BOTTOM_PX;
|
||||
const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic";
|
||||
const logicallyAtBottom = owner === "automatic" || (owner === "navigation" && near);
|
||||
const logicallyAtBottom = owner === "automatic" || near;
|
||||
setAtBottom((current) =>
|
||||
current === logicallyAtBottom ? current : logicallyAtBottom,
|
||||
);
|
||||
@@ -557,7 +557,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
if (!direction) return;
|
||||
threadMotionRef.current?.handleUserScrollIntent(
|
||||
canScrollInDirection(el, direction),
|
||||
direction === "forward",
|
||||
);
|
||||
};
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
@@ -573,21 +572,20 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (event.button === 0 && event.target === el) yieldCameraToUser();
|
||||
};
|
||||
let lastTouchY: number | null = null;
|
||||
let touchStartY: number | null = null;
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
lastTouchY = event.touches[0]?.clientY ?? null;
|
||||
touchStartY = event.touches[0]?.clientY ?? null;
|
||||
};
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
const currentY = event.touches[0]?.clientY;
|
||||
const scrollDeltaY =
|
||||
lastTouchY !== null && currentY !== undefined
|
||||
? lastTouchY - currentY
|
||||
touchStartY !== null && currentY !== undefined
|
||||
? touchStartY - currentY
|
||||
: 0;
|
||||
lastTouchY = currentY ?? null;
|
||||
handleDirectionalInput(directionFromDelta(scrollDeltaY));
|
||||
};
|
||||
const handleTouchEnd = () => {
|
||||
lastTouchY = null;
|
||||
touchStartY = null;
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
|
||||
@@ -168,9 +168,6 @@ export class ThreadMotionCoordinator {
|
||||
private measurementFrameId: number | null = null;
|
||||
private geometryDirty = false;
|
||||
private composerInputDuringTurn = false;
|
||||
// A user leaving the live tail must first move beyond the near-bottom
|
||||
// boundary, or explicitly reverse toward latest, before follow can resume.
|
||||
private resumeFollowArmed = false;
|
||||
|
||||
constructor(options: ThreadMotionCoordinatorOptions) {
|
||||
this.camera = options.camera;
|
||||
@@ -201,7 +198,6 @@ export class ThreadMotionCoordinator {
|
||||
if (isNewTurn) {
|
||||
this.camera.cancel();
|
||||
this.composerInputDuringTurn = false;
|
||||
this.resumeFollowArmed = false;
|
||||
this.promptPositioned = turn.entry === "restored";
|
||||
this.mode = this.promptPositioned && turn.hasOutput
|
||||
? "follow-output"
|
||||
@@ -253,31 +249,15 @@ export class ThreadMotionCoordinator {
|
||||
this.handleUserScrollIntent(true);
|
||||
}
|
||||
|
||||
handleUserScrollIntent(canScroll: boolean, towardLatest = false): void {
|
||||
if (this.mode === "browsing-history" && towardLatest && !canScroll) {
|
||||
this.transitionToAutoFollow(false);
|
||||
return;
|
||||
}
|
||||
handleUserScrollIntent(canScroll: boolean): void {
|
||||
const event = canScroll ? "user-scroll" : "boundary-scroll";
|
||||
const transitioned = this.transition(event);
|
||||
if (this.mode === "browsing-history" && canScroll) {
|
||||
this.resumeFollowArmed = towardLatest;
|
||||
} else if (transitioned && this.mode === "browsing-history") {
|
||||
this.resumeFollowArmed = false;
|
||||
}
|
||||
if (!transitioned) return;
|
||||
if (!this.transition(event)) return;
|
||||
this.camera.cancel();
|
||||
}
|
||||
|
||||
resumeAutoFollow(): void {
|
||||
this.transitionToAutoFollow(true);
|
||||
}
|
||||
|
||||
private transitionToAutoFollow(cancelCamera: boolean): void {
|
||||
if (!this.transition("resume-follow")) return;
|
||||
this.resumeFollowArmed = false;
|
||||
if (cancelCamera) this.camera.cancel();
|
||||
this.onAutoFollow?.();
|
||||
this.camera.cancel();
|
||||
this.invalidateGeometry();
|
||||
}
|
||||
|
||||
@@ -337,19 +317,11 @@ export class ThreadMotionCoordinator {
|
||||
case "navigating-history":
|
||||
if (!this.camera.isFollowing()) {
|
||||
this.transition("navigation-settled");
|
||||
if (nearBottom) {
|
||||
this.resumeAutoFollow();
|
||||
} else {
|
||||
this.resumeFollowArmed = true;
|
||||
}
|
||||
if (nearBottom) this.resumeAutoFollow();
|
||||
}
|
||||
return "navigation";
|
||||
case "browsing-history":
|
||||
if (!nearBottom) {
|
||||
this.resumeFollowArmed = true;
|
||||
return "user";
|
||||
}
|
||||
if (!this.resumeFollowArmed) return "user";
|
||||
if (!nearBottom) return "user";
|
||||
this.resumeAutoFollow();
|
||||
return "automatic";
|
||||
default:
|
||||
@@ -367,7 +339,6 @@ export class ThreadMotionCoordinator {
|
||||
this.camera.cancel();
|
||||
this.turn = { id: null, promptId: null, hasOutput: false };
|
||||
this.composerInputDuringTurn = false;
|
||||
this.resumeFollowArmed = false;
|
||||
this.mode = "idle";
|
||||
this.promptPositioned = false;
|
||||
}
|
||||
|
||||
@@ -487,7 +487,6 @@ export interface SendOptions {
|
||||
finalizeActiveTurn?: boolean;
|
||||
/** Append guidance to the running turn without detaching its active answer segment. */
|
||||
continueActiveTurn?: boolean;
|
||||
temporary?: boolean;
|
||||
}
|
||||
|
||||
export interface SubmittedTurn {
|
||||
@@ -547,7 +546,6 @@ export function useNanobotStream(
|
||||
initialMessages: UIMessage[] = [],
|
||||
hasPendingToolCalls = false,
|
||||
onTurnEnd?: () => void,
|
||||
options?: { temporary?: boolean },
|
||||
): {
|
||||
messages: UIMessage[];
|
||||
/** Whether ``messages`` belongs to the current ``chatId`` after a session switch. */
|
||||
@@ -1343,9 +1341,7 @@ export function useNanobotStream(
|
||||
// ``attached`` frames aren't actionable here.
|
||||
};
|
||||
|
||||
const unsub = options?.temporary
|
||||
? client.onChat(chatId, handle, { temporary: true })
|
||||
: client.onChat(chatId, handle);
|
||||
const unsub = client.onChat(chatId, handle);
|
||||
return () => {
|
||||
unsub();
|
||||
buffer.current = null;
|
||||
@@ -1367,7 +1363,6 @@ export function useNanobotStream(
|
||||
flushPendingStreamEvents,
|
||||
isSideChannelEvent,
|
||||
onTurnEnd,
|
||||
options?.temporary,
|
||||
schedulePendingStreamFlush,
|
||||
scheduleStreamEndTimer,
|
||||
]);
|
||||
@@ -1455,18 +1450,8 @@ export function useNanobotStream(
|
||||
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
||||
});
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
if (options?.temporary) {
|
||||
client.sendMessage(chatId, "/stop", undefined, { temporary: true });
|
||||
} else {
|
||||
client.sendMessage(chatId, "/stop");
|
||||
}
|
||||
}, [
|
||||
chatId,
|
||||
clearActivitySegment,
|
||||
client,
|
||||
flushPendingStreamEvents,
|
||||
options?.temporary,
|
||||
]);
|
||||
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
|
||||
|
||||
const reconcileTurnComplete = useCallback(() => {
|
||||
cancelStreamEndTimer();
|
||||
|
||||
@@ -62,15 +62,7 @@
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "What's on your mind?",
|
||||
"temporary": {
|
||||
"title": "Temporary Chat",
|
||||
"enter": "Temporary",
|
||||
"active": "Temporary",
|
||||
"exit": "Exit temporary chat",
|
||||
"greeting": "Start a temporary chat",
|
||||
"description": "No history, memory, tools, or project access. Content is still sent to your selected model provider."
|
||||
}
|
||||
"greeting": "What's on your mind?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Back to chat",
|
||||
|
||||
@@ -62,15 +62,7 @@
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "¿Qué tienes en mente?",
|
||||
"temporary": {
|
||||
"title": "Chat temporal",
|
||||
"enter": "Temporal",
|
||||
"active": "Temporal",
|
||||
"exit": "Salir del chat temporal",
|
||||
"greeting": "Inicia un chat temporal",
|
||||
"description": "Sin historial, memoria, herramientas ni acceso al proyecto. El contenido se envía al proveedor del modelo elegido."
|
||||
}
|
||||
"greeting": "¿Qué tienes en mente?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Volver al chat",
|
||||
|
||||
@@ -62,15 +62,7 @@
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "De quoi avez-vous envie de parler ?",
|
||||
"temporary": {
|
||||
"title": "Discussion temporaire",
|
||||
"enter": "Temporaire",
|
||||
"active": "Temporaire",
|
||||
"exit": "Quitter la discussion temporaire",
|
||||
"greeting": "Démarrer une discussion temporaire",
|
||||
"description": "Aucun historique, mémoire, outil ou accès au projet. Le contenu est transmis au fournisseur du modèle choisi."
|
||||
}
|
||||
"greeting": "De quoi avez-vous envie de parler ?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Retour au chat",
|
||||
|
||||
@@ -62,15 +62,7 @@
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "Apa yang sedang kamu pikirkan?",
|
||||
"temporary": {
|
||||
"title": "Obrolan sementara",
|
||||
"enter": "Sementara",
|
||||
"active": "Sementara",
|
||||
"exit": "Keluar dari obrolan sementara",
|
||||
"greeting": "Mulai obrolan sementara",
|
||||
"description": "Tanpa riwayat, memori, alat, atau akses proyek. Konten tetap dikirim ke penyedia model pilihan Anda."
|
||||
}
|
||||
"greeting": "Apa yang sedang kamu pikirkan?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Kembali ke chat",
|
||||
|
||||
@@ -62,15 +62,7 @@
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "何について話しますか?",
|
||||
"temporary": {
|
||||
"title": "一時チャット",
|
||||
"enter": "一時チャット",
|
||||
"active": "一時チャット中",
|
||||
"exit": "一時チャットを終了",
|
||||
"greeting": "一時チャットを始める",
|
||||
"description": "履歴、メモリ、ツール、プロジェクトにはアクセスしません。内容は選択したモデル提供元に送信されます。"
|
||||
}
|
||||
"greeting": "何について話しますか?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "チャットに戻る",
|
||||
|
||||
@@ -62,15 +62,7 @@
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "무슨 이야기를 나눠볼까요?",
|
||||
"temporary": {
|
||||
"title": "임시 채팅",
|
||||
"enter": "임시 채팅",
|
||||
"active": "임시 채팅 중",
|
||||
"exit": "임시 채팅 종료",
|
||||
"greeting": "임시 채팅 시작하기",
|
||||
"description": "기록, 메모리, 도구, 프로젝트에 접근하지 않습니다. 내용은 선택한 모델 제공업체로 전송됩니다."
|
||||
}
|
||||
"greeting": "무슨 이야기를 나눠볼까요?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "채팅으로 돌아가기",
|
||||
|
||||
@@ -62,15 +62,7 @@
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "O que você está pensando?",
|
||||
"temporary": {
|
||||
"title": "Chat temporário",
|
||||
"enter": "Temporário",
|
||||
"active": "Temporário",
|
||||
"exit": "Sair do chat temporário",
|
||||
"greeting": "Inicie um chat temporário",
|
||||
"description": "Sem histórico, memória, ferramentas ou acesso ao projeto. O conteúdo ainda é enviado ao provedor do modelo escolhido."
|
||||
}
|
||||
"greeting": "O que você está pensando?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Voltar para a conversa",
|
||||
|
||||
@@ -62,15 +62,7 @@
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "Bạn đang nghĩ gì?",
|
||||
"temporary": {
|
||||
"title": "Trò chuyện tạm thời",
|
||||
"enter": "Tạm thời",
|
||||
"active": "Tạm thời",
|
||||
"exit": "Thoát trò chuyện tạm thời",
|
||||
"greeting": "Bắt đầu trò chuyện tạm thời",
|
||||
"description": "Không lịch sử, bộ nhớ, công cụ hay quyền truy cập dự án. Nội dung vẫn được gửi đến nhà cung cấp mô hình bạn chọn."
|
||||
}
|
||||
"greeting": "Bạn đang nghĩ gì?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Quay lại chat",
|
||||
|
||||
@@ -62,15 +62,7 @@
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "想聊点什么?",
|
||||
"temporary": {
|
||||
"title": "临时聊天",
|
||||
"enter": "临时聊天",
|
||||
"active": "临时聊天中",
|
||||
"exit": "退出临时聊天",
|
||||
"greeting": "开启一次临时聊天",
|
||||
"description": "不保存记录,不读取记忆或项目,也不使用工具;内容仍会发送给你选择的模型服务商。"
|
||||
}
|
||||
"greeting": "想聊点什么?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "返回聊天",
|
||||
|
||||
@@ -62,15 +62,7 @@
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "想聊點什麼?",
|
||||
"temporary": {
|
||||
"title": "臨時聊天",
|
||||
"enter": "臨時聊天",
|
||||
"active": "臨時聊天中",
|
||||
"exit": "退出臨時聊天",
|
||||
"greeting": "開啟一次臨時聊天",
|
||||
"description": "不儲存記錄,不讀取記憶或專案,也不使用工具;內容仍會傳送給你選擇的模型服務商。"
|
||||
}
|
||||
"greeting": "想聊點什麼?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "返回聊天",
|
||||
|
||||
@@ -673,12 +673,8 @@ export class NanobotClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to events for a given chat_id. Auto-attaches unless it is temporary. */
|
||||
onChat(
|
||||
chatId: string,
|
||||
handler: EventHandler,
|
||||
options?: { temporary?: boolean },
|
||||
): Unsubscribe {
|
||||
/** Subscribe to events for a given chat_id. Auto-attaches on the next open. */
|
||||
onChat(chatId: string, handler: EventHandler): Unsubscribe {
|
||||
let handlers = this.chatHandlers.get(chatId);
|
||||
if (!handlers) {
|
||||
handlers = new Set();
|
||||
@@ -693,7 +689,7 @@ export class NanobotClient {
|
||||
handler(ev);
|
||||
}
|
||||
}
|
||||
if (!options?.temporary) this.attach(chatId);
|
||||
this.attach(chatId);
|
||||
return () => {
|
||||
const current = this.chatHandlers.get(chatId);
|
||||
if (!current) return;
|
||||
@@ -813,10 +809,9 @@ export class NanobotClient {
|
||||
turnId?: string;
|
||||
/** False for side-channel or injected messages that do not own a lifecycle. */
|
||||
startsNewRun?: boolean;
|
||||
temporary?: boolean;
|
||||
},
|
||||
): void {
|
||||
if (!options?.temporary) this.knownChats.add(chatId);
|
||||
this.knownChats.add(chatId);
|
||||
const frame: Outbound = {
|
||||
type: "message",
|
||||
chat_id: chatId,
|
||||
@@ -827,7 +822,6 @@ export class NanobotClient {
|
||||
...(options?.quotedContext?.trim() ? { quoted_context: options.quotedContext.trim() } : {}),
|
||||
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
|
||||
...(options?.turnId ? { turn_id: options.turnId } : {}),
|
||||
...(options?.temporary ? { temporary: true } : {}),
|
||||
webui: true,
|
||||
};
|
||||
if (!this.frameFitsTransport(frame)) {
|
||||
@@ -849,12 +843,7 @@ export class NanobotClient {
|
||||
this.queueSend(frame);
|
||||
}
|
||||
|
||||
sendSystemCommand(
|
||||
chatId: string,
|
||||
command: string,
|
||||
timeoutMs = 5_000,
|
||||
options?: { temporary?: boolean },
|
||||
): Promise<void> {
|
||||
sendSystemCommand(chatId: string, command: string, timeoutMs = 5_000): Promise<void> {
|
||||
const normalized = command.trim();
|
||||
const turnId = `${SYSTEM_COMMAND_TURN_PREFIX}${crypto.randomUUID()}`;
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
@@ -863,44 +852,8 @@ export class NanobotClient {
|
||||
reject(new Error("system command timed out"));
|
||||
}, timeoutMs);
|
||||
this.pendingSystemCommands.set(turnId, { resolve, reject, timer });
|
||||
this.sendMessage(chatId, normalized, undefined, {
|
||||
turnId,
|
||||
temporary: options?.temporary,
|
||||
this.sendMessage(chatId, normalized, undefined, { turnId });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
discardTemporaryChat(chatId: string): void {
|
||||
this.knownChats.delete(chatId);
|
||||
this.chatHandlers.delete(chatId);
|
||||
this.pendingInboundByChat.delete(chatId);
|
||||
this.runStartedAtByChatId.delete(chatId);
|
||||
this.goalStateByChatId.delete(chatId);
|
||||
this.runGenerationByChatId.delete(chatId);
|
||||
this.latestRunTurnIdByChatId.delete(chatId);
|
||||
this.unsettledRunTurnIdsByChatId.delete(chatId);
|
||||
this.canonicalCompletedTurnIdsByChatId.delete(chatId);
|
||||
const turnKeyPrefix = `${chatId}\u0000`;
|
||||
for (const key of this.runStartedAtByTurnKey.keys()) {
|
||||
if (key.startsWith(turnKeyPrefix)) this.runStartedAtByTurnKey.delete(key);
|
||||
}
|
||||
for (const [key, pending] of this.pendingMessageSends) {
|
||||
if (pending.chatId !== chatId) continue;
|
||||
if (isSystemCommandTurnId(pending.turnId)) {
|
||||
this.rejectSystemCommand(pending.turnId, "temporary chat discarded");
|
||||
}
|
||||
this.pendingMessageSends.delete(key);
|
||||
this.socketPendingMessageSendKeys.delete(key);
|
||||
}
|
||||
if (this.lastSocketMessageSendKey?.startsWith(turnKeyPrefix)) {
|
||||
this.lastSocketMessageSendKey = null;
|
||||
}
|
||||
this.sendQueue = this.sendQueue.filter(
|
||||
(frame) => !("chat_id" in frame) || frame.chat_id !== chatId,
|
||||
);
|
||||
if (this.socket?.readyState === WS_OPEN) {
|
||||
this.rawSend({ type: "discard_temporary_chat", chat_id: chatId });
|
||||
}
|
||||
}
|
||||
|
||||
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
|
||||
@@ -1054,11 +1007,6 @@ export class NanobotClient {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.event === "temporary_chat_discarded") {
|
||||
this.pendingInboundByChat.delete(parsed.chat_id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.event === "error" && parsed.detail === "workspace_scope_rejected") {
|
||||
this.emitError({
|
||||
kind: "workspace_scope_rejected",
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
export const QUICK_CHAT_ID = "quick-chat";
|
||||
export const QUICK_CHAT_KEY = `websocket:${QUICK_CHAT_ID}`;
|
||||
export const TEMPORARY_CHAT_ID_PREFIX = "temporary-";
|
||||
|
||||
export function isQuickChatKey(key: string | null): boolean {
|
||||
return key === QUICK_CHAT_KEY;
|
||||
@@ -21,18 +20,3 @@ export function quickChatSession(persisted?: ChatSummary): ChatSummary {
|
||||
workspaceScope: persisted?.workspaceScope ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTemporaryChatSession(): ChatSummary {
|
||||
const chatId = `${TEMPORARY_CHAT_ID_PREFIX}${crypto.randomUUID()}`;
|
||||
return {
|
||||
key: `websocket:${chatId}`,
|
||||
channel: "websocket",
|
||||
chatId,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
preview: "",
|
||||
modelPreset: null,
|
||||
runStartedAt: null,
|
||||
workspaceScope: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1247,7 +1247,6 @@ export type InboundEvent =
|
||||
scope?: "metadata" | "thread" | string;
|
||||
workspace_scope?: WorkspaceScopePayload;
|
||||
}
|
||||
| { event: "temporary_chat_discarded"; chat_id: string }
|
||||
| { event: "transcription_result"; request_id: string; text: string }
|
||||
| {
|
||||
event: "transcription_error";
|
||||
@@ -1334,7 +1333,6 @@ export type Outbound =
|
||||
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
||||
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
||||
| { type: "attach"; chat_id: string }
|
||||
| { type: "discard_temporary_chat"; chat_id: string }
|
||||
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
|
||||
| { type: "transcribe_audio"; request_id: string; data_url: string; duration_ms?: number }
|
||||
| {
|
||||
@@ -1347,7 +1345,6 @@ export type Outbound =
|
||||
quoted_context?: string;
|
||||
workspace_scope?: WorkspaceScopePayload;
|
||||
turn_id?: string;
|
||||
temporary?: true;
|
||||
/** Marks messages sent by the embedded WebUI, without changing the
|
||||
* generic websocket protocol for other clients. */
|
||||
webui?: true;
|
||||
|
||||
@@ -12,7 +12,6 @@ const getSessionAutomationsSpy = vi.fn<(key: string) => Promise<SessionAutomatio
|
||||
const toggleThemeSpy = vi.fn();
|
||||
const updateUrlSpy = vi.fn();
|
||||
const attachSpy = vi.fn();
|
||||
const discardTemporaryChatSpy = vi.fn();
|
||||
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||
let mockSessions: ChatSummary[] = [];
|
||||
@@ -220,7 +219,6 @@ vi.mock("@/lib/nanobot-client", () => {
|
||||
sendMessage = vi.fn();
|
||||
newChat = vi.fn();
|
||||
attach = attachSpy;
|
||||
discardTemporaryChat = discardTemporaryChatSpy;
|
||||
close = vi.fn();
|
||||
updateUrl = updateUrlSpy;
|
||||
updateMaxFrameBytes = vi.fn();
|
||||
@@ -248,7 +246,6 @@ describe("App layout", () => {
|
||||
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
|
||||
toggleThemeSpy.mockReset();
|
||||
attachSpy.mockReset();
|
||||
discardTemporaryChatSpy.mockReset();
|
||||
runStatusHandlers.clear();
|
||||
sessionUpdateHandlers.clear();
|
||||
window.history.replaceState(null, "", "/");
|
||||
@@ -352,22 +349,6 @@ describe("App layout", () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("highlights the blank new-topic destination immediately", async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
const newTopicButton = within(sidebar).getByRole("button", { name: "New topic" });
|
||||
|
||||
expect(newTopicButton).toHaveAttribute("aria-current", "page");
|
||||
expect(newTopicButton).not.toHaveClass("bg-sidebar-accent");
|
||||
expect(newTopicButton).toHaveClass("transition-[width,padding,color]");
|
||||
expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute(
|
||||
"data-active-id",
|
||||
"new-chat",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a single fixed Quick Chat without provisioning a new session", async () => {
|
||||
render(<App />);
|
||||
|
||||
@@ -415,28 +396,6 @@ describe("App layout", () => {
|
||||
expect(within(sidebar).queryAllByRole("button", { current: "page" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("enters and destroys Temporary Chat inside Quick Chat", async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Quick Chat" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Temporary" }));
|
||||
|
||||
expect(screen.getByText("Start a temporary chat")).toBeInTheDocument();
|
||||
expect(screen.getByText(/No history, memory, tools, or project access/))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Attach image" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Exit temporary chat" }));
|
||||
|
||||
expect(discardTemporaryChatSpy).toHaveBeenCalledTimes(1);
|
||||
expect(discardTemporaryChatSpy.mock.calls[0][0]).toMatch(/^temporary-/);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What's on your mind?")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("restores Quick Chat before it has a persisted session", async () => {
|
||||
window.history.replaceState(null, "", "/#/quick-chat");
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ function rect({
|
||||
describe("ChatList", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("orders chats by latest session activity by default", () => {
|
||||
@@ -221,20 +220,8 @@ describe("ChatList", () => {
|
||||
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("positions one background highlight and resets it across hidden targets", () => {
|
||||
it("positions one background highlight, then slides it between selected topics", () => {
|
||||
let revealFrame: FrameRequestCallback | null = null;
|
||||
let resizeObserverCallback: ResizeObserverCallback | null = null;
|
||||
let activeTargetVisible = true;
|
||||
class MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeObserverCallback = callback;
|
||||
}
|
||||
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
revealFrame = callback;
|
||||
return 1;
|
||||
@@ -245,9 +232,7 @@ describe("ChatList", () => {
|
||||
return rect({ left: 0, top: 0, width: 300, height: 200 });
|
||||
}
|
||||
if (this.getAttribute("data-chat-row") === "websocket:active") {
|
||||
return activeTargetVisible
|
||||
? rect({ left: 8, top: 12, width: 284, height: 32 })
|
||||
: rect({ left: 0, top: 0, width: 0, height: 0 });
|
||||
return rect({ left: 8, top: 12, width: 284, height: 32 });
|
||||
}
|
||||
if (this.getAttribute("data-chat-row") === "websocket:inactive") {
|
||||
return rect({ left: 8, top: 48, width: 284, height: 40 });
|
||||
@@ -270,7 +255,7 @@ describe("ChatList", () => {
|
||||
const { rerender } = render(
|
||||
<ChatList
|
||||
{...props}
|
||||
activeKey="websocket:active"
|
||||
activeKey={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -280,9 +265,16 @@ describe("ChatList", () => {
|
||||
"transition-[transform,width,height]",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(highlight).toHaveStyle("opacity: 0");
|
||||
expect(screen.queryByTestId("sessions-selection-highlight-surface"))
|
||||
.not.toBeInTheDocument();
|
||||
expect(resizeObserverCallback).not.toBeNull();
|
||||
|
||||
rerender(
|
||||
<ChatList
|
||||
{...props}
|
||||
activeKey="websocket:active"
|
||||
/>,
|
||||
);
|
||||
|
||||
const activeButton = screen.getByTitle("Active topic");
|
||||
expect(activeButton).toHaveAttribute("aria-current", "page");
|
||||
@@ -292,10 +284,6 @@ describe("ChatList", () => {
|
||||
"bg-sidebar-accent",
|
||||
"shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
|
||||
);
|
||||
expect(highlight).toHaveClass(
|
||||
"transition-[transform,width,height]",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(highlight).toHaveStyle(
|
||||
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
|
||||
);
|
||||
@@ -303,17 +291,6 @@ describe("ChatList", () => {
|
||||
revealFrame?.(0);
|
||||
expect(highlight.style.transitionProperty).toBe("");
|
||||
|
||||
activeTargetVisible = false;
|
||||
resizeObserverCallback?.([], {} as ResizeObserver);
|
||||
expect(highlight).toHaveStyle("opacity: 0");
|
||||
|
||||
activeTargetVisible = true;
|
||||
resizeObserverCallback?.([], {} as ResizeObserver);
|
||||
expect(highlight).toHaveStyle(
|
||||
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
|
||||
);
|
||||
revealFrame?.(0);
|
||||
|
||||
rerender(
|
||||
<ChatList
|
||||
{...props}
|
||||
@@ -326,9 +303,6 @@ describe("ChatList", () => {
|
||||
expect(highlight).toHaveStyle(
|
||||
"width: 284px; height: 40px; transform: translate3d(8px, 48px, 0)",
|
||||
);
|
||||
|
||||
rerender(<ChatList {...props} activeKey={null} />);
|
||||
expect(highlight).toHaveStyle("opacity: 0");
|
||||
});
|
||||
|
||||
it("can collapse a project group and keeps project rename separate from chat titles", async () => {
|
||||
|
||||
@@ -70,42 +70,6 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("NanobotClient", () => {
|
||||
it("does not attach or retain temporary chats across reconnects", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const handler = vi.fn();
|
||||
client.onChat("temporary-one", handler, { temporary: true });
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
|
||||
expect(lastSocket().sent).toEqual([]);
|
||||
|
||||
client.sendMessage("temporary-one", "hello", undefined, {
|
||||
temporary: true,
|
||||
turnId: "turn-temp",
|
||||
});
|
||||
expect(JSON.parse(lastSocket().sent.at(-1)!)).toMatchObject({
|
||||
type: "message",
|
||||
chat_id: "temporary-one",
|
||||
temporary: true,
|
||||
});
|
||||
|
||||
client.discardTemporaryChat("temporary-one");
|
||||
expect(JSON.parse(lastSocket().sent.at(-1)!)).toEqual({
|
||||
type: "discard_temporary_chat",
|
||||
chat_id: "temporary-one",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "message",
|
||||
chat_id: "temporary-one",
|
||||
text: "late",
|
||||
});
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes events to the matching chat handler", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createTemporaryChatSession,
|
||||
isQuickChatKey,
|
||||
QUICK_CHAT_ID,
|
||||
QUICK_CHAT_KEY,
|
||||
quickChatSession,
|
||||
TEMPORARY_CHAT_ID_PREFIX,
|
||||
} from "@/lib/quick-chat";
|
||||
|
||||
describe("Quick Chat identity", () => {
|
||||
@@ -36,14 +34,4 @@ describe("Quick Chat identity", () => {
|
||||
modelPreset: "fast",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates isolated temporary identities without replacing Quick Chat", () => {
|
||||
const first = createTemporaryChatSession();
|
||||
const second = createTemporaryChatSession();
|
||||
|
||||
expect(first.chatId).toMatch(new RegExp(`^${TEMPORARY_CHAT_ID_PREFIX}`));
|
||||
expect(first.key).toBe(`websocket:${first.chatId}`);
|
||||
expect(first.key).not.toBe(second.key);
|
||||
expect(isQuickChatKey(first.key)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2576,17 +2576,4 @@ describe("ThreadComposer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("removes every attachment entry point when attachments are disabled", () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
allowAttachments={false}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Attach image" })).not.toBeInTheDocument();
|
||||
expect(document.querySelector('input[type="file"]')).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -410,9 +410,6 @@ describe("ThreadMotionCoordinator", () => {
|
||||
expect(camera.jumpTo).toHaveBeenCalledWith(780);
|
||||
|
||||
coordinator.takeUserControl();
|
||||
expect(coordinator.observeScroll(true)).toBe("user");
|
||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||
|
||||
expect(coordinator.observeScroll(false)).toBe("user");
|
||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||
|
||||
@@ -420,57 +417,6 @@ describe("ThreadMotionCoordinator", () => {
|
||||
expect(coordinator.snapshot().mode).toBe("anchor-prompt");
|
||||
});
|
||||
|
||||
it("resumes shallow history browsing when user intent turns toward latest", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
} = motionHarness({
|
||||
scrollTop: 1_400,
|
||||
});
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
camera.followTo.mockClear();
|
||||
|
||||
coordinator.handleUserScrollIntent(true);
|
||||
expect(coordinator.observeScroll(true)).toBe("user");
|
||||
advanceFrame();
|
||||
expect(camera.followTo).not.toHaveBeenCalled();
|
||||
|
||||
coordinator.handleUserScrollIntent(true, true);
|
||||
expect(coordinator.observeScroll(true)).toBe("automatic");
|
||||
expect(coordinator.snapshot().mode).toBe("follow-output");
|
||||
advanceFrame();
|
||||
expect(camera.followTo).toHaveBeenCalledWith(1_400);
|
||||
});
|
||||
|
||||
it("resumes shallow history browsing from forward intent at the boundary", () => {
|
||||
const {
|
||||
advanceFrame,
|
||||
coordinator,
|
||||
onAutoFollow,
|
||||
} = motionHarness({
|
||||
scrollTop: 1_400,
|
||||
});
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
|
||||
coordinator.handleUserScrollIntent(true);
|
||||
expect(coordinator.observeScroll(true)).toBe("user");
|
||||
|
||||
coordinator.handleUserScrollIntent(false, true);
|
||||
expect(coordinator.snapshot().mode).toBe("follow-output");
|
||||
expect(onAutoFollow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves history browsing when an active turn is cleared", () => {
|
||||
const {
|
||||
camera,
|
||||
|
||||
@@ -86,22 +86,6 @@ function makeClient() {
|
||||
runStartedAtByChatId.delete(chatId);
|
||||
return true;
|
||||
});
|
||||
const onChat = vi.fn((
|
||||
chatId: string,
|
||||
handler: (ev: import("@/lib/types").InboundEvent) => void,
|
||||
options?: { temporary?: boolean },
|
||||
) => {
|
||||
void options;
|
||||
let handlers = chatHandlers.get(chatId);
|
||||
if (!handlers) {
|
||||
handlers = new Set();
|
||||
chatHandlers.set(chatId, handlers);
|
||||
}
|
||||
handlers.add(handler);
|
||||
return () => {
|
||||
handlers?.delete(handler);
|
||||
};
|
||||
});
|
||||
return {
|
||||
get status() {
|
||||
return status;
|
||||
@@ -128,7 +112,17 @@ function makeClient() {
|
||||
canReconcileCanonicalCompletion,
|
||||
reconcileCanonicalCompletion,
|
||||
getGoalState: (chatId: string) => goalStateByChatId.get(chatId),
|
||||
onChat,
|
||||
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
|
||||
let handlers = chatHandlers.get(chatId);
|
||||
if (!handlers) {
|
||||
handlers = new Set();
|
||||
chatHandlers.set(chatId, handlers);
|
||||
}
|
||||
handlers.add(handler);
|
||||
return () => {
|
||||
handlers?.delete(handler);
|
||||
};
|
||||
},
|
||||
onError: (handler: (err: StreamError) => void) => {
|
||||
errorHandlers.add(handler);
|
||||
return () => {
|
||||
@@ -3375,32 +3369,6 @@ describe("ThreadShell", () => {
|
||||
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks every temporary chat subscription as temporary", async () => {
|
||||
const client = makeClient();
|
||||
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("temporary-test")}
|
||||
title="Temporary Chat"
|
||||
onToggleSidebar={() => {}}
|
||||
temporary
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const temporaryCalls = client.onChat.mock.calls.filter(
|
||||
([chatId]) => chatId === "temporary-test",
|
||||
);
|
||||
expect(temporaryCalls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(temporaryCalls.every(([, , options]) => (
|
||||
options?.temporary === true
|
||||
))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("removes session-management affordances from a fixed conversation", async () => {
|
||||
const client = makeClient();
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -763,101 +763,6 @@ describe("ThreadViewport", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps shallow wheel and touch scrolling user-owned until intent reverses", async () => {
|
||||
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo");
|
||||
const threaded: UIMessage[] = [
|
||||
{ id: "u1", role: "user", content: "old question", turnId: "turn-1", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "old answer", turnId: "turn-1", createdAt: 2 },
|
||||
{ id: "u2", role: "user", content: "new question", turnId: "turn-2", createdAt: 3 },
|
||||
];
|
||||
const answer: UIMessage = {
|
||||
id: "a2",
|
||||
role: "assistant",
|
||||
content: "streaming answer",
|
||||
turnId: "turn-2",
|
||||
isStreaming: true,
|
||||
createdAt: 4,
|
||||
};
|
||||
const { container, rerender } = render(
|
||||
<ThreadViewport
|
||||
messages={threaded}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
/>,
|
||||
);
|
||||
const scroller = getScroller(container);
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 1_904 },
|
||||
clientHeight: { configurable: true, value: 500 },
|
||||
scrollTop: { configurable: true, writable: true, value: 1_404 },
|
||||
});
|
||||
const prompt = container.querySelector<HTMLElement>('[data-user-prompt-id="u2"]');
|
||||
expect(prompt).not.toBeNull();
|
||||
Object.defineProperty(prompt, "offsetTop", {
|
||||
configurable: true,
|
||||
value: 1_420,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={[...threaded, answer]}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
activeTurnId="turn-2"
|
||||
activeTurnStartedHere
|
||||
/>,
|
||||
);
|
||||
await flushAnimationFrame();
|
||||
followTo.mockClear();
|
||||
|
||||
act(() => {
|
||||
fireEvent.wheel(scroller, { deltaY: -24 });
|
||||
scroller.scrollTop = 1_380;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await flushAnimationFrame();
|
||||
|
||||
expect(followTo).not.toHaveBeenCalled();
|
||||
expect(scroller.scrollTop).toBe(1_380);
|
||||
expect(screen.getByRole("button", { name: "Scroll to bottom" })).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
scroller.scrollTop = 1_404;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
fireEvent.wheel(scroller, { deltaY: 24 });
|
||||
});
|
||||
await flushAnimationFrame();
|
||||
|
||||
expect(followTo).toHaveBeenCalledWith(1_404);
|
||||
expect(scroller.scrollTop).toBe(1_404);
|
||||
expect(screen.queryByRole("button", { name: "Scroll to bottom" }))
|
||||
.not.toBeInTheDocument();
|
||||
|
||||
followTo.mockClear();
|
||||
act(() => {
|
||||
fireEvent.touchStart(scroller, { touches: [{ clientY: 300 }] });
|
||||
fireEvent.touchMove(scroller, { touches: [{ clientY: 324 }] });
|
||||
scroller.scrollTop = 1_380;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await flushAnimationFrame();
|
||||
|
||||
expect(followTo).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: "Scroll to bottom" })).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
fireEvent.touchMove(scroller, { touches: [{ clientY: 300 }] });
|
||||
scroller.scrollTop = 1_404;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
fireEvent.touchEnd(scroller);
|
||||
});
|
||||
await flushAnimationFrame();
|
||||
|
||||
expect(followTo).toHaveBeenCalledWith(1_404);
|
||||
expect(screen.queryByRole("button", { name: "Scroll to bottom" }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the scroll-to-bottom button above a growing composer", async () => {
|
||||
const resizeObserver = stubResizeObserver();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user