Compare commits

..
Author SHA1 Message Date
Xubin Ren 3d14bcaf72 refactor(webui): reuse sidebar selection highlight 2026-07-31 00:59:35 +08:00
Xubin Ren 47d83af0b6 feat(webui): add persistent Quick Chat 2026-07-31 00:53:10 +08:00
80 changed files with 340 additions and 2482 deletions
+2 -1
View File
@@ -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 conversation, which helps with multi-step tasks. Supported providers can also
compact long conversations automatically. 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 Native compaction is also automatic when the provider supports it. The
threshold is derived from the active model's context window and reserved output threshold is derived from the active model's context window and reserved output
headroom; no provider configuration is required. headroom; no provider configuration is required.
-2
View File
@@ -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. `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 ### Custom OpenAI-Compatible Endpoint
The `custom` provider fits one OpenAI-compatible endpoint that is not represented by a named provider. The `custom` provider fits one OpenAI-compatible endpoint that is not represented by a named provider.
+3 -14
View File
@@ -134,21 +134,10 @@ class AutoCompact:
if entry: if entry:
return session, self._format_summary(entry[0], entry[1]) return session, self._format_summary(entry[0], entry[1])
# Cold path: summary persisted in session metadata (process restarted). # 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") meta = session.metadata.get("_last_summary")
if isinstance(meta, dict): if isinstance(meta, dict):
summary_meta = cast(dict[str, object], meta) return session, self._format_summary(
text = summary_meta.get("text") cast(str, meta["text"]),
if isinstance(text, str) and text: datetime.fromisoformat(cast(str, meta["last_active"])),
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
) )
except ValueError:
last_active = session.updated_at
return session, self._format_summary(text, last_active)
return session, None return session, None
+6 -6
View File
@@ -217,18 +217,16 @@ class ContextBuilder:
include_memory_recent_history: bool = True, include_memory_recent_history: bool = True,
session_key: str | None = None, session_key: str | None = None,
unified_session: bool = False, unified_session: bool = False,
conversation_only: bool = False,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call.""" """Build the complete message list for an LLM call."""
messages = list(history)
if not conversation_only:
root = workspace or self.workspace root = workspace or self.workspace
active_skill_names = ( active_skill_names = (
self.skills.get_explicitly_invoked_skills(current_message) self.skills.get_explicitly_invoked_skills(current_message)
if current_role == "user" if current_role == "user"
else [] else []
) )
messages.insert(0, { messages: list[dict[str, Any]] = [
{
"role": "system", "role": "system",
"content": self.build_system_prompt( "content": self.build_system_prompt(
active_skill_names=active_skill_names, active_skill_names=active_skill_names,
@@ -239,14 +237,16 @@ class ContextBuilder:
session_key=session_key, session_key=session_key,
unified_session=unified_session, unified_session=unified_session,
), ),
}) },
*history,
]
current = self.build_current_message( current = self.build_current_message(
current_message, current_message,
media=media, media=media,
current_role=current_role, current_role=current_role,
runtime_context_blocks=runtime_context_blocks, 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 = dict(messages[-1])
last["content"] = self._merge_message_content( last["content"] = self._merge_message_content(
last.get("content"), last.get("content"),
+7 -31
View File
@@ -723,7 +723,6 @@ class AgentLoop:
include_memory_recent_history=not ctx.ephemeral, include_memory_recent_history=not ctx.ephemeral,
session_key=ctx.session.key, session_key=ctx.session.key,
unified_session=self._unified_session, unified_session=self._unified_session,
conversation_only=ctx.session.transient is True,
) )
def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext: def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext:
@@ -751,12 +750,10 @@ class AgentLoop:
self, self,
ctx: TurnContext, ctx: TurnContext,
) -> list[RuntimeContextBlock]: ) -> list[RuntimeContextBlock]:
if ctx.require_session().transient is True:
return []
assert ctx.request_context is not None assert ctx.request_context is not None
return await self._resolve_runtime_context_for_request( return await self._resolve_runtime_context_for_request(
ctx.request_context, 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( async def _resolve_runtime_context_for_request(
@@ -787,24 +784,18 @@ class AgentLoop:
else: else:
logger.warning("Command '{}' matched but dispatch returned None", raw) logger.warning("Command '{}' matched but dispatch returned None", raw)
async def cancel_active_turn(self, key: str) -> int: async def _cancel_active_tasks(self, key: str) -> int:
"""Cancel active work and discard queued follow-ups for *key*. """Cancel and await all active tasks and subagents for *key*.
Returns the total number of cancelled tasks + subagents. 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())) tasks = tuple(self._active_tasks.pop(key, set()))
cancelled = sum(1 for t in tasks if not t.done() and t.cancel()) cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
for t in tasks: for t in tasks:
with suppress(asyncio.CancelledError, Exception): with suppress(asyncio.CancelledError, Exception):
await t await t
sub_cancelled = await self.subagents.cancel_by_session(key) 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: def _effective_session_key(self, msg: InboundMessage) -> str:
"""Return the session key used for task routing and mid-turn injections.""" """Return the session key used for task routing and mid-turn injections."""
@@ -931,10 +922,7 @@ class AgentLoop:
if isinstance(metadata_value, dict) if isinstance(metadata_value, dict)
else {} else {}
) )
if ( if pending_msg.channel != "system":
pending_msg.channel != "system"
and not (session is not None and session.transient is True)
):
scope = self.workspace_scopes.for_turn( scope = self.workspace_scopes.for_turn(
channel=pending_msg.channel, channel=pending_msg.channel,
message_metadata=metadata, message_metadata=metadata,
@@ -1014,7 +1002,7 @@ class AgentLoop:
message_metadata=metadata, message_metadata=metadata,
session_metadata=session.metadata if session is not None else None, 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( request_ctx = request_context or RequestContext(
channel=channel, channel=channel,
chat_id=chat_id, chat_id=chat_id,
@@ -1172,11 +1160,6 @@ class AgentLoop:
effective_key = self._effective_session_key(msg) effective_key = self._effective_session_key(msg)
if await agent_context.handle_runtime_control(self, msg, self.tools): if await agent_context.handle_runtime_control(self, msg, self.tools):
continue continue
if (
msg.transient_session
and not self.sessions.is_transient_active(effective_key)
):
continue
if self.commands.is_priority(raw): if self.commands.is_priority(raw):
await self._dispatch_command_inline( await self._dispatch_command_inline(
msg, effective_key, raw, msg, effective_key, raw,
@@ -1288,8 +1271,6 @@ class AgentLoop:
session_key, session_key,
exc_info=True, exc_info=True,
) )
if msg.transient_session:
raise
# Preserve partial context from the interrupted turn so # Preserve partial context from the interrupted turn so
# the user does not lose tool results and assistant # the user does not lose tool results and assistant
# messages accumulated before /stop. The checkpoint was # messages accumulated before /stop. The checkpoint was
@@ -1592,16 +1573,13 @@ class AgentLoop:
if ctx.session is None: if ctx.session is None:
ctx.session = self.sessions.get_or_create(ctx.session_key) ctx.session = self.sessions.get_or_create(ctx.session_key)
session = ctx.session session = ctx.session
if session.transient is True:
ctx.ephemeral = True
ctx.tools = ToolRegistry()
self._remember_unified_session_route( self._remember_unified_session_route(
session, session,
msg, msg,
is_user_turn=ctx.original_user_text is not None, is_user_turn=ctx.original_user_text is not None,
) )
await ctx.delivery.started() 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) self.workspace_scopes.persist_message_scope(session, msg)
if self._restore_runtime_checkpoint(session): if self._restore_runtime_checkpoint(session):
@@ -1611,8 +1589,6 @@ class AgentLoop:
async def _compact_session(self, ctx: TurnContext) -> None: async def _compact_session(self, ctx: TurnContext) -> None:
session = ctx.require_session() session = ctx.require_session()
if session.transient is True:
return
ctx.session, pending = self.auto_compact.prepare_session( ctx.session, pending = self.auto_compact.prepare_session(
session, session,
ctx.session_key, ctx.session_key,
+5 -4
View File
@@ -713,10 +713,11 @@ class MemoryStore:
if tools_used if tools_used
else "" else ""
) )
raw_timestamp = message.get("timestamp") timestamp = cast(str, message.get("timestamp", "?"))
timestamp = str(raw_timestamp) if raw_timestamp is not None else "?" role = cast(str, message["role"])
role = str(message.get("role") or "unknown") lines.append(
lines.append(f"[{timestamp[:16]}] {role.upper()}{tools}: {content}") f"[{timestamp[:16]}] {role.upper()}{tools}: {content}"
)
return "\n".join(lines) return "\n".join(lines)
def raw_archive( def raw_archive(
+1 -1
View File
@@ -660,7 +660,7 @@ class WriteStdinTool(Tool):
close_stdin=close_stdin if first else False, close_stdin=close_stdin if first else False,
terminate=terminate if first else False, terminate=terminate if first else False,
yield_time_ms=step_ms, yield_time_ms=step_ms,
max_output_chars=MAX_OUTPUT_CHARS, max_output_chars=max_output_chars,
owner_session_key=current_request_session_key(), owner_session_key=current_request_session_key(),
) )
first = False first = False
-2
View File
@@ -18,7 +18,6 @@ INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
RUNTIME_CONTROL_ACK = "_ack" RUNTIME_CONTROL_ACK = "_ack"
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload" RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload" RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
INBOUND_META_TRANSIENT_SESSION = "_transient_session"
@dataclass @dataclass
@@ -33,7 +32,6 @@ class InboundMessage:
media: list[str] = field(default_factory=list) # Media URLs media: list[str] = field(default_factory=list) # Media URLs
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
session_key_override: str | None = None # Optional override for thread-scoped sessions 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 @property
def session_key(self) -> str: def session_key(self) -> str:
+2 -8
View File
@@ -8,11 +8,7 @@ from typing import Any, cast
from loguru import logger from loguru import logger
from nanobot.bus.events import ( from nanobot.bus.events import InboundMessage, OutboundMessage
INBOUND_META_TRANSIENT_SESSION,
InboundMessage,
OutboundMessage,
)
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.pairing import ( from nanobot.pairing import (
PAIRING_CODE_META_KEY, PAIRING_CODE_META_KEY,
@@ -281,8 +277,7 @@ class BaseChannel(ABC):
) )
return return
meta = dict(metadata or {}) meta = metadata or {}
transient_session = meta.pop(INBOUND_META_TRANSIENT_SESSION, False) is True
if self.supports_streaming: if self.supports_streaming:
meta = {**meta, "_wants_stream": True} meta = {**meta, "_wants_stream": True}
@@ -294,7 +289,6 @@ class BaseChannel(ABC):
media=media or [], media=media or [],
metadata=meta, metadata=meta,
session_key_override=session_key, session_key_override=session_key,
transient_session=transient_session,
) )
await self.bus.publish_inbound(msg) await self.bus.publish_inbound(msg)
+1 -4
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import inspect import inspect
from collections.abc import Awaitable, Callable, Iterable from collections.abc import Callable, Iterable
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
@@ -97,7 +97,6 @@ class ChannelManager:
webui_runtime_model_name: Callable[[], str | None] | None = None, webui_runtime_model_name: Callable[[], str | None] | None = None,
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None, webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
webui_local_trigger_pending_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_static_dist: bool = True,
webui_runtime_surface: str = "browser", webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None, webui_runtime_capabilities: dict[str, Any] | None = None,
@@ -111,7 +110,6 @@ class ChannelManager:
self._webui_runtime_model_name = webui_runtime_model_name self._webui_runtime_model_name = webui_runtime_model_name
self._webui_cron_pending_job_ids = webui_cron_pending_job_ids self._webui_cron_pending_job_ids = webui_cron_pending_job_ids
self._webui_local_trigger_pending_ids = webui_local_trigger_pending_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_static_dist = webui_static_dist
self._webui_runtime_surface = webui_runtime_surface self._webui_runtime_surface = webui_runtime_surface
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {}) self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
@@ -180,7 +178,6 @@ class ChannelManager:
local_trigger_store=self._local_trigger_store, local_trigger_store=self._local_trigger_store,
cron_pending_job_ids=self._webui_cron_pending_job_ids, cron_pending_job_ids=self._webui_cron_pending_job_ids,
local_trigger_pending_ids=self._webui_local_trigger_pending_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_feature_action=self.apply_channel_feature_action,
channel_runtime_status=self.get_status, channel_runtime_status=self.get_status,
skill_state_action=self._webui_skill_state_action, skill_state_action=self._webui_skill_state_action,
+6 -5
View File
@@ -493,11 +493,12 @@ class SlackChannel(BaseChannel):
except Exception as e: except Exception as e:
self.logger.debug("reactions_add failed: {}", e) self.logger.debug("reactions_add failed: {}", e)
# Thread-scoped session key whenever the turn lives in a thread: either the # Thread-scoped session key whenever the user is in a real thread
# message arrived inside one (raw_thread_ts) or reply_in_thread opens a new # (raw_thread_ts is set). DM threads get their own session, separate
# thread for this channel message. DM roots have no thread_ts and keep the # from the DM root, so context doesn't bleed across thread boundaries.
# default per-chat session, so context doesn't bleed across thread boundaries. session_key = (
session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts else None f"slack:{chat_id}:{thread_ts}" if thread_ts and raw_thread_ts else None
)
media_paths: list[str] = [] media_paths: list[str] = []
file_markers: list[str] = [] file_markers: list[str] = []
for file_info in _as_json_list(event.get("files")) or []: 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" 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 @pytest.mark.asyncio
async def test_slack_slash_command_skips_thread_context() -> None: async def test_slack_slash_command_skips_thread_context() -> None:
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus()) channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
+13 -202
View File
@@ -18,11 +18,7 @@ from websockets.asyncio.server import ServerConnection, serve, unix_serve
from websockets.exceptions import ConnectionClosed from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest from websockets.http11 import Request as WsRequest
from nanobot.bus.events import ( from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
INBOUND_META_TRANSIENT_SESSION,
OUTBOUND_META_AGENT_UI,
OutboundMessage,
)
from nanobot.bus.outbound_events import ( from nanobot.bus.outbound_events import (
GoalStateSyncEvent, GoalStateSyncEvent,
GoalStatusEvent, GoalStatusEvent,
@@ -36,10 +32,6 @@ from nanobot.bus.outbound_events import (
) )
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel 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.command.builtin import builtin_command_starts_agent_turn
from nanobot.config.schema import Base from nanobot.config.schema import Base
from nanobot.runtime_context import ( 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. # Plain HTTP WebUI routes also run through websockets.process_request.
_WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0 _WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0
_TEMPORARY_CHAT_ID_PREFIX = "temporary-"
_TEMPORARY_COMMANDS = frozenset({"/model", "/stop"})
class WebSocketConfig(Base): 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 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: 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. """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._workspaces = gateway.workspaces
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {} 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 ------------------------------------------- # -- Subscription bookkeeping -------------------------------------------
@@ -318,23 +297,6 @@ class WebSocketChannel(BaseChannel):
self._subs.setdefault(chat_id, set()).add(connection) self._subs.setdefault(chat_id, set()).add(connection)
self._conn_chats.setdefault(connection, set()).add(chat_id) 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( async def send_webui_protocol_error(
self, self,
connection: ServerConnection, connection: ServerConnection,
@@ -363,13 +325,16 @@ class WebSocketChannel(BaseChannel):
) )
await self._hydrate_after_subscribe(fork_id) 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.""" """Remove *connection* from every subscription set; safe to call multiple times."""
try: chat_ids = self._conn_chats.pop(connection, set())
await self._temporary_chats.discard_owner(connection) for cid in chat_ids:
finally: subs = self._subs.get(cid)
for chat_id in tuple(self._conn_chats.get(connection, ())): if subs is None:
self._detach(connection, chat_id) continue
subs.discard(connection)
if not subs:
self._subs.pop(cid, None)
self._conn_default.pop(connection, None) self._conn_default.pop(connection, None)
self._webui_connections.discard(connection) self._webui_connections.discard(connection)
@@ -422,7 +387,7 @@ class WebSocketChannel(BaseChannel):
try: try:
await connection.send(raw) await connection.send(raw)
except ConnectionClosed: except ConnectionClosed:
await self._cleanup_connection(connection) self._cleanup_connection(connection)
except Exception as e: except Exception as e:
self.logger.warning("failed to send {} event: {}", event, e) self.logger.warning("failed to send {} event: {}", event, e)
@@ -644,7 +609,7 @@ class WebSocketChannel(BaseChannel):
except Exception as e: except Exception as e:
self.logger.debug("connection ended: {}", e) self.logger.debug("connection ended: {}", e)
finally: finally:
await self._cleanup_connection(connection) self._cleanup_connection(connection)
# -- Inbound WebSocket envelopes --------------------------------------- # -- Inbound WebSocket envelopes ---------------------------------------
@@ -682,36 +647,11 @@ class WebSocketChannel(BaseChannel):
if t == "fork_chat": if t == "fork_chat":
await handle_webui_fork_chat(self, connection, envelope) await handle_webui_fork_chat(self, connection, envelope)
return 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": if t == "attach":
cid = envelope.get("chat_id") cid = envelope.get("chat_id")
if not _is_valid_chat_id(cid): if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid chat_id") await self._send_event(connection, "error", detail="invalid chat_id")
return 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) self._attach(connection, cid)
await self._send_event(connection, "attached", chat_id=cid) await self._send_event(connection, "attached", chat_id=cid)
await self._hydrate_after_subscribe(cid) await self._hydrate_after_subscribe(cid)
@@ -721,14 +661,6 @@ class WebSocketChannel(BaseChannel):
if not _is_valid_chat_id(cid): if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid chat_id") await self._send_event(connection, "error", detail="invalid chat_id")
return 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( scope = await self._workspace_scope_or_error(
connection, connection,
lambda: self._workspaces.scope_for_set_request( lambda: self._workspaces.scope_for_set_request(
@@ -760,15 +692,6 @@ class WebSocketChannel(BaseChannel):
if not _is_valid_chat_id(cid): if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid chat_id") await self._send_event(connection, "error", detail="invalid chat_id")
return 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") raw_turn_id = envelope.get("turn_id")
turn_id = raw_turn_id if isinstance(raw_turn_id, str) and raw_turn_id else None turn_id = raw_turn_id if isinstance(raw_turn_id, str) and raw_turn_id else None
rejection_fields = { rejection_fields = {
@@ -805,17 +728,6 @@ class WebSocketChannel(BaseChannel):
**rejection_fields, **rejection_fields,
) )
return 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") raw_media = envelope.get("media")
media_paths: list[str] = [] media_paths: list[str] = []
@@ -937,103 +849,6 @@ class WebSocketChannel(BaseChannel):
return return
await self._send_event(connection, "error", detail=f"unknown type: {t!r}") 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( async def _workspace_scope_or_error(
self, self,
connection: ServerConnection, connection: ServerConnection,
@@ -1074,8 +889,6 @@ class WebSocketChannel(BaseChannel):
except Exception as e: except Exception as e:
self.logger.warning("server task error during shutdown: {}", e) self.logger.warning("server task error during shutdown: {}", e)
self._server_task = None self._server_task = None
for connection in tuple(self._conn_chats):
await self._temporary_chats.discard_owner(connection)
self._subs.clear() self._subs.clear()
self._conn_chats.clear() self._conn_chats.clear()
self._conn_default.clear() self._conn_default.clear()
@@ -1093,7 +906,7 @@ class WebSocketChannel(BaseChannel):
try: try:
await connection.send(raw) await connection.send(raw)
except ConnectionClosed: except ConnectionClosed:
await self._cleanup_connection(connection) self._cleanup_connection(connection)
self.logger.warning("connection gone{}", label) self.logger.warning("connection gone{}", label)
except Exception: except Exception:
self.logger.exception("send failed{}", label) self.logger.exception("send failed{}", label)
@@ -1110,8 +923,6 @@ class WebSocketChannel(BaseChannel):
transcript_overrides: dict[str, Any] | None = None, transcript_overrides: dict[str, Any] | None = None,
) -> bool: ) -> bool:
"""Persist one canonical turn event and retain unsafe owners on failure.""" """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( persisted = self._transcripts.prepare_and_append(
chat_id, chat_id,
event, 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_model_name=None,
runtime_surface=kw.get("runtime_surface", "browser"), runtime_surface=kw.get("runtime_surface", "browser"),
runtime_capabilities_overrides=kw.get("runtime_capabilities_overrides"), 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() 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 @pytest.mark.asyncio
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None: async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
class Conn: class Conn:
+2 -25
View File
@@ -230,30 +230,9 @@ class WeixinChannel(BaseChannel):
self.logger.error("Failed to load Weixin account state", exc_info=True) self.logger.error("Failed to load Weixin account state", exc_info=True)
return False return False
def _save_state(self, *, force: bool = False) -> None: def _save_state(self) -> None:
state_file = self._get_state_dir() / "account.json" state_file = self._get_state_dir() / "account.json"
with suppress(Exception): 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 = { data = {
"token": self._token, "token": self._token,
"get_updates_buf": self._get_updates_buf, "get_updates_buf": self._get_updates_buf,
@@ -510,7 +489,7 @@ class WeixinChannel(BaseChannel):
self._token = token self._token = token
if base_url: if base_url:
self.config.base_url = base_url self.config.base_url = base_url
self._save_state(force=True) self._save_state()
async def connect_close_client(self) -> None: async def connect_close_client(self) -> None:
self._running = False self._running = False
@@ -634,8 +613,6 @@ class WeixinChannel(BaseChannel):
remaining = self._session_pause_remaining_s() remaining = self._session_pause_remaining_s()
if remaining > 0: if remaining > 0:
await asyncio.sleep(remaining) await asyncio.sleep(remaining)
if not self.config.token:
self._load_state()
return return
body: dict[str, Any] = { 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"} 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 @pytest.mark.asyncio
async def test_process_message_deduplicates_inbound_ids() -> None: async def test_process_message_deduplicates_inbound_ids() -> None:
channel, bus = _make_channel() 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 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 @pytest.mark.asyncio
async def test_qr_login_refreshes_expired_qr_and_then_succeeds( async def test_qr_login_refreshes_expired_qr_and_then_succeeds(
no_qr_poll_delay, no_qr_poll_delay,
+9 -8
View File
@@ -1,5 +1,7 @@
"""Typer commands for foreground and background gateway control.""" """Typer commands for foreground and background gateway control."""
# pyright: reportUnusedFunction=false
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
@@ -133,9 +135,8 @@ def create_gateway_app(
console.print() console.print()
console.print(result.content) console.print(result.content)
# Typer consumes these callbacks through decorator registration.
@gateway_app.callback(invoke_without_command=True) @gateway_app.callback(invoke_without_command=True)
def gateway( # pyright: ignore[reportUnusedFunction] def gateway(
ctx: typer.Context, ctx: typer.Context,
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"), port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
@@ -190,7 +191,7 @@ def create_gateway_app(
) )
@gateway_app.command("status") @gateway_app.command("status")
def gateway_status( # pyright: ignore[reportUnusedFunction] def gateway_status(
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
) -> None: ) -> None:
@@ -198,7 +199,7 @@ def create_gateway_app(
print_status(runtime_for_instance(workspace=workspace, config=config).status()) print_status(runtime_for_instance(workspace=workspace, config=config).status())
@gateway_app.command("logs") @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"), 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"), follow: bool = typer.Option(True, "--follow/--no-follow", help="Follow new log output"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
@@ -216,7 +217,7 @@ def create_gateway_app(
console.print(line) console.print(line)
@gateway_app.command("stop") @gateway_app.command("stop")
def gateway_stop( # pyright: ignore[reportUnusedFunction] def gateway_stop(
timeout: int = typer.Option(20, "--timeout", help="Stop timeout in seconds"), timeout: int = typer.Option(20, "--timeout", help="Stop timeout in seconds"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), 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) raise typer.Exit(1)
@gateway_app.command("restart") @gateway_app.command("restart")
def gateway_restart( # pyright: ignore[reportUnusedFunction] def gateway_restart(
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"), port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
@@ -265,7 +266,7 @@ def create_gateway_app(
raise typer.Exit(1) raise typer.Exit(1)
@gateway_app.command("install-service") @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"), port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
@@ -301,7 +302,7 @@ def create_gateway_app(
raise typer.Exit(1) raise typer.Exit(1)
@gateway_app.command("uninstall-service") @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"), name: str = typer.Option("nanobot-gateway", "--name", help="Service name"),
manager: ServiceManagerKind = typer.Option("auto", "--manager", help="auto, systemd, or launchd"), manager: ServiceManagerKind = typer.Option("auto", "--manager", help="auto, systemd, or launchd"),
dry_run: bool = typer.Option(False, "--dry-run", help="Print actions without uninstalling"), dry_run: bool = typer.Option(False, "--dry-run", help="Print actions without uninstalling"),
-1
View File
@@ -581,7 +581,6 @@ def _run_gateway(
webui_runtime_model_name=_webui_runtime_model_name, webui_runtime_model_name=_webui_runtime_model_name,
webui_cron_pending_job_ids=agent.pending_cron_job_ids_for_session, 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_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_static_dist=webui_static_dist,
webui_runtime_surface=webui_runtime_surface, webui_runtime_surface=webui_runtime_surface,
webui_runtime_capabilities=webui_runtime_capabilities, webui_runtime_capabilities=webui_runtime_capabilities,
+11 -16
View File
@@ -1,5 +1,7 @@
"""Interactive onboarding questionnaire for nanobot.""" """Interactive onboarding questionnaire for nanobot."""
# pyright: reportMissingTypeStubs=false, reportUnusedFunction=false
import asyncio import asyncio
import json import json
import types import types
@@ -204,36 +206,35 @@ def _select_with_back(
# Key bindings # Key bindings
bindings = KeyBindings() bindings = KeyBindings()
# KeyBindings consumes these handlers through decorator registration.
@bindings.add(Keys.Up) @bindings.add(Keys.Up)
def _up(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction] def _up(event: KeyPressEvent) -> None:
nonlocal selected_index nonlocal selected_index
selected_index = (selected_index - 1) % len(choices) selected_index = (selected_index - 1) % len(choices)
event.app.invalidate() event.app.invalidate()
@bindings.add(Keys.Down) @bindings.add(Keys.Down)
def _down(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction] def _down(event: KeyPressEvent) -> None:
nonlocal selected_index nonlocal selected_index
selected_index = (selected_index + 1) % len(choices) selected_index = (selected_index + 1) % len(choices)
event.app.invalidate() event.app.invalidate()
@bindings.add(Keys.Enter) @bindings.add(Keys.Enter)
def _enter(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction] def _enter(event: KeyPressEvent) -> None:
state["result"] = choices[selected_index] state["result"] = choices[selected_index]
event.app.exit() event.app.exit()
@bindings.add("escape") @bindings.add("escape")
def _escape(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction] def _escape(event: KeyPressEvent) -> None:
state["result"] = _BACK_PRESSED state["result"] = _BACK_PRESSED
event.app.exit() event.app.exit()
@bindings.add(Keys.Left) @bindings.add(Keys.Left)
def _left(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction] def _left(event: KeyPressEvent) -> None:
state["result"] = _BACK_PRESSED state["result"] = _BACK_PRESSED
event.app.exit() event.app.exit()
@bindings.add(Keys.ControlC) @bindings.add(Keys.ControlC)
def _ctrl_c(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction] def _ctrl_c(event: KeyPressEvent) -> None:
state["result"] = None state["result"] = None
event.app.exit() 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.""" """Return key bindings that make Escape behave like a local back action."""
bindings = KeyBindings() bindings = KeyBindings()
# KeyBindings consumes this handler through decorator registration.
@bindings.add("escape") @bindings.add("escape")
def _escape(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction] def _escape(event: KeyPressEvent) -> None:
event.app.exit(result=_BACK_PRESSED) event.app.exit(result=_BACK_PRESSED)
return bindings return bindings
@@ -1668,11 +1668,7 @@ def _quick_start_oauth_login(config: Config, provider_name: str) -> bool:
return False return False
try: try:
# oauth-cli-kit does not publish type information. from oauth_cli_kit import get_token, login_oauth_interactive
from oauth_cli_kit import ( # pyright: ignore[reportMissingTypeStubs]
get_token,
login_oauth_interactive,
)
except ImportError: except ImportError:
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
return False return False
@@ -1713,8 +1709,7 @@ def _quick_start_oauth_is_authenticated(config: Config, provider_name: str) -> b
if provider_name != "openai_codex": if provider_name != "openai_codex":
return False return False
try: try:
# oauth-cli-kit does not publish type information. from oauth_cli_kit import get_token
from oauth_cli_kit import get_token # pyright: ignore[reportMissingTypeStubs]
proxy = _quick_start_codex_proxy(config) proxy = _quick_start_codex_proxy(config)
token = get_token(proxy=proxy) token = get_token(proxy=proxy)
+11 -2
View File
@@ -203,7 +203,16 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
"""Cancel all active tasks and subagents for the session.""" """Cancel all active tasks and subagents for the session."""
loop = ctx.loop loop = ctx.loop
msg = ctx.msg 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." content = f"Stopped {total} task(s)." if total else "No active task to stop."
return OutboundMessage( return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content, 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: async def cmd_new(ctx: CommandContext) -> OutboundMessage:
"""Stop active task and start a fresh session.""" """Stop active task and start a fresh session."""
loop = ctx.loop 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) session = ctx.session or loop.sessions.get_or_create(ctx.key)
snapshot = session.messages[session.last_consolidated:] snapshot = session.messages[session.last_consolidated:]
runtime = None runtime = None
-18
View File
@@ -504,7 +504,6 @@ class Config(BaseSettings):
model_normalized = model_lower.replace("-", "_") model_normalized = model_lower.replace("-", "_")
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
normalized_prefix = model_prefix.replace("-", "_") normalized_prefix = model_prefix.replace("-", "_")
prefixed_provider = find_by_name(model_prefix) if model_prefix else None
def _kw_matches(kw: str) -> bool: def _kw_matches(kw: str) -> bool:
kw = kw.lower() kw = kw.lower()
@@ -534,22 +533,6 @@ class Config(BaseSettings):
continue continue
p = getattr(self.providers, spec.name, None) p = getattr(self.providers, spec.name, None)
if p and any(_kw_matches(kw) for kw in spec.keywords): 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: if spec.is_oauth or spec.is_local or spec.is_direct or p.api_key:
return p, spec.name return p, spec.name
@@ -558,7 +541,6 @@ class Config(BaseSettings):
# Prefer providers whose detect_by_base_keyword matches the configured api_base # 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. # (e.g. Ollama's "11434" in "http://localhost:11434") over plain registry order.
local_fallback: tuple[ProviderConfig, str] | None = None local_fallback: tuple[ProviderConfig, str] | None = None
if prefixed_provider is None:
for spec in PROVIDERS: for spec in PROVIDERS:
if not spec.is_local: if not spec.is_local:
continue continue
+23 -29
View File
@@ -163,13 +163,9 @@ class CronService:
self._store: CronStore | None = None self._store: CronStore | None = None
self._timer_task: asyncio.Task[None] | None = None self._timer_task: asyncio.Task[None] | None = None
self._running = False self._running = False
self._active_executions = 0 self._timer_active = False
self.max_sleep_ms = max_sleep_ms 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: def _is_unbound_agent_job(self, job: CronJob) -> bool:
return job.payload.kind == "agent_turn" and not is_bound_cron_job(job) 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") logger.exception("load action line error")
continue continue
self._store.jobs = list(jobs_map.values()) # pyright: ignore[reportOptionalMemberAccess] 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._action_path.write_text("", encoding="utf-8")
self._save_store() self._save_store()
return 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. """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. - 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. _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 - When the on-disk store exists but is unreadable: keep using the
previous in-memory ``self._store`` if we already have one (so a previous in-memory ``self._store`` if we already have one (so a
transient corruption does not drop live jobs); only the very first transient corruption does not drop live jobs); only the very first
load (during ``start``) can return ``None`` to signal an unrecoverable load (during ``start``) can return ``None`` to signal an unrecoverable
state to the caller. 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 return self._store
loaded = self._load_jobs() loaded = self._load_jobs()
if loaded is None: if loaded is None:
@@ -312,12 +307,12 @@ class CronService:
jobs, version = loaded jobs, version = loaded
self._store = CronStore(version=version, jobs=jobs) self._store = CronStore(version=version, jobs=jobs)
self._merge_action() 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() self._save_store()
return self._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. """Return a usable store or raise a clear error.
``_load_store`` deliberately returns ``None`` when the first load sees ``_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 ``AttributeError`` and, more importantly, prevents follow-up saves from
treating a corrupt store as an empty one. 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: if store is None:
raise RuntimeError( raise RuntimeError(
f"cron store at {self.store_path} could not be loaded and was preserved " 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: async def _on_timer(self) -> None:
"""Handle timer tick - run due jobs.""" """Handle timer tick - run due jobs."""
reload_store = self._active_executions == 0 self._load_store()
self._active_executions += 1
try:
store = self._load_store(reload_during_execution=reload_store)
# If a hot reload found a corrupt store on disk, ``self._store`` may # If a hot reload found a corrupt store on disk, ``self._store`` may
# still hold the previous, known-good in-memory snapshot. Keep using # still hold the previous, known-good in-memory snapshot. Keep using
# it rather than crashing the timer or wiping live jobs. # it rather than crashing the timer or wiping live jobs.
if store is None: if not self._store:
self._arm_timer() self._arm_timer()
return return
self._timer_active = True
try:
now = _now_ms() now = _now_ms()
due_jobs = [ 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 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() self._save_store()
finally: finally:
self._active_executions -= 1 self._timer_active = False
self._arm_timer() self._arm_timer()
async def _execute_job(self, job: CronJob) -> None: async def _execute_job(self, job: CronJob) -> None:
@@ -663,7 +657,7 @@ class CronService:
) )
_normalize_agent_turn_job(job) _normalize_agent_turn_job(job)
self._enforce_agent_binding(job) self._enforce_agent_binding(job)
if self._should_persist_store(): if self._running:
store = self._require_store() store = self._require_store()
store.jobs.append(job) store.jobs.append(job)
self._save_store() self._save_store()
@@ -703,7 +697,7 @@ class CronService:
removed = len(store.jobs) < before removed = len(store.jobs) < before
if removed: if removed:
if self._should_persist_store(): if self._running:
self._save_store() self._save_store()
self._arm_timer() self._arm_timer()
else: else:
@@ -725,7 +719,7 @@ class CronService:
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms()) job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
else: else:
job.state.next_run_at_ms = None job.state.next_run_at_ms = None
if self._should_persist_store(): if self._running:
self._save_store() self._save_store()
self._arm_timer() self._arm_timer()
else: else:
@@ -781,7 +775,7 @@ class CronService:
else: else:
job.state.next_run_at_ms = None job.state.next_run_at_ms = None
if self._should_persist_store(): if self._running:
self._save_store() self._save_store()
self._arm_timer() self._arm_timer()
else: else:
@@ -792,10 +786,10 @@ class CronService:
async def run_job(self, job_id: str, force: bool = False) -> bool: async def run_job(self, job_id: str, force: bool = False) -> bool:
"""Manually run a job without disturbing the service's running state.""" """Manually run a job without disturbing the service's running state."""
reload_store = self._active_executions == 0 was_running = self._running
self._active_executions += 1 self._running = True
try: try:
store = self._require_store(reload_during_execution=reload_store) store = self._require_store()
for job in store.jobs: for job in store.jobs:
if job.id == job_id: if job.id == job_id:
if self._is_unbound_agent_job(job): if self._is_unbound_agent_job(job):
@@ -809,8 +803,8 @@ class CronService:
return True return True
return False return False
finally: finally:
self._active_executions -= 1 self._running = was_running
if self._running and self._active_executions == 0: if was_running:
self._arm_timer() self._arm_timer()
def get_job(self, job_id: str) -> CronJob | None: def get_job(self, job_id: str) -> CronJob | None:
+6 -21
View File
@@ -958,34 +958,22 @@ class OpenAICompatProvider(LLMProvider):
model: str | None, model: str | None,
reasoning_effort: str | None, reasoning_effort: str | None,
) -> bool: ) -> 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": if self._api_type == "chat_completions":
return False return False
spec_name = self._spec.name if self._spec is not None else None if self._spec and self._spec.name not in ("openai", "github_copilot"):
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:
return False return False
if self._api_type == "responses": if self._api_type == "responses":
# Explicit configuration means Responses is mandatory; do not # Explicit configuration means Responses is mandatory; do not
# consult the circuit breaker or fall back to Chat Completions. # consult the circuit breaker or fall back to Chat Completions.
return True 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): if not _is_direct_openai_base(self._effective_base):
return False return False
model_name = (model or self.default_model).lower()
wants = False wants = False
if model_responses: if reasoning_effort and reasoning_effort.lower() != "none":
wants = True
elif reasoning_effort and reasoning_effort.lower() != "none":
wants = True wants = True
elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")): elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")):
wants = True wants = True
@@ -1111,13 +1099,11 @@ class OpenAICompatProvider(LLMProvider):
self._sanitize_empty_content(sanitized_state.pending_messages) 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( instructions, input_items, replayed = prepare_responses_input(
sanitized_messages, sanitized_messages,
state=sanitized_state, state=sanitized_state,
provider=self._responses_state_provider(), provider=self._responses_state_provider(),
model=model_name, model=model_name,
preserve_reasoning=preserve_reasoning,
) )
body: dict[str, Any] = { body: dict[str, Any] = {
@@ -1145,7 +1131,7 @@ class OpenAICompatProvider(LLMProvider):
if self._supports_temperature(model_name, reasoning_effort): if self._supports_temperature(model_name, reasoning_effort):
body["temperature"] = temperature 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"] body["include"] = ["reasoning.encrypted_content"]
if reasoning_effort and reasoning_effort.lower() != "none": if reasoning_effort and reasoning_effort.lower() != "none":
body["reasoning"] = {"effort": reasoning_effort} body["reasoning"] = {"effort": reasoning_effort}
@@ -1841,7 +1827,6 @@ class OpenAICompatProvider(LLMProvider):
_timed_stream(), _timed_stream(),
on_content_delta, on_content_delta,
on_tool_call_delta=on_tool_call_delta, on_tool_call_delta=on_tool_call_delta,
on_reasoning_delta=on_thinking_delta,
capture=capture, capture=capture,
) )
self._record_responses_success(model, reasoning_effort) 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 return cast(dict[str, Any], value) if isinstance(value, dict) else None
def convert_messages( def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
messages: list[dict[str, Any]],
*,
preserve_reasoning: bool = False,
) -> tuple[str, list[dict[str, Any]]]:
"""Convert Chat Completions messages to Responses API input items. """Convert Chat Completions messages to Responses API input items.
Returns ``(system_prompt, input_items)`` where *system_prompt* is extracted Returns ``(system_prompt, input_items)`` where *system_prompt* is extracted
@@ -40,13 +36,6 @@ def convert_messages(
continue continue
if role == "assistant": 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: if isinstance(content, str) and content:
message_id = _unique_item_id(f"msg_{idx}", used_item_ids) message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
input_items.append({ input_items.append({
+12 -34
View File
@@ -69,9 +69,7 @@ def _response_object(value: object) -> dict[str, Any] | None:
return object_value return object_value
dump = getattr(value, "model_dump", None) dump = getattr(value, "model_dump", None)
if callable(dump): if callable(dump):
dumped = _as_json_object(dump()) return _as_json_object(dump())
if dumped is not None:
return dumped
try: try:
return _as_json_object(vars(value)) return _as_json_object(vars(value))
except TypeError: except TypeError:
@@ -446,14 +444,6 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
for item in _response_object_list(output): for item in _response_object_list(output):
if item.get("type") != "reasoning": if item.get("type") != "reasoning":
continue 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")): for summary in _response_object_list(item.get("summary")):
if summary.get("type") == "summary_text" and summary.get("text"): if summary.get("type") == "summary_text" and summary.get("text"):
text = summary.get("text") text = summary.get("text")
@@ -493,8 +483,10 @@ def parse_response_output(
if isinstance(refusal, str): if isinstance(refusal, str):
content_parts.append(refusal) content_parts.append(refusal)
elif item_type == "reasoning": elif item_type == "reasoning":
text = _extract_reasoning_summary_from_output([item]) for s in _response_object_list(item.get("summary")):
if text: if s.get("type") == "summary_text" and s.get("text"):
text = s.get("text")
if isinstance(text, str):
reasoning_content = (reasoning_content or "") + text reasoning_content = (reasoning_content or "") + text
elif item_type == "function_call": elif item_type == "function_call":
call_id = item.get("call_id") or "" call_id = item.get("call_id") or ""
@@ -540,7 +532,6 @@ async def consume_sdk_stream(
stream: Any, stream: Any,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], 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, capture: ResponsesStreamCapture | None = None,
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]: ) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
"""Consume an SDK async stream from ``client.responses.create(stream=True)``.""" """Consume an SDK async stream from ``client.responses.create(stream=True)``."""
@@ -551,7 +542,6 @@ async def consume_sdk_stream(
finish_reason = "stop" finish_reason = "stop"
usage: dict[str, int] = {} usage: dict[str, int] = {}
reasoning_content: str | None = None reasoning_content: str | None = None
streamed_reasoning = False
refusal_seen = False refusal_seen = False
refusal_deltas: dict[tuple[str | None, int | None], str] = {} refusal_deltas: dict[tuple[str | None, int | None], str] = {}
emitted_refusal_text = "" emitted_refusal_text = ""
@@ -582,19 +572,6 @@ async def consume_sdk_stream(
content += delta_text content += delta_text
if on_content_delta and delta_text: if on_content_delta and delta_text:
await on_content_delta(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": elif event_type == "response.refusal.delta":
refusal_seen = True refusal_seen = True
delta_text = getattr(event, "delta", None) 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), "completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0),
"total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0), "total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0),
} }
if not reasoning_content: for out_item in cast(list[Any], getattr(resp, "output", None) or []):
reasoning_content = _extract_reasoning_summary_from_output( if getattr(out_item, "type", None) == "reasoning":
getattr(resp, "output", None) for s in cast(list[Any], getattr(out_item, "summary", None) or []):
) if getattr(s, "type", None) == "summary_text":
if reasoning_content and on_reasoning_delta: text = getattr(s, "text", None)
await on_reasoning_delta(reasoning_content) if text:
reasoning_content = (reasoning_content or "") + text
elif event_type in {"error", "response.failed"}: elif event_type in {"error", "response.failed"}:
detail = getattr(event, "error", None) or getattr(event, "message", None) or event detail = getattr(event, "error", None) or getattr(event, "message", None) or event
raise RuntimeError(f"Response failed: {str(detail)[:500]}") raise RuntimeError(f"Response failed: {str(detail)[:500]}")
+2 -9
View File
@@ -43,7 +43,6 @@ def prepare_responses_input(
state: ProviderConversationState | None, state: ProviderConversationState | None,
provider: str, provider: str,
model: str, model: str,
preserve_reasoning: bool = False,
) -> tuple[str, list[dict[str, Any]], bool]: ) -> tuple[str, list[dict[str, Any]], bool]:
"""Build a request from exact prior items plus only newly appended messages. """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 When no compatible state exists, it is converted normally as a safe
fallback. fallback.
""" """
instructions, fallback_items = convert_messages( instructions, fallback_items = convert_messages(messages)
messages,
preserve_reasoning=preserve_reasoning,
)
if state is None or not responses_state_matches( if state is None or not responses_state_matches(
state, state,
provider=provider, provider=provider,
@@ -66,10 +62,7 @@ def prepare_responses_input(
if prior_items is None: if prior_items is None:
return instructions, fallback_items, False return instructions, fallback_items, False
_, delta_items = convert_messages( _, delta_items = convert_messages(state.pending_messages)
state.pending_messages,
preserve_reasoning=preserve_reasoning,
)
logger.debug( logger.debug(
"Replaying Responses state: prior_items={} pending_messages={}", "Replaying Responses state: prior_items={} pending_messages={}",
len(prior_items), len(prior_items),
-6
View File
@@ -111,11 +111,6 @@ class ProviderSpec:
# Substring match against the wire model name (lowercased). # Substring match against the wire model name (lowercased).
implicit_reasoning_models: tuple[str, ...] = () 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",...} + # When the model returns content as a list of {"type":"thinking",...} +
# {"type":"text",...} blocks, extract the thinking text into # {"type":"text",...} blocks, extract the thinking text into
# reasoning_content. Mistral's Magistral / reasoning-enabled responses use # reasoning_content. Mistral's Magistral / reasoning-enabled responses use
@@ -466,7 +461,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat", backend="openai_compat",
default_api_base="https://api.deepseek.com", default_api_base="https://api.deepseek.com",
thinking_style="thinking_type", thinking_style="thinking_type",
responses_models=("deepseek-v4-flash",),
), ),
# Gemini: Google's OpenAI-compatible endpoint # Gemini: Google's OpenAI-compatible endpoint
ProviderSpec( ProviderSpec(
-28
View File
@@ -157,7 +157,6 @@ class Session:
metadata: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict)
last_consolidated: int = 0 # Number of messages already consolidated to files last_consolidated: int = 0 # Number of messages already consolidated to files
provider_state: ProviderConversationState | None = field(default=None, repr=False) provider_state: ProviderConversationState | None = field(default=None, repr=False)
transient: bool = field(default=False, repr=False, compare=False)
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not isinstance(cast(object, self.metadata), dict): if not isinstance(cast(object, self.metadata), dict):
@@ -965,7 +964,6 @@ class SessionManager:
self._cache: OrderedDict[str, Session] = OrderedDict() self._cache: OrderedDict[str, Session] = OrderedDict()
# Preserve identity for sessions held by active callers without retaining idle ones. # Preserve identity for sessions held by active callers without retaining idle ones.
self._overflow_cache: WeakValueDictionary[str, Session] = WeakValueDictionary() self._overflow_cache: WeakValueDictionary[str, Session] = WeakValueDictionary()
self._transient_sessions: dict[str, Session] = {}
self._max_cached_sessions = SESSION_CACHE_MAX_SIZE self._max_cached_sessions = SESSION_CACHE_MAX_SIZE
self._file_cap_archiver: Callable[..., None] | None = None self._file_cap_archiver: Callable[..., None] | None = None
@@ -979,10 +977,6 @@ class SessionManager:
self._overflow_cache[key] = evicted self._overflow_cache[key] = evicted
def _cached(self, key: str) -> Session | None: 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) session = self._cache.get(key)
if session is not None: if session is not None:
self._cache.move_to_end(key) self._cache.move_to_end(key)
@@ -1059,24 +1053,6 @@ class SessionManager:
self._remember(session) self._remember(session)
return 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: def _load(self, key: str) -> Session | None:
return self._store.load(key) return self._store.load(key)
@@ -1090,9 +1066,6 @@ class SessionManager:
def save(self, session: Session, *, fsync: bool = False) -> None: def save(self, session: Session, *, fsync: bool = False) -> None:
"""Persist a session and retain it in the cache.""" """Persist a session and retain it in the cache."""
if session.transient is True:
return
archiver = self._file_cap_archiver archiver = self._file_cap_archiver
if archiver is not None: if archiver is not None:
session.enforce_file_cap( session.enforce_file_cap(
@@ -1125,7 +1098,6 @@ class SessionManager:
def invalidate(self, key: str) -> None: def invalidate(self, key: str) -> None:
"""Remove a session from the in-memory cache.""" """Remove a session from the in-memory cache."""
self._transient_sessions.pop(key, None)
self._cache.pop(key, None) self._cache.pop(key, None)
self._overflow_cache.pop(key, None) self._overflow_cache.pop(key, None)
-10
View File
@@ -334,16 +334,6 @@ def clear_websocket_turn_if_current(
return False 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( def build_bus_progress_callback(
bus: MessageBus, bus: MessageBus,
msg: InboundMessage, msg: InboundMessage,
+1 -5
View File
@@ -2,10 +2,9 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, Callable
from loguru import logger as default_logger from loguru import logger as default_logger
@@ -39,7 +38,6 @@ class GatewayServices:
local_trigger_store: LocalTriggerStore | None local_trigger_store: LocalTriggerStore | None
cron_pending_job_ids: Callable[[str], set[str]] | None cron_pending_job_ids: Callable[[str], set[str]] | None
local_trigger_pending_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( def build_gateway_services(
@@ -58,7 +56,6 @@ def build_gateway_services(
local_trigger_store: LocalTriggerStore | None = None, local_trigger_store: LocalTriggerStore | None = None,
cron_pending_job_ids: Callable[[str], set[str]] | None = None, cron_pending_job_ids: Callable[[str], set[str]] | None = None,
local_trigger_pending_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_feature_action: Callable[..., Any] | None = None,
channel_runtime_status: Callable[[], dict[str, Any]] | None = None, channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
skill_state_action: Callable[[set[str]], None] | 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, local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids, cron_pending_job_ids=cron_pending_job_ids,
local_trigger_pending_ids=local_trigger_pending_ids, local_trigger_pending_ids=local_trigger_pending_ids,
cancel_active_turn=cancel_active_turn,
) )
+1 -1
View File
@@ -51,7 +51,7 @@ dependencies = [
"filelock>=3.25.2", "filelock>=3.25.2",
"watchfiles>=1.1.1,<2.0.0", "watchfiles>=1.1.1,<2.0.0",
"packaging>=24.0", "packaging>=24.0",
"tzdata>=2025.2", "tzdata>=2025.2; sys_platform == 'win32'",
"defusedxml>=0.7.1,<1.0.0", "defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0", "pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0", "python-docx>=1.1.0,<2.0.0",
-52
View File
@@ -592,58 +592,6 @@ class TestPrepareSession:
assert summary is not None assert summary is not None
assert "Cold summary." in summary 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): def test_no_summary_available_returns_none(self):
"""When no summary is available, should return (session, None).""" """When no summary is available, should return (session, None)."""
ac = _make_autocompact() ac = _make_autocompact()
-14
View File
@@ -15,20 +15,6 @@ def _builder(tmp_path: Path, **kw) -> ContextBuilder:
return ContextBuilder(workspace=tmp_path, **kw) 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) # _merge_message_content (static)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
-18
View File
@@ -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") next_cursor = memory.append_history("next", session_key="cli:t")
assert next_cursor == 2 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
-42
View File
@@ -111,50 +111,8 @@ class TestHandleStop:
assert all(e.is_set() for e in events) assert all(e.is_set() for e in events)
assert "2 task" in out.content 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: 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 @pytest.mark.asyncio
async def test_run_logs_and_continues_after_leaked_cancelled_error(self, monkeypatch): async def test_run_logs_and_continues_after_leaked_cancelled_error(self, monkeypatch):
loop, bus = _make_loop() loop, bus = _make_loop()
-169
View File
@@ -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
+2 -2
View File
@@ -253,7 +253,7 @@ class TestCmdNewUnifiedSession:
loop = SimpleNamespace( loop = SimpleNamespace(
sessions=sessions, sessions=sessions,
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)), 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()), llm_runtime=MagicMock(return_value=MagicMock()),
schedule_background=lambda coro: asyncio.ensure_future(coro), schedule_background=lambda coro: asyncio.ensure_future(coro),
) )
@@ -301,7 +301,7 @@ class TestCmdNewUnifiedSession:
loop = SimpleNamespace( loop = SimpleNamespace(
sessions=sessions, sessions=sessions,
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)), 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()), runtime_for_session=MagicMock(return_value=MagicMock()),
schedule_background=lambda coro: asyncio.ensure_future(coro), schedule_background=lambda coro: asyncio.ensure_future(coro),
) )
+1 -1
View File
@@ -2558,7 +2558,7 @@ def test_optional_dependency_metadata_for_enable():
): ):
assert not any(dep.startswith(dep_name) for dep in required) assert not any(dep.startswith(dep_name) for dep in required)
for dependency in ( for dependency in (
"tzdata>=2025.2", "tzdata>=2025.2; sys_platform == 'win32'",
"defusedxml>=0.7.1,<1.0.0", "defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0", "pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0", "python-docx>=1.1.0,<2.0.0",
-57
View File
@@ -1160,63 +1160,6 @@ def test_config_falls_back_to_vllm_when_ollama_not_configured():
assert config.get_api_base() == "http://localhost:8000" 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(): def test_openai_compat_provider_passes_model_through():
from nanobot.providers.openai_compat_provider import OpenAICompatProvider from nanobot.providers.openai_compat_provider import OpenAICompatProvider
+1 -1
View File
@@ -109,7 +109,7 @@ class TestMidTurnCommandDispatchedDirectly:
loop.sessions.save = MagicMock() loop.sessions.save = MagicMock()
loop.sessions.invalidate = MagicMock() loop.sessions.invalidate = MagicMock()
loop.schedule_background = MagicMock() loop.schedule_background = MagicMock()
loop.cancel_active_turn = AsyncMock(return_value=0) loop._cancel_active_tasks = AsyncMock(return_value=0)
return loop return loop
@pytest.fixture() @pytest.fixture()
+17 -5
View File
@@ -1,5 +1,6 @@
"""Test cmd_stop drains pending queue to prevent mid-turn injection deadlock.""" """Test cmd_stop drains pending queue to prevent mid-turn injection deadlock."""
import asyncio
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@@ -13,7 +14,13 @@ from nanobot.command.router import CommandContext
async def test_cmd_stop_drains_pending_queue(): async def test_cmd_stop_drains_pending_queue():
"""cmd_stop should drain pending queue in addition to cancelling active tasks.""" """cmd_stop should drain pending queue in addition to cancelling active tasks."""
mock_loop = MagicMock() 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( ctx = CommandContext(
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}), 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 isinstance(result, OutboundMessage)
assert "Stopped 3 task(s)" in result.content # 1 cancelled + 2 drained 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 @pytest.mark.asyncio
async def test_cmd_stop_with_empty_pending_queue(): async def test_cmd_stop_with_empty_pending_queue():
"""cmd_stop should work correctly when pending queue is empty.""" """cmd_stop should work correctly when pending queue is empty."""
mock_loop = MagicMock() 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( ctx = CommandContext(
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}), 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) result = await cmd_stop(ctx)
assert "Stopped 2 task(s)" in result.content 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 @pytest.mark.asyncio
async def test_cmd_stop_no_pending_queue(): async def test_cmd_stop_no_pending_queue():
"""cmd_stop should work when no pending queue exists.""" """cmd_stop should work when no pending queue exists."""
mock_loop = MagicMock() 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( ctx = CommandContext(
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}), msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
-30
View File
@@ -1,8 +1,4 @@
import json import json
import os
import subprocess
import sys
import textwrap
import warnings import warnings
import pytest import pytest
@@ -46,32 +42,6 @@ def test_agent_timezone_rejects_unknown_iana_name() -> None:
Config.model_validate({"agents": {"defaults": {"timezone": "Not/AZone"}}}) 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: def test_provider_api_type_accepts_exact_values_only() -> None:
config = Config.model_validate({ config = Config.model_validate({
"providers": { "providers": {
-111
View File
@@ -600,117 +600,6 @@ async def test_run_job_preserves_running_service_state(tmp_path) -> None:
service.stop() 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 @pytest.mark.asyncio
async def test_running_service_honors_external_disable(tmp_path) -> None: async def test_running_service_honors_external_disable(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json" store_path = tmp_path / "cron" / "jobs.json"
-56
View File
@@ -150,22 +150,6 @@ class TestConvertMessages:
assert items[0]["content"][0]["type"] == "output_text" assert items[0]["content"][0]["type"] == "output_text"
assert items[0]["content"][0]["text"] == "I'll help" 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): def test_assistant_empty_content_skipped(self):
_, items = convert_messages([{"role": "assistant", "content": ""}]) _, items = convert_messages([{"role": "assistant", "content": ""}])
assert len(items) == 0 assert len(items) == 0
@@ -555,22 +539,6 @@ class TestParseResponseOutput:
assert result.content == "42" assert result.content == "42"
assert result.reasoning_content == "I think therefore I am." 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): def test_empty_output(self):
resp = {"output": [], "status": "completed", "usage": {}} resp = {"output": [], "status": "completed", "usage": {}}
result = parse_response_output(resp) result = parse_response_output(resp)
@@ -1665,30 +1633,6 @@ class TestConsumeSdkStream:
_, _, _, _, reasoning = await consume_sdk_stream(stream()) _, _, _, _, reasoning = await consume_sdk_stream(stream())
assert reasoning == "thinking..." 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 @pytest.mark.asyncio
async def test_error_event_raises(self): async def test_error_event_raises(self):
ev = MagicMock(type="error", error="rate_limit_exceeded") 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 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): def test_direct_openai_enables_server_compaction(provider):
provider._extra_body = {} provider._extra_body = {}
-20
View File
@@ -73,23 +73,3 @@ def test_flush_all_includes_live_sessions_outside_strong_cache(tmp_path, monkeyp
assert manager.flush_all() == 2 assert manager.flush_all() == 2
assert set(saved) == {("test:active", True), ("test:other", True)} 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
-48
View File
@@ -16,13 +16,11 @@ from nanobot.agent import context as agent_context
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
from nanobot.agent.tools.exec_session import ( from nanobot.agent.tools.exec_session import (
MAX_OUTPUT_CHARS,
ExecSessionManager, ExecSessionManager,
ListExecSessionsTool, ListExecSessionsTool,
WriteStdinTool, WriteStdinTool,
_BoundedOutputBuffer, _BoundedOutputBuffer,
_SessionPoll, _SessionPoll,
_truncate_output,
) )
from nanobot.agent.tools.registry import is_tool_error_result from nanobot.agent.tools.registry import is_tool_error_result
from nanobot.agent.tools.shell import ExecTool 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 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): def test_exec_one_shot_accepts_max_output_tokens_alias(tmp_path):
async def run() -> str: async def run() -> str:
tool = ExecTool(working_dir=str(tmp_path), timeout=5) tool = ExecTool(working_dir=str(tmp_path), timeout=5)
+8 -87
View File
@@ -8,7 +8,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } 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 { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry"; import { channelUiPresentation } from "@/channel-plugins/registry";
import { Sidebar } from "@/components/Sidebar"; import { Sidebar } from "@/components/Sidebar";
@@ -38,7 +38,6 @@ import { displayTitle } from "@/lib/chat-groups";
import { deriveTitle } from "@/lib/format"; import { deriveTitle } from "@/lib/format";
import { NanobotClient } from "@/lib/nanobot-client"; import { NanobotClient } from "@/lib/nanobot-client";
import { import {
createTemporaryChatSession,
isQuickChatKey, isQuickChatKey,
QUICK_CHAT_ID, QUICK_CHAT_ID,
QUICK_CHAT_KEY, QUICK_CHAT_KEY,
@@ -974,8 +973,6 @@ function Shell({
initialRouteRef.current.activeKey, initialRouteRef.current.activeKey,
); );
const [view, setView] = useState<ShellView>(initialRouteRef.current.view); const [view, setView] = useState<ShellView>(initialRouteRef.current.view);
const [temporarySession, setTemporarySession] = useState<ChatSummary | null>(null);
const temporarySessionRef = useRef<ChatSummary | null>(null);
const [settingsInitialSection, setSettingsInitialSection] = const [settingsInitialSection, setSettingsInitialSection] =
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection); useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
const [hostSidebarOpen, setHostSidebarOpen] = const [hostSidebarOpen, setHostSidebarOpen] =
@@ -1025,33 +1022,19 @@ function Shell({
const showHostChrome = effectiveRuntimeSurface === "native"; const showHostChrome = effectiveRuntimeSurface === "native";
const showMainSidebar = view !== "settings"; 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( const navigate = useCallback(
(route: ShellRoute, options?: { replace?: boolean }) => { (route: ShellRoute, options?: { replace?: boolean }) => {
if (route.view !== "chat" || route.activeKey !== QUICK_CHAT_KEY) {
discardTemporaryChat();
}
setActiveKey(route.activeKey); setActiveKey(route.activeKey);
setView(route.view); setView(route.view);
setSettingsInitialSection(route.settingsSection); setSettingsInitialSection(route.settingsSection);
writeShellRoute(route, options?.replace); writeShellRoute(route, options?.replace);
}, },
[discardTemporaryChat], [],
); );
useEffect(() => { useEffect(() => {
const applyRoute = () => { const applyRoute = () => {
const route = readShellRoute(); const route = readShellRoute();
if (route.view !== "chat" || route.activeKey !== QUICK_CHAT_KEY) {
discardTemporaryChat();
}
setActiveKey(route.activeKey); setActiveKey(route.activeKey);
setView(route.view); setView(route.view);
setSettingsInitialSection(route.settingsSection); setSettingsInitialSection(route.settingsSection);
@@ -1062,15 +1045,7 @@ function Shell({
}; };
window.addEventListener("hashchange", applyRoute); window.addEventListener("hashchange", applyRoute);
return () => window.removeEventListener("hashchange", applyRoute); return () => window.removeEventListener("hashchange", applyRoute);
}, [discardTemporaryChat]); }, []);
useEffect(() => {
return client.onStatus((status) => {
if (status !== "open") discardTemporaryChat();
});
}, [client, discardTemporaryChat]);
useEffect(() => () => discardTemporaryChat(), [discardTemporaryChat]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -1157,11 +1132,10 @@ function Shell({
const activeSession = useMemo<ChatSummary | null>(() => { const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null; if (!activeKey) return null;
if (isQuickChatKey(activeKey)) return temporarySession ?? quickSession; if (isQuickChatKey(activeKey)) return quickSession;
return sessions.find((s) => s.key === activeKey) ?? null; return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey, quickSession, temporarySession]); }, [sessions, activeKey, quickSession]);
const quickChatActive = isQuickChatKey(activeKey); const quickChatActive = isQuickChatKey(activeKey);
const temporaryChatActive = quickChatActive && temporarySession !== null;
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]); const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const activeChatId = activeSession?.chatId ?? null; const activeChatId = activeSession?.chatId ?? null;
@@ -1176,9 +1150,6 @@ function Shell({
}); });
}, [activeChatId]); }, [activeChatId]);
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => { const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
if (temporaryChatActive) {
return null;
}
if (quickChatActive) { if (quickChatActive) {
return workspaces?.default_scope ?? null; return workspaces?.default_scope ?? null;
} }
@@ -1194,7 +1165,6 @@ function Shell({
activeSession?.workspaceScope, activeSession?.workspaceScope,
draftWorkspaceScope, draftWorkspaceScope,
quickChatActive, quickChatActive,
temporaryChatActive,
workspaceOverrides, workspaceOverrides,
workspaces?.default_scope, workspaces?.default_scope,
]); ]);
@@ -1487,16 +1457,6 @@ function Shell({
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
}, [navigate]); }, [navigate]);
const onToggleTemporaryChat = useCallback(() => {
if (temporarySessionRef.current) {
discardTemporaryChat();
return;
}
const session = createTemporaryChatSession();
temporarySessionRef.current = session;
setTemporarySession(session);
}, [discardTemporaryChat]);
const onNewChatInProject = useCallback( const onNewChatInProject = useCallback(
(projectPath: string, projectName: string) => { (projectPath: string, projectName: string) => {
const base = workspaces?.default_scope ?? activeWorkspaceScope; const base = workspaces?.default_scope ?? activeWorkspaceScope;
@@ -1947,9 +1907,7 @@ function Shell({
}); });
}, []); }, []);
const headerTitle = temporaryChatActive const headerTitle = quickChatActive
? t("quickChat.temporary.title")
: quickChatActive
? t("sidebar.quickChat") ? t("sidebar.quickChat")
: activeSession : activeSession
? sidebarState.title_overrides[activeSession.key] || ? sidebarState.title_overrides[activeSession.key] ||
@@ -1957,30 +1915,6 @@ function Shell({
deriveTitle(activeSession.preview, t("chat.newChat")) deriveTitle(activeSession.preview, t("chat.newChat"))
: t("app.brand"); : 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(() => { useEffect(() => {
if (view === "settings") { if (view === "settings") {
document.title = t("app.documentTitle.chat", { document.title = t("app.documentTitle.chat", {
@@ -2213,9 +2147,7 @@ function Shell({
hostChromeTitleInset={hostSidebarCollapsed} hostChromeTitleInset={hostSidebarCollapsed}
hideHeader={false} hideHeader={false}
workspaceScope={activeWorkspaceScope} workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={ workspaceDefaultScope={workspaces?.default_scope ?? null}
temporaryChatActive ? null : workspaces?.default_scope ?? null
}
workspaceControls={ workspaceControls={
quickChatActive ? null : (workspaces?.controls ?? null) quickChatActive ? null : (workspaces?.controls ?? null)
} }
@@ -2228,19 +2160,8 @@ function Shell({
allowConversationReset={!quickChatActive} allowConversationReset={!quickChatActive}
showSessionInfo={!quickChatActive} showSessionInfo={!quickChatActive}
emptyStateGreeting={ emptyStateGreeting={
temporaryChatActive quickChatActive ? t("quickChat.greeting") : undefined
? t("quickChat.temporary.greeting")
: quickChatActive
? t("quickChat.greeting")
: undefined
} }
emptyStateDescription={
temporaryChatActive
? t("quickChat.temporary.description")
: undefined
}
temporary={temporaryChatActive}
headerAction={temporaryChatAction}
/> />
</div> </div>
{view !== "chat" && ( {view !== "chat" && (
+10 -5
View File
@@ -107,6 +107,7 @@ export const ChatList = memo(function ChatList({
}: ChatListProps) { }: ChatListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS); const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
const listContentRef = useRef<HTMLDivElement>(null);
const activeRowRef = useRef<HTMLDivElement>(null); const activeRowRef = useRef<HTMLDivElement>(null);
const labels = useMemo<ChatGroupLabels>(() => ({ const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"), pinned: t("chat.groups.pinned"),
@@ -187,10 +188,8 @@ export const ChatList = memo(function ChatList({
return ( return (
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent"> <div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
<SidebarSelectionHighlight <div
targetRef={activeRowRef} ref={listContentRef}
activeId={activeKey}
scope="sessions"
data-chat-list-content data-chat-list-content
className="relative min-w-0 space-y-3 px-2 py-1.5" className="relative min-w-0 space-y-3 px-2 py-1.5"
> >
@@ -409,7 +408,13 @@ export const ChatList = memo(function ChatList({
</button> </button>
</div> </div>
) : null} ) : null}
</SidebarSelectionHighlight> <SidebarSelectionHighlight
containerRef={listContentRef}
targetRef={activeRowRef}
activeId={activeKey}
scope="sessions"
/>
</div>
</div> </div>
); );
}); });
+10 -5
View File
@@ -95,6 +95,7 @@ export function Sidebar(props: SidebarProps) {
const collapsed = Boolean(props.collapsed); const collapsed = Boolean(props.collapsed);
const toggleLabel = t("thread.header.toggleSidebar"); const toggleLabel = t("thread.header.toggleSidebar");
const newChatShortcut = newChatShortcutLabel(); const newChatShortcut = newChatShortcutLabel();
const actionListRef = useRef<HTMLDivElement>(null);
const activeActionRef = useRef<HTMLButtonElement>(null); const activeActionRef = useRef<HTMLButtonElement>(null);
const activeActionId = props.quickChatActive const activeActionId = props.quickChatActive
? "quick-chat" ? "quick-chat"
@@ -154,10 +155,8 @@ export function Sidebar(props: SidebarProps) {
)} )}
</div> </div>
<SidebarSelectionHighlight <div
targetRef={activeActionRef} ref={actionListRef}
activeId={activeActionId}
scope="actions"
className={cn( className={cn(
"relative space-y-1.5 px-2 pb-2", "relative space-y-1.5 px-2 pb-2",
collapsed && "flex w-14 flex-col items-center px-0", 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" />} icon={<Archive className="h-4 w-4" />}
/> />
) : null} ) : null}
</SidebarSelectionHighlight> <SidebarSelectionHighlight
containerRef={actionListRef}
targetRef={activeActionRef}
activeId={activeActionId}
scope="actions"
/>
</div>
<div <div
className={cn( className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden transition-opacity duration-200", "flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden transition-opacity duration-200",
@@ -1,11 +1,11 @@
import { import {
type HTMLAttributes,
type RefObject, type RefObject,
useLayoutEffect, useLayoutEffect,
useRef, useRef,
} from "react"; } from "react";
interface SidebarSelectionHighlightProps extends HTMLAttributes<HTMLDivElement> { interface SidebarSelectionHighlightProps {
containerRef: RefObject<HTMLElement>;
targetRef: RefObject<HTMLElement>; targetRef: RefObject<HTMLElement>;
activeId: string | null; activeId: string | null;
scope: string; 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"; "relative z-[1] transition-[width,padding,color] [transition-duration:300ms,300ms,150ms] ease-out motion-reduce:transition-none";
export function SidebarSelectionHighlight({ export function SidebarSelectionHighlight({
containerRef,
targetRef, targetRef,
activeId, activeId,
scope, scope,
children,
...containerProps
}: SidebarSelectionHighlightProps) { }: SidebarSelectionHighlightProps) {
const containerRef = useRef<HTMLDivElement>(null);
const highlightRef = useRef<HTMLDivElement>(null); const highlightRef = useRef<HTMLDivElement>(null);
const positionedRef = useRef(false); const positionedRef = useRef(false);
@@ -32,19 +30,11 @@ export function SidebarSelectionHighlight({
const highlight = highlightRef.current; const highlight = highlightRef.current;
const container = containerRef.current; const container = containerRef.current;
const target = targetRef.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; let restoreTransitionFrame: number | null = null;
const position = () => { const position = () => {
const containerRect = container.getBoundingClientRect(); if (!highlight) return;
const targetRect = target.getBoundingClientRect(); if (!activeId || !container || !target) {
if (targetRect.width === 0 || targetRect.height === 0) {
highlight.style.opacity = "0"; highlight.style.opacity = "0";
positionedRef.current = false; positionedRef.current = false;
return; return;
@@ -53,6 +43,8 @@ export function SidebarSelectionHighlight({
const firstPosition = !positionedRef.current; const firstPosition = !positionedRef.current;
if (firstPosition) highlight.style.transitionProperty = "none"; if (firstPosition) highlight.style.transitionProperty = "none";
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
highlight.style.width = `${targetRect.width}px`; highlight.style.width = `${targetRect.width}px`;
highlight.style.height = `${targetRect.height}px`; highlight.style.height = `${targetRect.height}px`;
highlight.style.transform = `translate3d(${targetRect.left - containerRect.left}px, ${ highlight.style.transform = `translate3d(${targetRect.left - containerRect.left}px, ${
@@ -72,8 +64,8 @@ export function SidebarSelectionHighlight({
position(); position();
const resizeObserver = const resizeObserver =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(position); typeof ResizeObserver === "undefined" ? null : new ResizeObserver(position);
resizeObserver?.observe(container); if (container) resizeObserver?.observe(container);
resizeObserver?.observe(target); if (target) resizeObserver?.observe(target);
window.addEventListener("resize", position); window.addEventListener("resize", position);
return () => { return () => {
@@ -87,8 +79,6 @@ export function SidebarSelectionHighlight({
}); });
return ( return (
<div {...containerProps} ref={containerRef}>
{children}
<div <div
ref={highlightRef} ref={highlightRef}
data-testid={`${scope}-selection-highlight`} data-testid={`${scope}-selection-highlight`}
@@ -96,6 +86,5 @@ export function SidebarSelectionHighlight({
aria-hidden="true" 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]" 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; hostChromeInset?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const desktopNavRef = useRef<HTMLDivElement>(null);
const activeNavItemRef = useRef<HTMLButtonElement>(null); const activeNavItemRef = useRef<HTMLButtonElement>(null);
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection) const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
?? SETTINGS_NAV_ITEMS[0]; ?? SETTINGS_NAV_ITEMS[0];
@@ -2574,12 +2575,7 @@ function SettingsSidebar({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<SidebarSelectionHighlight <div ref={desktopNavRef} className="relative hidden space-y-1 lg:block">
targetRef={activeNavItemRef}
activeId={activeSection}
scope="settings"
className="relative hidden space-y-1 lg:block"
>
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => { {SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection; const active = key === activeSection;
return ( return (
@@ -2604,7 +2600,13 @@ function SettingsSidebar({
</button> </button>
); );
})} })}
</SidebarSelectionHighlight> <SidebarSelectionHighlight
containerRef={desktopNavRef}
targetRef={activeNavItemRef}
activeId={activeSection}
scope="settings"
/>
</div>
</nav> </nav>
<div className="hidden lg:mt-auto lg:block lg:pt-4"> <div className="hidden lg:mt-auto lg:block lg:pt-4">
+7 -20
View File
@@ -202,7 +202,6 @@ interface ThreadComposerProps {
quotedContext?: string | null; quotedContext?: string | null;
focusRequest?: number; focusRequest?: number;
onQuotedContextChange?: (text: string | null) => void; onQuotedContextChange?: (text: string | null) => void;
allowAttachments?: boolean;
} }
const COMMAND_ICONS: Record<string, LucideIcon> = { const COMMAND_ICONS: Record<string, LucideIcon> = {
@@ -851,7 +850,6 @@ export function ThreadComposer({
quotedContext = null, quotedContext = null,
focusRequest = 0, focusRequest = 0,
onQuotedContextChange, onQuotedContextChange,
allowAttachments = true,
}: ThreadComposerProps) { }: ThreadComposerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [value, setValue] = useState(""); const [value, setValue] = useState("");
@@ -915,10 +913,6 @@ export function ThreadComposer({
const { images, enqueue, remove, clear, restoreReadyImages, encoding, full } = const { images, enqueue, remove, clear, restoreReadyImages, encoding, full } =
useAttachedImages({ ingressLimits }); useAttachedImages({ ingressLimits });
useEffect(() => {
if (!allowAttachments) clear();
}, [allowAttachments, clear]);
const formatRejection = useCallback( const formatRejection = useCallback(
(reason: AttachmentError): string => { (reason: AttachmentError): string => {
const key = `thread.composer.imageRejected.${reason}`; const key = `thread.composer.imageRejected.${reason}`;
@@ -948,7 +942,6 @@ export function ThreadComposer({
const addFiles = useCallback( const addFiles = useCallback(
(files: File[]) => { (files: File[]) => {
if (!allowAttachments) return;
if (files.length === 0) return; if (files.length === 0) return;
secondEnterPromptIdRef.current = null; secondEnterPromptIdRef.current = null;
const { rejected } = enqueue(files); const { rejected } = enqueue(files);
@@ -958,7 +951,7 @@ export function ThreadComposer({
setInlineError(null); setInlineError(null);
} }
}, },
[allowAttachments, enqueue, formatRejection], [enqueue, formatRejection],
); );
const { const {
@@ -1881,10 +1874,10 @@ export function ThreadComposer({
e.preventDefault(); e.preventDefault();
submit(); submit();
}} }}
onDragEnter={allowAttachments ? onDragEnter : undefined} onDragEnter={onDragEnter}
onDragOver={allowAttachments ? onDragOver : undefined} onDragOver={onDragOver}
onDragLeave={allowAttachments ? onDragLeave : undefined} onDragLeave={onDragLeave}
onDrop={allowAttachments ? onDrop : undefined} onDrop={onDrop}
className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")} className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
> >
{showSlashMenu ? ( {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-[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]", : "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", 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 && goalState?.active &&
"goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45", "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)} onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
onSelect={(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)} onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
onPaste={allowAttachments ? onPaste : undefined} onPaste={onPaste}
rows={1} rows={1}
placeholder={resolvedPlaceholder} placeholder={resolvedPlaceholder}
disabled={disabled} disabled={disabled}
@@ -2066,8 +2057,6 @@ export function ThreadComposer({
isHero ? "gap-1.5" : "gap-2", isHero ? "gap-1.5" : "gap-2",
)} )}
> >
{allowAttachments ? (
<>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -2092,8 +2081,6 @@ export function ThreadComposer({
> >
<Plus className={cn(isHero ? "h-[18px] w-[18px]" : "h-4 w-4")} /> <Plus className={cn(isHero ? "h-[18px] w-[18px]" : "h-4 w-4")} />
</Button> </Button>
</>
) : null}
{voiceRecorder.isRecording ? ( {voiceRecorder.isRecording ? (
<VoiceRecordingMeter <VoiceRecordingMeter
ariaLabel={voiceRecordingStatusLabel} ariaLabel={voiceRecordingStatusLabel}
@@ -16,7 +16,6 @@ interface ThreadHeaderProps {
minimal?: boolean; minimal?: boolean;
promptNavigatorAction?: ReactNode; promptNavigatorAction?: ReactNode;
sessionInfoAction?: ReactNode; sessionInfoAction?: ReactNode;
headerAction?: ReactNode;
} }
export function ThreadHeader({ export function ThreadHeader({
@@ -30,7 +29,6 @@ export function ThreadHeader({
minimal = false, minimal = false,
promptNavigatorAction, promptNavigatorAction,
sessionInfoAction, sessionInfoAction,
headerAction,
}: ThreadHeaderProps) { }: ThreadHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -63,7 +61,6 @@ export function ThreadHeader({
</div> </div>
<div className="ml-auto flex shrink-0 items-center gap-1"> <div className="ml-auto flex shrink-0 items-center gap-1">
{headerAction}
{sessionInfoAction} {sessionInfoAction}
{promptNavigatorAction} {promptNavigatorAction}
{!hideThemeButton ? ( {!hideThemeButton ? (
+28 -66
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; 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 { useTranslation } from "react-i18next";
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext"; import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
@@ -33,7 +33,6 @@ import {
} from "@/lib/mcp-preset-events"; } from "@/lib/mcp-preset-events";
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client"; import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand"; import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import { TEMPORARY_CHAT_ID_PREFIX } from "@/lib/quick-chat";
import type { import type {
ChatSummary, ChatSummary,
SettingsPayload, SettingsPayload,
@@ -319,9 +318,6 @@ interface ThreadShellProps {
allowConversationReset?: boolean; allowConversationReset?: boolean;
showSessionInfo?: boolean; showSessionInfo?: boolean;
emptyStateGreeting?: string; emptyStateGreeting?: string;
emptyStateDescription?: string;
temporary?: boolean;
headerAction?: ReactNode;
} }
function toModelBadgeLabel(modelName: string | null): string | null { function toModelBadgeLabel(modelName: string | null): string | null {
@@ -607,13 +603,10 @@ export function ThreadShell({
allowConversationReset = true, allowConversationReset = true,
showSessionInfo = true, showSessionInfo = true,
emptyStateGreeting, emptyStateGreeting,
emptyStateDescription,
temporary = false,
headerAction,
}: ThreadShellProps) { }: ThreadShellProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const chatId = session?.chatId ?? null; const chatId = session?.chatId ?? null;
const historyKey = temporary ? null : session?.key ?? null; const historyKey = session?.key ?? null;
const { const {
messages: historical, messages: historical,
loading, loading,
@@ -636,14 +629,10 @@ export function ThreadShell({
const [booting, setBooting] = useState(false); const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]); const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const availableSlashCommands = useMemo( const availableSlashCommands = useMemo(
() => temporary () => allowConversationReset
? slashCommands.filter((command) =>
command.command === "/model" || command.command === "/stop",
)
: allowConversationReset
? slashCommands ? slashCommands
: slashCommands.filter((command) => command.command !== "/new"), : slashCommands.filter((command) => command.command !== "/new"),
[allowConversationReset, slashCommands, temporary], [allowConversationReset, slashCommands],
); );
const cliApps = useInstalledSettingItems({ const cliApps = useInstalledSettingItems({
getToken, getToken,
@@ -692,9 +681,8 @@ export function ThreadShell({
const initial = useMemo(() => { const initial = useMemo(() => {
if (!chatId) return historical; if (!chatId) return historical;
if (temporary) return historical;
return messageCacheRef.current.get(chatId) ?? historical; return messageCacheRef.current.get(chatId) ?? historical;
}, [chatId, historical, temporary]); }, [chatId, historical]);
const handleTurnEnd = useCallback(() => { const handleTurnEnd = useCallback(() => {
if (chatId) activeViewportTurnByChatIdRef.current.delete(chatId); if (chatId) activeViewportTurnByChatIdRef.current.delete(chatId);
setSubmittedViewportTurnId(null); setSubmittedViewportTurnId(null);
@@ -714,13 +702,7 @@ export function ThreadShell({
setMessages, setMessages,
streamError, streamError,
dismissStreamError, dismissStreamError,
} = useNanobotStream( } = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
chatId,
initial,
hasPendingToolCalls,
handleTurnEnd,
{ temporary },
);
useLayoutEffect(() => { useLayoutEffect(() => {
if (currentUiMessagesRef.current === messages) return; if (currentUiMessagesRef.current === messages) return;
@@ -849,12 +831,9 @@ export function ThreadShell({
const handleModelPresetChange = useCallback((name: string) => { const handleModelPresetChange = useCallback((name: string) => {
setLocalModelPreset(name); setLocalModelPreset(name);
if (chatId) { if (chatId) {
const request = temporary void client.sendSystemCommand(chatId, `/model ${name}`).catch(() => {});
? client.sendSystemCommand(chatId, `/model ${name}`, 5_000, { temporary: true })
: client.sendSystemCommand(chatId, `/model ${name}`);
void request.catch(() => {});
} }
}, [chatId, client, temporary]); }, [chatId, client]);
const modelPresetOptions = useMemo( const modelPresetOptions = useMemo(
() => modelPresetOptionsFromSettings(settings), () => modelPresetOptionsFromSettings(settings),
[settings], [settings],
@@ -875,16 +854,13 @@ export function ThreadShell({
const withWorkspaceScope = useCallback( const withWorkspaceScope = useCallback(
(options?: SendOptions): SendOptions | undefined => { (options?: SendOptions): SendOptions | undefined => {
if (temporary) {
return { ...(options ?? {}), temporary: true };
}
if (!workspaceScope) return options; if (!workspaceScope) return options;
return { return {
...(options ?? {}), ...(options ?? {}),
workspaceScope, workspaceScope,
}; };
}, },
[temporary, workspaceScope], [workspaceScope],
); );
const refreshModelSettings = useCallback(async () => { const refreshModelSettings = useCallback(async () => {
@@ -918,11 +894,11 @@ export function ThreadShell({
return client.onChat(chatId, (event) => { return client.onChat(chatId, (event) => {
if (event.event !== "turn_model_updated") return; if (event.event !== "turn_model_updated") return;
setFallbackModelName(event.model_name); setFallbackModelName(event.model_name);
}, { temporary }); });
}, [chatId, client, temporary]); }, [chatId, client]);
useEffect(() => { useEffect(() => {
if (!chatId || loading || temporary) return; if (!chatId || loading) return;
const cached = messageCacheRef.current.get(chatId); const cached = messageCacheRef.current.get(chatId);
const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId); const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId);
const hasNewCanonicalHistory = ( const hasNewCanonicalHistory = (
@@ -1052,7 +1028,6 @@ export function ThreadShell({
historyLineage, historyLineage,
historyActiveTurnId, historyActiveTurnId,
hasPendingToolCalls, hasPendingToolCalls,
temporary,
]); ]);
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -1104,7 +1079,7 @@ export function ThreadShell({
}, [chatId, hasPendingToolCalls, historyVersion, messages, reconcileTurnComplete]); }, [chatId, hasPendingToolCalls, historyVersion, messages, reconcileTurnComplete]);
const refreshCanonicalHistory = useCallback(() => { const refreshCanonicalHistory = useCallback(() => {
if (!chatId || temporary) return; if (!chatId) return;
pendingCanonicalHydrateRef.current.set(chatId, { pendingCanonicalHydrateRef.current.set(chatId, {
historyLineage, historyLineage,
historyVersion, historyVersion,
@@ -1114,7 +1089,7 @@ export function ThreadShell({
uiRevision: uiRevisionRef.current, uiRevision: uiRevisionRef.current,
}); });
refreshHistory(); refreshHistory();
}, [chatId, client, historyLineage, historyVersion, refreshHistory, temporary]); }, [chatId, client, historyLineage, historyVersion, refreshHistory]);
useEffect(() => { useEffect(() => {
if (!chatId) return; if (!chatId) return;
@@ -1181,22 +1156,16 @@ export function ThreadShell({
if (chatId) { if (chatId) {
const prev = prevChatIdForCacheRef.current; const prev = prevChatIdForCacheRef.current;
if (prev && prev !== chatId) { if (prev && prev !== chatId) {
if (prev.startsWith(TEMPORARY_CHAT_ID_PREFIX)) {
messageCacheRef.current.delete(prev);
} else {
messageCacheRef.current.set(prev, displayMessages); messageCacheRef.current.set(prev, displayMessages);
}
skipLayoutCacheRef.current = true; skipLayoutCacheRef.current = true;
} }
prevChatIdForCacheRef.current = chatId; prevChatIdForCacheRef.current = chatId;
} else { } else {
if (prevChatIdForCacheRef.current) { if (prevChatIdForCacheRef.current) {
const prev = prevChatIdForCacheRef.current; messageCacheRef.current.set(
if (prev.startsWith(TEMPORARY_CHAT_ID_PREFIX)) { prevChatIdForCacheRef.current,
messageCacheRef.current.delete(prev); displayMessages,
} else { );
messageCacheRef.current.set(prev, displayMessages);
}
skipLayoutCacheRef.current = true; skipLayoutCacheRef.current = true;
} }
prevChatIdForCacheRef.current = null; prevChatIdForCacheRef.current = null;
@@ -1207,7 +1176,7 @@ export function ThreadShell({
// ``useEffect`` reset has flushed; ``skipLayoutCacheRef`` drops the first run that still // ``useEffect`` reset has flushed; ``skipLayoutCacheRef`` drops the first run that still
// sees the *previous* chat's ``messages`` (avoids stale rows leaking across sessions). // sees the *previous* chat's ``messages`` (avoids stale rows leaking across sessions).
useEffect(() => { useEffect(() => {
if (!chatId || temporary) { if (!chatId) {
return; return;
} }
if (skipLayoutCacheRef.current) { if (skipLayoutCacheRef.current) {
@@ -1218,7 +1187,7 @@ export function ThreadShell({
return; return;
} }
messageCacheRef.current.set(chatId, displayMessages); 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. // 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 // 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} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant={showHeroComposer ? "hero" : "thread"} variant={showHeroComposer ? "hero" : "thread"}
slashCommands={availableSlashCommands} slashCommands={availableSlashCommands}
cliApps={temporary ? [] : cliApps} cliApps={cliApps}
mcpPresets={temporary ? [] : mcpPresets} mcpPresets={mcpPresets}
skills={temporary ? [] : skills} skills={skills}
onStop={stop} onStop={stop}
onTranscribeAudio={temporary ? undefined : transcribeAudio} onTranscribeAudio={transcribeAudio}
runStartedAt={currentRunStartedAt} runStartedAt={currentRunStartedAt}
goalState={currentGoalState} goalState={currentGoalState}
workspaceScope={workspaceScope} workspaceScope={workspaceScope}
@@ -1437,7 +1406,6 @@ export function ThreadShell({
quotedContext={quotedContext} quotedContext={quotedContext}
focusRequest={composerFocusSignal} focusRequest={composerFocusSignal}
onQuotedContextChange={setQuotedContext} onQuotedContextChange={setQuotedContext}
allowAttachments={!temporary}
/> />
) : ( ) : (
<ThreadComposer <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"> <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)} /> <HeroGreeting text={emptyStateGreeting ?? t(heroGreetingKey)} />
{emptyStateDescription ? (
<p className="mt-3 max-w-xl text-sm text-muted-foreground">
{emptyStateDescription}
</p>
) : null}
</div> </div>
); );
const sessionInfoAction = historyKey && showSessionInfo ? ( const sessionInfoAction = historyKey && showSessionInfo ? (
@@ -1519,7 +1482,6 @@ export function ThreadShell({
minimal={!session && !loading} minimal={!session && !loading}
promptNavigatorAction={promptNavigatorAction} promptNavigatorAction={promptNavigatorAction}
sessionInfoAction={sessionInfoAction} sessionInfoAction={sessionInfoAction}
headerAction={headerAction}
/> />
) : null} ) : null}
<FilePreviewAvailabilityProvider <FilePreviewAvailabilityProvider
@@ -1536,8 +1498,8 @@ export function ThreadShell({
conversationKey={historyKey} conversationKey={historyKey}
conversationReady={messagesReady} conversationReady={messagesReady}
showScrollToBottomButton={!!session} showScrollToBottomButton={!!session}
cliApps={temporary ? [] : cliApps} cliApps={cliApps}
mcpPresets={temporary ? [] : mcpPresets} mcpPresets={mcpPresets}
slashCommands={availableSlashCommands} slashCommands={availableSlashCommands}
forkBoundaryMessageCount={forkBoundaryMessageCount} forkBoundaryMessageCount={forkBoundaryMessageCount}
hasMoreBefore={hasMoreBefore} hasMoreBefore={hasMoreBefore}
@@ -1545,8 +1507,8 @@ export function ThreadShell({
userMessageOffset={userMessageOffset} userMessageOffset={userMessageOffset}
onLoadOlder={loadOlder} onLoadOlder={loadOlder}
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined} onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
onForkFromMessage={!temporary && onForkChat ? handleForkFromMessage : undefined} onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
onQuoteSelection={session && !temporary ? handleQuoteSelection : undefined} onQuoteSelection={session ? handleQuoteSelection : undefined}
/> />
</FilePreviewAvailabilityProvider> </FilePreviewAvailabilityProvider>
</div> </div>
@@ -542,7 +542,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const distance = el.scrollHeight - el.scrollTop - el.clientHeight; const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
const near = distance < NEAR_BOTTOM_PX; const near = distance < NEAR_BOTTOM_PX;
const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic"; const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic";
const logicallyAtBottom = owner === "automatic" || (owner === "navigation" && near); const logicallyAtBottom = owner === "automatic" || near;
setAtBottom((current) => setAtBottom((current) =>
current === logicallyAtBottom ? current : logicallyAtBottom, current === logicallyAtBottom ? current : logicallyAtBottom,
); );
@@ -557,7 +557,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
if (!direction) return; if (!direction) return;
threadMotionRef.current?.handleUserScrollIntent( threadMotionRef.current?.handleUserScrollIntent(
canScrollInDirection(el, direction), canScrollInDirection(el, direction),
direction === "forward",
); );
}; };
const handleWheel = (event: WheelEvent) => { const handleWheel = (event: WheelEvent) => {
@@ -573,21 +572,20 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const handlePointerDown = (event: PointerEvent) => { const handlePointerDown = (event: PointerEvent) => {
if (event.button === 0 && event.target === el) yieldCameraToUser(); if (event.button === 0 && event.target === el) yieldCameraToUser();
}; };
let lastTouchY: number | null = null; let touchStartY: number | null = null;
const handleTouchStart = (event: TouchEvent) => { const handleTouchStart = (event: TouchEvent) => {
lastTouchY = event.touches[0]?.clientY ?? null; touchStartY = event.touches[0]?.clientY ?? null;
}; };
const handleTouchMove = (event: TouchEvent) => { const handleTouchMove = (event: TouchEvent) => {
const currentY = event.touches[0]?.clientY; const currentY = event.touches[0]?.clientY;
const scrollDeltaY = const scrollDeltaY =
lastTouchY !== null && currentY !== undefined touchStartY !== null && currentY !== undefined
? lastTouchY - currentY ? touchStartY - currentY
: 0; : 0;
lastTouchY = currentY ?? null;
handleDirectionalInput(directionFromDelta(scrollDeltaY)); handleDirectionalInput(directionFromDelta(scrollDeltaY));
}; };
const handleTouchEnd = () => { const handleTouchEnd = () => {
lastTouchY = null; touchStartY = null;
}; };
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ( if (
+5 -34
View File
@@ -168,9 +168,6 @@ export class ThreadMotionCoordinator {
private measurementFrameId: number | null = null; private measurementFrameId: number | null = null;
private geometryDirty = false; private geometryDirty = false;
private composerInputDuringTurn = 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) { constructor(options: ThreadMotionCoordinatorOptions) {
this.camera = options.camera; this.camera = options.camera;
@@ -201,7 +198,6 @@ export class ThreadMotionCoordinator {
if (isNewTurn) { if (isNewTurn) {
this.camera.cancel(); this.camera.cancel();
this.composerInputDuringTurn = false; this.composerInputDuringTurn = false;
this.resumeFollowArmed = false;
this.promptPositioned = turn.entry === "restored"; this.promptPositioned = turn.entry === "restored";
this.mode = this.promptPositioned && turn.hasOutput this.mode = this.promptPositioned && turn.hasOutput
? "follow-output" ? "follow-output"
@@ -253,31 +249,15 @@ export class ThreadMotionCoordinator {
this.handleUserScrollIntent(true); this.handleUserScrollIntent(true);
} }
handleUserScrollIntent(canScroll: boolean, towardLatest = false): void { handleUserScrollIntent(canScroll: boolean): void {
if (this.mode === "browsing-history" && towardLatest && !canScroll) {
this.transitionToAutoFollow(false);
return;
}
const event = canScroll ? "user-scroll" : "boundary-scroll"; const event = canScroll ? "user-scroll" : "boundary-scroll";
const transitioned = this.transition(event); if (!this.transition(event)) return;
if (this.mode === "browsing-history" && canScroll) {
this.resumeFollowArmed = towardLatest;
} else if (transitioned && this.mode === "browsing-history") {
this.resumeFollowArmed = false;
}
if (!transitioned) return;
this.camera.cancel(); this.camera.cancel();
} }
resumeAutoFollow(): void { resumeAutoFollow(): void {
this.transitionToAutoFollow(true);
}
private transitionToAutoFollow(cancelCamera: boolean): void {
if (!this.transition("resume-follow")) return; if (!this.transition("resume-follow")) return;
this.resumeFollowArmed = false; this.camera.cancel();
if (cancelCamera) this.camera.cancel();
this.onAutoFollow?.();
this.invalidateGeometry(); this.invalidateGeometry();
} }
@@ -337,19 +317,11 @@ export class ThreadMotionCoordinator {
case "navigating-history": case "navigating-history":
if (!this.camera.isFollowing()) { if (!this.camera.isFollowing()) {
this.transition("navigation-settled"); this.transition("navigation-settled");
if (nearBottom) { if (nearBottom) this.resumeAutoFollow();
this.resumeAutoFollow();
} else {
this.resumeFollowArmed = true;
}
} }
return "navigation"; return "navigation";
case "browsing-history": case "browsing-history":
if (!nearBottom) { if (!nearBottom) return "user";
this.resumeFollowArmed = true;
return "user";
}
if (!this.resumeFollowArmed) return "user";
this.resumeAutoFollow(); this.resumeAutoFollow();
return "automatic"; return "automatic";
default: default:
@@ -367,7 +339,6 @@ export class ThreadMotionCoordinator {
this.camera.cancel(); this.camera.cancel();
this.turn = { id: null, promptId: null, hasOutput: false }; this.turn = { id: null, promptId: null, hasOutput: false };
this.composerInputDuringTurn = false; this.composerInputDuringTurn = false;
this.resumeFollowArmed = false;
this.mode = "idle"; this.mode = "idle";
this.promptPositioned = false; this.promptPositioned = false;
} }
+2 -17
View File
@@ -487,7 +487,6 @@ export interface SendOptions {
finalizeActiveTurn?: boolean; finalizeActiveTurn?: boolean;
/** Append guidance to the running turn without detaching its active answer segment. */ /** Append guidance to the running turn without detaching its active answer segment. */
continueActiveTurn?: boolean; continueActiveTurn?: boolean;
temporary?: boolean;
} }
export interface SubmittedTurn { export interface SubmittedTurn {
@@ -547,7 +546,6 @@ export function useNanobotStream(
initialMessages: UIMessage[] = [], initialMessages: UIMessage[] = [],
hasPendingToolCalls = false, hasPendingToolCalls = false,
onTurnEnd?: () => void, onTurnEnd?: () => void,
options?: { temporary?: boolean },
): { ): {
messages: UIMessage[]; messages: UIMessage[];
/** Whether ``messages`` belongs to the current ``chatId`` after a session switch. */ /** Whether ``messages`` belongs to the current ``chatId`` after a session switch. */
@@ -1343,9 +1341,7 @@ export function useNanobotStream(
// ``attached`` frames aren't actionable here. // ``attached`` frames aren't actionable here.
}; };
const unsub = options?.temporary const unsub = client.onChat(chatId, handle);
? client.onChat(chatId, handle, { temporary: true })
: client.onChat(chatId, handle);
return () => { return () => {
unsub(); unsub();
buffer.current = null; buffer.current = null;
@@ -1367,7 +1363,6 @@ export function useNanobotStream(
flushPendingStreamEvents, flushPendingStreamEvents,
isSideChannelEvent, isSideChannelEvent,
onTurnEnd, onTurnEnd,
options?.temporary,
schedulePendingStreamFlush, schedulePendingStreamFlush,
scheduleStreamEndTimer, scheduleStreamEndTimer,
]); ]);
@@ -1455,18 +1450,8 @@ export function useNanobotStream(
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)); return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
}); });
suppressStreamUntilTurnEndRef.current = false; suppressStreamUntilTurnEndRef.current = false;
if (options?.temporary) {
client.sendMessage(chatId, "/stop", undefined, { temporary: true });
} else {
client.sendMessage(chatId, "/stop"); client.sendMessage(chatId, "/stop");
} }, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
}, [
chatId,
clearActivitySegment,
client,
flushPendingStreamEvents,
options?.temporary,
]);
const reconcileTurnComplete = useCallback(() => { const reconcileTurnComplete = useCallback(() => {
cancelStreamEndTimer(); cancelStreamEndTimer();
+1 -9
View File
@@ -62,15 +62,7 @@
} }
}, },
"quickChat": { "quickChat": {
"greeting": "What's on your mind?", "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."
}
}, },
"settings": { "settings": {
"backToChat": "Back to chat", "backToChat": "Back to chat",
+1 -9
View File
@@ -62,15 +62,7 @@
} }
}, },
"quickChat": { "quickChat": {
"greeting": "¿Qué tienes en mente?", "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."
}
}, },
"settings": { "settings": {
"backToChat": "Volver al chat", "backToChat": "Volver al chat",
+1 -9
View File
@@ -62,15 +62,7 @@
} }
}, },
"quickChat": { "quickChat": {
"greeting": "De quoi avez-vous envie de parler ?", "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."
}
}, },
"settings": { "settings": {
"backToChat": "Retour au chat", "backToChat": "Retour au chat",
+1 -9
View File
@@ -62,15 +62,7 @@
} }
}, },
"quickChat": { "quickChat": {
"greeting": "Apa yang sedang kamu pikirkan?", "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."
}
}, },
"settings": { "settings": {
"backToChat": "Kembali ke chat", "backToChat": "Kembali ke chat",
+1 -9
View File
@@ -62,15 +62,7 @@
} }
}, },
"quickChat": { "quickChat": {
"greeting": "何について話しますか?", "greeting": "何について話しますか?"
"temporary": {
"title": "一時チャット",
"enter": "一時チャット",
"active": "一時チャット中",
"exit": "一時チャットを終了",
"greeting": "一時チャットを始める",
"description": "履歴、メモリ、ツール、プロジェクトにはアクセスしません。内容は選択したモデル提供元に送信されます。"
}
}, },
"settings": { "settings": {
"backToChat": "チャットに戻る", "backToChat": "チャットに戻る",
+1 -9
View File
@@ -62,15 +62,7 @@
} }
}, },
"quickChat": { "quickChat": {
"greeting": "무슨 이야기를 나눠볼까요?", "greeting": "무슨 이야기를 나눠볼까요?"
"temporary": {
"title": "임시 채팅",
"enter": "임시 채팅",
"active": "임시 채팅 중",
"exit": "임시 채팅 종료",
"greeting": "임시 채팅 시작하기",
"description": "기록, 메모리, 도구, 프로젝트에 접근하지 않습니다. 내용은 선택한 모델 제공업체로 전송됩니다."
}
}, },
"settings": { "settings": {
"backToChat": "채팅으로 돌아가기", "backToChat": "채팅으로 돌아가기",
+1 -9
View File
@@ -62,15 +62,7 @@
} }
}, },
"quickChat": { "quickChat": {
"greeting": "O que você está pensando?", "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."
}
}, },
"settings": { "settings": {
"backToChat": "Voltar para a conversa", "backToChat": "Voltar para a conversa",
+1 -9
View File
@@ -62,15 +62,7 @@
} }
}, },
"quickChat": { "quickChat": {
"greeting": "Bạn đang nghĩ gì?", "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."
}
}, },
"settings": { "settings": {
"backToChat": "Quay lại chat", "backToChat": "Quay lại chat",
+1 -9
View File
@@ -62,15 +62,7 @@
} }
}, },
"quickChat": { "quickChat": {
"greeting": "想聊点什么?", "greeting": "想聊点什么?"
"temporary": {
"title": "临时聊天",
"enter": "临时聊天",
"active": "临时聊天中",
"exit": "退出临时聊天",
"greeting": "开启一次临时聊天",
"description": "不保存记录,不读取记忆或项目,也不使用工具;内容仍会发送给你选择的模型服务商。"
}
}, },
"settings": { "settings": {
"backToChat": "返回聊天", "backToChat": "返回聊天",
+1 -9
View File
@@ -62,15 +62,7 @@
} }
}, },
"quickChat": { "quickChat": {
"greeting": "想聊點什麼?", "greeting": "想聊點什麼?"
"temporary": {
"title": "臨時聊天",
"enter": "臨時聊天",
"active": "臨時聊天中",
"exit": "退出臨時聊天",
"greeting": "開啟一次臨時聊天",
"description": "不儲存記錄,不讀取記憶或專案,也不使用工具;內容仍會傳送給你選擇的模型服務商。"
}
}, },
"settings": { "settings": {
"backToChat": "返回聊天", "backToChat": "返回聊天",
+6 -58
View File
@@ -673,12 +673,8 @@ export class NanobotClient {
} }
} }
/** Subscribe to events for a given chat_id. Auto-attaches unless it is temporary. */ /** Subscribe to events for a given chat_id. Auto-attaches on the next open. */
onChat( onChat(chatId: string, handler: EventHandler): Unsubscribe {
chatId: string,
handler: EventHandler,
options?: { temporary?: boolean },
): Unsubscribe {
let handlers = this.chatHandlers.get(chatId); let handlers = this.chatHandlers.get(chatId);
if (!handlers) { if (!handlers) {
handlers = new Set(); handlers = new Set();
@@ -693,7 +689,7 @@ export class NanobotClient {
handler(ev); handler(ev);
} }
} }
if (!options?.temporary) this.attach(chatId); this.attach(chatId);
return () => { return () => {
const current = this.chatHandlers.get(chatId); const current = this.chatHandlers.get(chatId);
if (!current) return; if (!current) return;
@@ -813,10 +809,9 @@ export class NanobotClient {
turnId?: string; turnId?: string;
/** False for side-channel or injected messages that do not own a lifecycle. */ /** False for side-channel or injected messages that do not own a lifecycle. */
startsNewRun?: boolean; startsNewRun?: boolean;
temporary?: boolean;
}, },
): void { ): void {
if (!options?.temporary) this.knownChats.add(chatId); this.knownChats.add(chatId);
const frame: Outbound = { const frame: Outbound = {
type: "message", type: "message",
chat_id: chatId, chat_id: chatId,
@@ -827,7 +822,6 @@ export class NanobotClient {
...(options?.quotedContext?.trim() ? { quoted_context: options.quotedContext.trim() } : {}), ...(options?.quotedContext?.trim() ? { quoted_context: options.quotedContext.trim() } : {}),
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}), ...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
...(options?.turnId ? { turn_id: options.turnId } : {}), ...(options?.turnId ? { turn_id: options.turnId } : {}),
...(options?.temporary ? { temporary: true } : {}),
webui: true, webui: true,
}; };
if (!this.frameFitsTransport(frame)) { if (!this.frameFitsTransport(frame)) {
@@ -849,12 +843,7 @@ export class NanobotClient {
this.queueSend(frame); this.queueSend(frame);
} }
sendSystemCommand( sendSystemCommand(chatId: string, command: string, timeoutMs = 5_000): Promise<void> {
chatId: string,
command: string,
timeoutMs = 5_000,
options?: { temporary?: boolean },
): Promise<void> {
const normalized = command.trim(); const normalized = command.trim();
const turnId = `${SYSTEM_COMMAND_TURN_PREFIX}${crypto.randomUUID()}`; const turnId = `${SYSTEM_COMMAND_TURN_PREFIX}${crypto.randomUUID()}`;
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
@@ -863,44 +852,8 @@ export class NanobotClient {
reject(new Error("system command timed out")); reject(new Error("system command timed out"));
}, timeoutMs); }, timeoutMs);
this.pendingSystemCommands.set(turnId, { resolve, reject, timer }); this.pendingSystemCommands.set(turnId, { resolve, reject, timer });
this.sendMessage(chatId, normalized, undefined, { this.sendMessage(chatId, normalized, undefined, { turnId });
turnId,
temporary: options?.temporary,
}); });
});
}
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 { setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
@@ -1054,11 +1007,6 @@ export class NanobotClient {
return; return;
} }
if (parsed.event === "temporary_chat_discarded") {
this.pendingInboundByChat.delete(parsed.chat_id);
return;
}
if (parsed.event === "error" && parsed.detail === "workspace_scope_rejected") { if (parsed.event === "error" && parsed.detail === "workspace_scope_rejected") {
this.emitError({ this.emitError({
kind: "workspace_scope_rejected", kind: "workspace_scope_rejected",
-16
View File
@@ -2,7 +2,6 @@ import type { ChatSummary } from "@/lib/types";
export const QUICK_CHAT_ID = "quick-chat"; export const QUICK_CHAT_ID = "quick-chat";
export const QUICK_CHAT_KEY = `websocket:${QUICK_CHAT_ID}`; export const QUICK_CHAT_KEY = `websocket:${QUICK_CHAT_ID}`;
export const TEMPORARY_CHAT_ID_PREFIX = "temporary-";
export function isQuickChatKey(key: string | null): boolean { export function isQuickChatKey(key: string | null): boolean {
return key === QUICK_CHAT_KEY; return key === QUICK_CHAT_KEY;
@@ -21,18 +20,3 @@ export function quickChatSession(persisted?: ChatSummary): ChatSummary {
workspaceScope: persisted?.workspaceScope ?? null, 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,
};
}
-3
View File
@@ -1247,7 +1247,6 @@ export type InboundEvent =
scope?: "metadata" | "thread" | string; scope?: "metadata" | "thread" | string;
workspace_scope?: WorkspaceScopePayload; workspace_scope?: WorkspaceScopePayload;
} }
| { event: "temporary_chat_discarded"; chat_id: string }
| { event: "transcription_result"; request_id: string; text: string } | { event: "transcription_result"; request_id: string; text: string }
| { | {
event: "transcription_error"; event: "transcription_error";
@@ -1334,7 +1333,6 @@ export type Outbound =
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload } | { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string } | { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
| { type: "attach"; chat_id: string } | { type: "attach"; chat_id: string }
| { type: "discard_temporary_chat"; chat_id: string }
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload } | { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
| { type: "transcribe_audio"; request_id: string; data_url: string; duration_ms?: number } | { type: "transcribe_audio"; request_id: string; data_url: string; duration_ms?: number }
| { | {
@@ -1347,7 +1345,6 @@ export type Outbound =
quoted_context?: string; quoted_context?: string;
workspace_scope?: WorkspaceScopePayload; workspace_scope?: WorkspaceScopePayload;
turn_id?: string; turn_id?: string;
temporary?: true;
/** Marks messages sent by the embedded WebUI, without changing the /** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */ * generic websocket protocol for other clients. */
webui?: true; webui?: true;
-41
View File
@@ -12,7 +12,6 @@ const getSessionAutomationsSpy = vi.fn<(key: string) => Promise<SessionAutomatio
const toggleThemeSpy = vi.fn(); const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn(); const updateUrlSpy = vi.fn();
const attachSpy = vi.fn(); const attachSpy = vi.fn();
const discardTemporaryChatSpy = vi.fn();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>(); const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>(); const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
let mockSessions: ChatSummary[] = []; let mockSessions: ChatSummary[] = [];
@@ -220,7 +219,6 @@ vi.mock("@/lib/nanobot-client", () => {
sendMessage = vi.fn(); sendMessage = vi.fn();
newChat = vi.fn(); newChat = vi.fn();
attach = attachSpy; attach = attachSpy;
discardTemporaryChat = discardTemporaryChatSpy;
close = vi.fn(); close = vi.fn();
updateUrl = updateUrlSpy; updateUrl = updateUrlSpy;
updateMaxFrameBytes = vi.fn(); updateMaxFrameBytes = vi.fn();
@@ -248,7 +246,6 @@ describe("App layout", () => {
getSessionAutomationsSpy.mockReset().mockResolvedValue([]); getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
toggleThemeSpy.mockReset(); toggleThemeSpy.mockReset();
attachSpy.mockReset(); attachSpy.mockReset();
discardTemporaryChatSpy.mockReset();
runStatusHandlers.clear(); runStatusHandlers.clear();
sessionUpdateHandlers.clear(); sessionUpdateHandlers.clear();
window.history.replaceState(null, "", "/"); window.history.replaceState(null, "", "/");
@@ -352,22 +349,6 @@ describe("App layout", () => {
).toBeTruthy(); ).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 () => { it("opens a single fixed Quick Chat without provisioning a new session", async () => {
render(<App />); render(<App />);
@@ -415,28 +396,6 @@ describe("App layout", () => {
expect(within(sidebar).queryAllByRole("button", { current: "page" })).toHaveLength(1); 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 () => { it("restores Quick Chat before it has a persisted session", async () => {
window.history.replaceState(null, "", "/#/quick-chat"); window.history.replaceState(null, "", "/#/quick-chat");
+11 -37
View File
@@ -44,7 +44,6 @@ function rect({
describe("ChatList", () => { describe("ChatList", () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllGlobals();
}); });
it("orders chats by latest session activity by default", () => { it("orders chats by latest session activity by default", () => {
@@ -221,20 +220,8 @@ describe("ChatList", () => {
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument(); 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 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) => { vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
revealFrame = callback; revealFrame = callback;
return 1; return 1;
@@ -245,9 +232,7 @@ describe("ChatList", () => {
return rect({ left: 0, top: 0, width: 300, height: 200 }); return rect({ left: 0, top: 0, width: 300, height: 200 });
} }
if (this.getAttribute("data-chat-row") === "websocket:active") { if (this.getAttribute("data-chat-row") === "websocket:active") {
return activeTargetVisible return rect({ left: 8, top: 12, width: 284, height: 32 });
? rect({ left: 8, top: 12, width: 284, height: 32 })
: rect({ left: 0, top: 0, width: 0, height: 0 });
} }
if (this.getAttribute("data-chat-row") === "websocket:inactive") { if (this.getAttribute("data-chat-row") === "websocket:inactive") {
return rect({ left: 8, top: 48, width: 284, height: 40 }); return rect({ left: 8, top: 48, width: 284, height: 40 });
@@ -270,7 +255,7 @@ describe("ChatList", () => {
const { rerender } = render( const { rerender } = render(
<ChatList <ChatList
{...props} {...props}
activeKey="websocket:active" activeKey={null}
/>, />,
); );
@@ -280,9 +265,16 @@ describe("ChatList", () => {
"transition-[transform,width,height]", "transition-[transform,width,height]",
"motion-reduce:transition-none", "motion-reduce:transition-none",
); );
expect(highlight).toHaveStyle("opacity: 0");
expect(screen.queryByTestId("sessions-selection-highlight-surface")) expect(screen.queryByTestId("sessions-selection-highlight-surface"))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
expect(resizeObserverCallback).not.toBeNull();
rerender(
<ChatList
{...props}
activeKey="websocket:active"
/>,
);
const activeButton = screen.getByTitle("Active topic"); const activeButton = screen.getByTitle("Active topic");
expect(activeButton).toHaveAttribute("aria-current", "page"); expect(activeButton).toHaveAttribute("aria-current", "page");
@@ -292,10 +284,6 @@ describe("ChatList", () => {
"bg-sidebar-accent", "bg-sidebar-accent",
"shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]", "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( expect(highlight).toHaveStyle(
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none", "width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
); );
@@ -303,17 +291,6 @@ describe("ChatList", () => {
revealFrame?.(0); revealFrame?.(0);
expect(highlight.style.transitionProperty).toBe(""); 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( rerender(
<ChatList <ChatList
{...props} {...props}
@@ -326,9 +303,6 @@ describe("ChatList", () => {
expect(highlight).toHaveStyle( expect(highlight).toHaveStyle(
"width: 284px; height: 40px; transform: translate3d(8px, 48px, 0)", "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 () => { it("can collapse a project group and keeps project rename separate from chat titles", async () => {
-36
View File
@@ -70,42 +70,6 @@ afterEach(() => {
}); });
describe("NanobotClient", () => { 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", () => { it("routes events to the matching chat handler", () => {
const client = new NanobotClient({ const client = new NanobotClient({
url: "ws://test", url: "ws://test",
-12
View File
@@ -1,12 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
createTemporaryChatSession,
isQuickChatKey, isQuickChatKey,
QUICK_CHAT_ID, QUICK_CHAT_ID,
QUICK_CHAT_KEY, QUICK_CHAT_KEY,
quickChatSession, quickChatSession,
TEMPORARY_CHAT_ID_PREFIX,
} from "@/lib/quick-chat"; } from "@/lib/quick-chat";
describe("Quick Chat identity", () => { describe("Quick Chat identity", () => {
@@ -36,14 +34,4 @@ describe("Quick Chat identity", () => {
modelPreset: "fast", 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);
});
}); });
-13
View File
@@ -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();
});
}); });
-54
View File
@@ -410,9 +410,6 @@ describe("ThreadMotionCoordinator", () => {
expect(camera.jumpTo).toHaveBeenCalledWith(780); expect(camera.jumpTo).toHaveBeenCalledWith(780);
coordinator.takeUserControl(); coordinator.takeUserControl();
expect(coordinator.observeScroll(true)).toBe("user");
expect(coordinator.snapshot().mode).toBe("browsing-history");
expect(coordinator.observeScroll(false)).toBe("user"); expect(coordinator.observeScroll(false)).toBe("user");
expect(coordinator.snapshot().mode).toBe("browsing-history"); expect(coordinator.snapshot().mode).toBe("browsing-history");
@@ -420,57 +417,6 @@ describe("ThreadMotionCoordinator", () => {
expect(coordinator.snapshot().mode).toBe("anchor-prompt"); 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", () => { it("preserves history browsing when an active turn is cleared", () => {
const { const {
camera, camera,
+11 -43
View File
@@ -86,22 +86,6 @@ function makeClient() {
runStartedAtByChatId.delete(chatId); runStartedAtByChatId.delete(chatId);
return true; 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 { return {
get status() { get status() {
return status; return status;
@@ -128,7 +112,17 @@ function makeClient() {
canReconcileCanonicalCompletion, canReconcileCanonicalCompletion,
reconcileCanonicalCompletion, reconcileCanonicalCompletion,
getGoalState: (chatId: string) => goalStateByChatId.get(chatId), 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) => { onError: (handler: (err: StreamError) => void) => {
errorHandlers.add(handler); errorHandlers.add(handler);
return () => { return () => {
@@ -3375,32 +3369,6 @@ describe("ThreadShell", () => {
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument(); 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 () => { it("removes session-management affordances from a fixed conversation", async () => {
const client = makeClient(); const client = makeClient();
vi.stubGlobal( vi.stubGlobal(
-95
View File
@@ -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 () => { it("keeps the scroll-to-bottom button above a growing composer", async () => {
const resizeObserver = stubResizeObserver(); const resizeObserver = stubResizeObserver();