diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts index 55d3a85b6..0d2f40b2e 100644 --- a/bridge/src/whatsapp.ts +++ b/bridge/src/whatsapp.ts @@ -165,6 +165,10 @@ export class WhatsAppClient { fallbackContent = '[Video]'; const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined); if (path) mediaPaths.push(path); + } else if (unwrapped.audioMessage) { + fallbackContent = '[Voice Message]'; + const path = await this.downloadMedia(msg, unwrapped.audioMessage.mimetype ?? undefined); + if (path) mediaPaths.push(path); } const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || ''; diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 9f4bcdd13..d1952312b 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -55,6 +55,7 @@ from nanobot.utils.progress_events import ( on_progress_accepts_tool_events, ) from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE +from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn if TYPE_CHECKING: from nanobot.config.schema import ChannelsConfig, ExecToolConfig, ToolsConfig, WebToolsConfig @@ -112,6 +113,11 @@ class _LoopHook(AgentHook): async def before_iteration(self, context: AgentHookContext) -> None: self._loop._current_iteration = context.iteration + logger.debug( + "Starting agent loop iteration {} for session {}", + context.iteration, + self._session_key, + ) async def before_execute_tools(self, context: AgentHookContext) -> None: if self._on_progress: @@ -422,7 +428,7 @@ class AgentLoop: logger.warning("MCP connection cancelled (will retry next message)") self._mcp_stacks.clear() except BaseException as e: - logger.error("Failed to connect MCP servers (will retry next message): {}", e) + logger.warning("Failed to connect MCP servers (will retry next message): {}", e) self._mcp_stacks.clear() finally: self._mcp_connecting = False @@ -648,6 +654,7 @@ class AgentLoop: context_block_limit=self.context_block_limit, provider_retry_mode=self.provider_retry_mode, progress_callback=on_progress, + stream_progress_deltas=on_stream is not None, retry_wait_callback=on_retry_wait, checkpoint_callback=_checkpoint, injection_callback=_drain_pending, @@ -800,6 +807,33 @@ class AgentLoop: channel=msg.channel, chat_id=msg.chat_id, content="", metadata=msg.metadata or {}, )) + if msg.channel == "websocket": + # Signal that the turn is fully complete (all tools executed, + # final text streamed). This lets WS clients know when to + # definitively stop the loading indicator. + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, + content="", metadata={**msg.metadata, "_turn_end": True}, + )) + if msg.metadata.get("webui") is True: + async def _generate_title_and_notify() -> None: + generated = await maybe_generate_webui_title_after_turn( + channel=msg.channel, + metadata=msg.metadata, + sessions=self.sessions, + session_key=session_key, + provider=self.provider, + model=self.model, + ) + if generated: + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="", + metadata={**msg.metadata, "_session_updated": True}, + )) + + self._schedule_background(_generate_title_and_notify()) except asyncio.CancelledError: logger.info("Task cancelled for session {}", session_key) # Preserve partial context from the interrupted turn so @@ -903,6 +937,8 @@ class AgentLoop: self.sessions.save(session) session, pending = self.auto_compact.prepare_session(session, key) + if pending: + logger.info("Memory compact triggered for session {}", key) await self.consolidator.maybe_consolidate_by_tokens( session, @@ -915,6 +951,7 @@ class AgentLoop: # LLM via the merged prompt. See _persist_subagent_followup. is_subagent = msg.sender_id == "subagent" if is_subagent and self._persist_subagent_followup(session, msg): + logger.debug("Subagent result persisted for session {}", key) self.sessions.save(session) self._set_tool_context( channel, chat_id, msg.metadata.get("message_id"), @@ -986,6 +1023,7 @@ class AgentLoop: key = session_key or msg.session_key session = self.sessions.get_or_create(key) + mark_webui_session(session, msg.metadata) if self._restore_runtime_checkpoint(session): self.sessions.save(session) if self._restore_pending_user_turn(session): @@ -1131,7 +1169,7 @@ class AgentLoop: ask_user_options_from_messages(all_msgs) if stop_reason == "ask_user" else [], msg.channel, ) - if on_stream is not None and stop_reason not in {"ask_user", "error"}: + if on_stream is not None and stop_reason not in {"ask_user", "error", "tool_error"}: meta["_streamed"] = True return OutboundMessage( channel=msg.channel, diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 85cc5ab4a..7794af5c2 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -974,12 +974,10 @@ class Dream: if event["status"] == "ok": changelog.append(f"{event['name']}: {event['detail']}") - # Advance cursor — always, to avoid re-processing Phase 1 - new_cursor = batch[-1]["cursor"] - self.store.set_last_dream_cursor(new_cursor) - self.store.compact_history() - + # Only advance cursor on successful completion to prevent silent loss if result and result.stop_reason == "completed": + new_cursor = batch[-1]["cursor"] + self.store.set_last_dream_cursor(new_cursor) logger.info( "Dream done: {} change(s), cursor advanced to {}", len(changelog), new_cursor, @@ -987,10 +985,12 @@ class Dream: else: reason = result.stop_reason if result else "exception" logger.warning( - "Dream incomplete ({}): cursor advanced to {}", - reason, new_cursor, + "Dream incomplete ({}): cursor NOT advanced, will retry next cron cycle", + reason, ) + self.store.compact_history() + # Git auto-commit (only when there are actual changes) if changelog and self.store.git.is_initialized(): ts = batch[-1]["timestamp"] diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 3d941f382..7fe92ad51 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -33,6 +33,7 @@ from nanobot.utils.runtime import ( ensure_nonempty_tool_result, is_blank_text, repeated_external_lookup_error, + repeated_workspace_violation_error, ) _DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model." @@ -75,6 +76,7 @@ class AgentRunSpec: context_block_limit: int | None = None provider_retry_mode: str = "standard" progress_callback: Any | None = None + stream_progress_deltas: bool = True retry_wait_callback: Any | None = None checkpoint_callback: Any | None = None injection_callback: Any | None = None @@ -239,6 +241,8 @@ class AgentRunner: stop_reason = "completed" tool_events: list[dict[str, str]] = [] external_lookup_counts: dict[str, int] = {} + # Per-turn throttle for repeated attempts against the same outside target. + workspace_violation_counts: dict[str, int] = {} empty_content_retries = 0 length_recovery_count = 0 had_injections = False @@ -258,12 +262,11 @@ class AgentRunner: # Snipping may have created new orphans; clean them up. messages_for_model = self._drop_orphan_tool_results(messages_for_model) messages_for_model = self._backfill_missing_tool_results(messages_for_model) - except Exception as exc: - logger.warning( - "Context governance failed on turn {} for {}: {}; applying minimal repair", + except Exception: + logger.exception( + "Context governance failed on turn {} for {}; applying minimal repair", iteration, spec.session_key or "default", - exc, ) try: messages_for_model = self._drop_orphan_tool_results(messages) @@ -314,6 +317,7 @@ class AgentRunner: spec, tool_calls, external_lookup_counts, + workspace_violation_counts, ) tool_events.extend(new_events) context.tool_results = list(results) @@ -612,6 +616,7 @@ class AgentRunner: wants_streaming = hook.wants_streaming() wants_progress_streaming = ( not wants_streaming + and spec.stream_progress_deltas and spec.progress_callback is not None and getattr(self.provider, "supports_progress_deltas", False) is True ) @@ -698,20 +703,25 @@ class AgentRunner: spec: AgentRunSpec, tool_calls: list[ToolCallRequest], external_lookup_counts: dict[str, int], + workspace_violation_counts: dict[str, int], ) -> tuple[list[Any], list[dict[str, str]], BaseException | None]: batches = self._partition_tool_batches(spec, tool_calls) tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = [] for batch in batches: if spec.concurrent_tools and len(batch) > 1: batch_results = await asyncio.gather(*( - self._run_tool(spec, tool_call, external_lookup_counts) + self._run_tool( + spec, tool_call, external_lookup_counts, workspace_violation_counts, + ) for tool_call in batch )) tool_results.extend(batch_results) else: batch_results = [] for tool_call in batch: - result = await self._run_tool(spec, tool_call, external_lookup_counts) + result = await self._run_tool( + spec, tool_call, external_lookup_counts, workspace_violation_counts, + ) tool_results.append(result) batch_results.append(result) if isinstance(result[2], AskUserInterrupt): @@ -734,6 +744,7 @@ class AgentRunner: spec: AgentRunSpec, tool_call: ToolCallRequest, external_lookup_counts: dict[str, int], + workspace_violation_counts: dict[str, int], ) -> tuple[Any, dict[str, str], BaseException | None]: hint = "\n\n[Analyze the error above and try a different approach.]" lookup_error = repeated_external_lookup_error( @@ -763,16 +774,18 @@ class AgentRunner: "status": "error", "detail": prep_error.split(": ", 1)[-1][:120], } - if self._is_workspace_violation(prep_error): - logger.warning( - "Tool {} blocked by workspace/safety guard during preparation; aborting turn: {}", - tool_call.name, - prep_error.replace("\n", " ").strip()[:200], - ) - event["detail"] = ("workspace_violation: " - + prep_error.replace("\n", " ").strip())[:160] - return prep_error, event, RuntimeError(prep_error) - return prep_error + hint, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None + handled = self._classify_violation( + raw_text=prep_error, + soft_payload=prep_error + hint, + event=event, + tool_call=tool_call, + workspace_violation_counts=workspace_violation_counts, + ) + if handled is not None: + return handled + return prep_error + hint, event, ( + RuntimeError(prep_error) if spec.fail_on_tool_error else None + ) try: if tool is not None: result = await tool.execute(**params) @@ -789,18 +802,20 @@ class AgentRunner: if isinstance(exc, AskUserInterrupt): event["status"] = "waiting" return "", event, exc - if self._is_workspace_violation(str(exc)): - logger.warning( - "Tool {} blocked by workspace/safety guard; aborting turn: {}", - tool_call.name, - str(exc).replace("\n", " ").strip()[:200], - ) - event["detail"] = ("workspace_violation: " - + str(exc).replace("\n", " ").strip())[:160] - return f"Error: {type(exc).__name__}: {exc}", event, exc + payload = f"Error: {type(exc).__name__}: {exc}" + handled = self._classify_violation( + raw_text=str(exc), + # Preserve legacy exception payloads without the retry hint. + soft_payload=payload, + event=event, + tool_call=tool_call, + workspace_violation_counts=workspace_violation_counts, + ) + if handled is not None: + return handled if spec.fail_on_tool_error: - return f"Error: {type(exc).__name__}: {exc}", event, exc - return f"Error: {type(exc).__name__}: {exc}", event, None + return payload, event, exc + return payload, event, None if isinstance(result, str) and result.startswith("Error"): event = { @@ -808,17 +823,15 @@ class AgentRunner: "status": "error", "detail": result.replace("\n", " ").strip()[:120], } - - # check the outside workspace error and break loop - if self._is_workspace_violation(result): - logger.warning( - "Tool {} blocked by workspace/safety guard; aborting turn: {}", - tool_call.name, - result.replace("\n", " ").strip()[:200], - ) - event["detail"] = ("workspace_violation: " - + result.replace("\n", " ").strip())[:160] - return result, event, RuntimeError(result) + handled = self._classify_violation( + raw_text=result, + soft_payload=result + hint, + event=event, + tool_call=tool_call, + workspace_violation_counts=workspace_violation_counts, + ) + if handled is not None: + return handled if spec.fail_on_tool_error: return result + hint, event, RuntimeError(result) return result + hint, event, None @@ -831,23 +844,97 @@ class AgentRunner: detail = detail[:120] + "..." return result, {"name": tool_call.name, "status": "ok", "detail": detail}, None - # Markers identifying tool results that represent a workspace / safety boundary rejection. - _WORKSPACE_BLOCK_MARKERS: tuple[str, ...] = ( + # SSRF is a hard security block at the tool boundary, but the agent turn + # should recover conversationally instead of aborting the runtime. + _SSRF_MARKERS: tuple[str, ...] = ( + "internal/private url detected", + "private/internal address", + "private address", + ) + _SSRF_BOUNDARY_NOTE: str = ( + "This is a non-bypassable security boundary. Stop trying to access " + "private/internal URLs. Do not retry with curl, wget, encoded IPs, " + "alternate DNS, redirects, proxies, or another tool. Ask the user for " + "local files, logs, screenshots, or an explicit safe public URL instead. " + "If the user explicitly trusts this private URL, ask them to whitelist " + "the exact IP/CIDR via tools.ssrfWhitelist." + ) + + # Non-SSRF boundary markers returned to the LLM as recoverable tool errors. + _WORKSPACE_VIOLATION_MARKERS: tuple[str, ...] = ( "outside the configured workspace", "outside allowed directory", "working_dir is outside", "working_dir could not be resolved", - "path traversal detected", "path outside working dir", - "internal/private url detected", + "path traversal detected", ) @classmethod - def _is_workspace_violation(cls, text: str) -> bool: + def _is_ssrf_violation(cls, text: str) -> bool: if not text: return False lowered = text.lower() - return any(marker in lowered for marker in cls._WORKSPACE_BLOCK_MARKERS) + return any(marker in lowered for marker in cls._SSRF_MARKERS) + + @classmethod + def _is_workspace_violation(cls, text: str) -> bool: + """True when *text* looks like any policy boundary rejection.""" + if not text: + return False + lowered = text.lower() + if cls._is_ssrf_violation(lowered): + return True + return any(marker in lowered for marker in cls._WORKSPACE_VIOLATION_MARKERS) + + def _classify_violation( + self, + *, + raw_text: str, + soft_payload: str, + event: dict[str, str], + tool_call: ToolCallRequest, + workspace_violation_counts: dict[str, int], + ) -> tuple[Any, dict[str, str], BaseException | None] | None: + """Classify safety-boundary failures, or return ``None`` to pass through.""" + if self._is_ssrf_violation(raw_text): + logger.warning( + "Tool {} blocked by SSRF guard; returning non-retryable tool error: {}", + tool_call.name, + raw_text.replace("\n", " ").strip()[:200], + ) + event["detail"] = self._event_detail("ssrf_violation: ", raw_text) + return self._ssrf_soft_payload(raw_text), event, None + + if self._is_workspace_violation(raw_text): + escalation = repeated_workspace_violation_error( + tool_call.name, + tool_call.arguments, + workspace_violation_counts, + ) + event["detail"] = self._event_detail("workspace_violation: ", raw_text) + if escalation is not None: + logger.warning( + "Tool {} hit workspace boundary repeatedly; escalating hint", + tool_call.name, + ) + event["detail"] = self._event_detail( + "workspace_violation_escalated: ", + raw_text, + ) + return escalation, event, None + return soft_payload, event, None + + return None + + @classmethod + def _ssrf_soft_payload(cls, raw_text: str) -> str: + text = raw_text.strip() or "Error: request blocked by SSRF guard" + return f"{text}\n\n{cls._SSRF_BOUNDARY_NOTE}" + + @staticmethod + def _event_detail(prefix: str, text: str, limit: int = 160) -> str: + return (prefix + text.replace("\n", " ").strip())[:limit] async def _emit_checkpoint( self, @@ -895,12 +982,11 @@ class AgentRunner: result, max_chars=spec.max_tool_result_chars, ) - except Exception as exc: - logger.warning( - "Tool result persist failed for {} in {}: {}; using raw result", + except Exception: + logger.exception( + "Tool result persist failed for {} in {}; using raw result", tool_call_id, spec.session_key or "default", - exc, ) content = result if isinstance(content, str) and len(content) > spec.max_tool_result_chars: diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 6d64698a7..e418c2a7e 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -250,7 +250,7 @@ class SubagentManager: except Exception as e: status.phase = "error" status.error = str(e) - logger.error("Subagent [{}] failed: {}", task_id, e) + logger.exception("Subagent [{}] failed", task_id) await self._announce_result(task_id, label, task, f"Error: {e}", origin, "error", origin_message_id) async def _announce_result( diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index 587a149f2..8091e7670 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -14,6 +14,13 @@ from nanobot.utils.helpers import build_image_content_blocks, detect_image_mime from nanobot.config.paths import get_media_dir +_FS_WORKSPACE_BOUNDARY_NOTE = ( + " (this is a hard policy boundary, not a transient failure; " + "do not retry with shell tricks or alternative tools, and ask " + "the user how to proceed if the resource is genuinely required)" +) + + def _resolve_path( path: str, workspace: Path | None = None, @@ -29,7 +36,10 @@ def _resolve_path( media_path = get_media_dir().resolve() all_dirs = [allowed_dir] + [media_path] + (extra_allowed_dirs or []) if not any(_is_under(resolved, d) for d in all_dirs): - raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}") + raise PermissionError( + f"Path {path} is outside allowed directory {allowed_dir}" + + _FS_WORKSPACE_BOUNDARY_NOTE + ) return resolved diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 580020a64..6d4e7d6cd 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -198,11 +198,10 @@ class MCPToolWrapper(Tool): await asyncio.sleep(1) # Brief backoff before retry continue # Second transient failure — give up with retry-specific message - logger.error( - "MCP tool '{}' failed after retry: {}: {}", + logger.exception( + "MCP tool '{}' failed after retry: {}", self._name, type(exc).__name__, - exc, ) return f"(MCP tool call failed after retry: {type(exc).__name__})" logger.exception( @@ -287,11 +286,10 @@ class MCPResourceWrapper(Tool): ) await asyncio.sleep(1) continue - logger.error( - "MCP resource '{}' failed after retry: {}: {}", + logger.exception( + "MCP resource '{}' failed after retry: {}", self._name, type(exc).__name__, - exc, ) return f"(MCP resource read failed after retry: {type(exc).__name__})" logger.exception( @@ -383,7 +381,7 @@ class MCPPromptWrapper(Tool): logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name) return "(MCP prompt call was cancelled)" except McpError as exc: - logger.error( + logger.exception( "MCP prompt '{}' failed: code={} message={}", self._name, exc.error.code, @@ -400,11 +398,10 @@ class MCPPromptWrapper(Tool): ) await asyncio.sleep(1) continue - logger.error( - "MCP prompt '{}' failed after retry: {}: {}", + logger.exception( + "MCP prompt '{}' failed after retry: {}", self._name, type(exc).__name__, - exc, ) return f"(MCP prompt call failed after retry: {type(exc).__name__})" logger.exception( @@ -439,8 +436,8 @@ async def connect_mcp_servers( """Connect to configured MCP servers and register their tools, resources, prompts. Returns a dict mapping server name -> its dedicated AsyncExitStack. - Each server gets its own stack and runs in its own task to prevent - cancel scope conflicts when multiple MCP servers are configured. + Each server gets its own stack to prevent cancel scope conflicts + when multiple MCP servers are configured. """ from mcp import ClientSession, StdioServerParameters from mcp.client.sse import sse_client @@ -608,26 +605,20 @@ async def connect_mcp_servers( " Hint: this looks like stdio protocol pollution. Make sure the MCP server writes " "only JSON-RPC to stdout and sends logs/debug output to stderr instead." ) - logger.error("MCP server '{}': failed to connect: {}{}", name, e, hint) + logger.exception("MCP server '{}': failed to connect: {}", name, hint) with suppress(Exception): await server_stack.aclose() return name, None server_stacks: dict[str, AsyncExitStack] = {} - tasks: list[asyncio.Task] = [] for name, cfg in mcp_servers.items(): - task = asyncio.create_task(connect_single_server(name, cfg)) - tasks.append(task) - - results = await asyncio.gather(*tasks, return_exceptions=True) - - for i, result in enumerate(results): - name = list(mcp_servers.keys())[i] - if isinstance(result, BaseException): - if not isinstance(result, asyncio.CancelledError): - logger.error("MCP server '{}' connection task failed: {}", name, result) - elif result is not None and result[1] is not None: + try: + result = await connect_single_server(name, cfg) + except Exception as e: + logger.error("MCP server '{}' connection failed: {}", name, e) + continue + if result is not None and result[1] is not None: server_stacks[result[0]] = result[1] return server_stacks diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index b7f841a5c..44767e97a 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -19,6 +19,16 @@ from nanobot.config.paths import get_media_dir _IS_WINDOWS = sys.platform == "win32" +# Policy note appended to recoverable workspace-boundary guard errors. +_WORKSPACE_BOUNDARY_NOTE = ( + "\n\nNote: this is a hard policy boundary, not a transient failure. " + "Do NOT retry with shell tricks (symlinks, base64 piping, alternative " + "tools, working_dir overrides). If the user genuinely needs this " + "resource, tell them you cannot reach it under the current " + "restrict_to_workspace policy and ask how to proceed." +) + + @tool_parameters( tool_parameters_schema( command=StringSchema("The shell command to execute"), @@ -83,6 +93,19 @@ class ExecTool(Tool): _MAX_TIMEOUT = 600 _MAX_OUTPUT = 10_000 + # Kernel device files safe as stdio redirect targets (#3599). + _BENIGN_DEVICE_PATHS: frozenset[str] = frozenset({ + "/dev/null", + "/dev/zero", + "/dev/full", + "/dev/random", + "/dev/urandom", + "/dev/stdin", + "/dev/stdout", + "/dev/stderr", + "/dev/tty", + }) + @property def description(self) -> str: return ( @@ -113,9 +136,15 @@ class ExecTool(Tool): requested = Path(cwd).expanduser().resolve() workspace_root = Path(self.working_dir).expanduser().resolve() except Exception: - return "Error: working_dir could not be resolved" + return ( + "Error: working_dir could not be resolved" + + _WORKSPACE_BOUNDARY_NOTE + ) if requested != workspace_root and workspace_root not in requested.parents: - return "Error: working_dir is outside the configured workspace" + return ( + "Error: working_dir is outside the configured workspace" + + _WORKSPACE_BOUNDARY_NOTE + ) guard_error = self._guard_command(command, cwd) if guard_error: @@ -191,9 +220,12 @@ class ExecTool(Tool): ) -> asyncio.subprocess.Process: """Launch *command* in a platform-appropriate shell.""" if _IS_WINDOWS: - comspec = env.get("COMSPEC", os.environ.get("COMSPEC", "cmd.exe")) - return await asyncio.create_subprocess_exec( - comspec, "/c", command, + # create_subprocess_exec re-quotes args via list2cmdline, which + # breaks commands containing paths with spaces (e.g. "D:\Program + # Files\python.exe" "script.py"). create_subprocess_shell passes + # the raw command string to COMSPEC without re-quoting. + return await asyncio.create_subprocess_shell( + command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=cwd, @@ -289,21 +321,33 @@ class ExecTool(Tool): from nanobot.security.network import contains_internal_url if contains_internal_url(cmd): + # The runner turns this marker into a non-retryable security hint. return "Error: Command blocked by safety guard (internal/private URL detected)" if self.restrict_to_workspace: if "..\\" in cmd or "../" in cmd: - return "Error: Command blocked by safety guard (path traversal detected)" + return ( + "Error: Command blocked by safety guard (path traversal detected)" + + _WORKSPACE_BOUNDARY_NOTE + ) cwd_path = Path(cwd).resolve() for raw in self._extract_absolute_paths(cmd): try: expanded = os.path.expandvars(raw.strip()) + # Match against the un-resolved path first. On Linux, + # /dev/stderr is a symlink to /proc/self/fd/2 and + # ``Path.resolve()`` would mask the device-file intent. + if self._is_benign_device_path(expanded): + continue p = Path(expanded).expanduser().resolve() except Exception: continue + if self._is_benign_device_path(str(p)): + continue + media_path = get_media_dir().resolve() if (p.is_absolute() and cwd_path not in p.parents @@ -311,15 +355,25 @@ class ExecTool(Tool): and media_path not in p.parents and p != media_path ): - return "Error: Command blocked by safety guard (path outside working dir)" + return ( + "Error: Command blocked by safety guard (path outside working dir)" + + _WORKSPACE_BOUNDARY_NOTE + ) return None + @classmethod + def _is_benign_device_path(cls, path: str) -> bool: + """Return True for kernel device files that should never be workspace-blocked.""" + if path in cls._BENIGN_DEVICE_PATHS: + return True + return path.startswith("/dev/fd/") + @staticmethod def _extract_absolute_paths(command: str) -> list[str]: # Windows: match drive-root paths like `C:\` as well as `C:\path\to\file` # NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted. win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]*", command) posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only - home_paths = re.findall(r"(?:^|[\s|>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~ + home_paths = re.findall(r"(?:^|[\s>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~ return win_paths + posix_paths + home_paths diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 6378a7979..aae40ac9c 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -500,10 +500,10 @@ class WebFetchTool(Tool): "untrusted": True, "text": text, }, ensure_ascii=False) except httpx.ProxyError as e: - logger.error("WebFetch proxy error for {}: {}", url, e) + logger.exception("WebFetch proxy error for {}", url) return json.dumps({"error": f"Proxy error: {e}", "url": url}, ensure_ascii=False) except Exception as e: - logger.error("WebFetch error for {}: {}", url, e) + logger.exception("WebFetch error for {}", url) return json.dumps({"error": str(e), "url": url}, ensure_ascii=False) def _to_markdown(self, html_content: str) -> str: diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 6097b420f..087677494 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -38,6 +38,7 @@ class BaseChannel(ABC): bus: The message bus for communication. """ self.config = config + self.logger = logger.bind(channel=self.name) self.bus = bus self._running = False @@ -61,8 +62,8 @@ class BaseChannel(ABC): language=self.transcription_language or None, ) return await provider.transcribe(file_path) - except Exception as e: - logger.warning("{}: audio transcription failed: {}", self.name, e) + except Exception: + self.logger.exception("Audio transcription failed") return "" async def login(self, force: bool = False) -> bool: @@ -136,7 +137,7 @@ class BaseChannel(ABC): else: allow_list = getattr(self.config, "allow_from", []) if not allow_list: - logger.warning("{}: allow_from is empty — all access denied", self.name) + self.logger.warning("allow_from is empty — all access denied") return False if "*" in allow_list: return True @@ -165,10 +166,10 @@ class BaseChannel(ABC): session_key: Optional session key override (e.g. thread-scoped sessions). """ if not self.is_allowed(sender_id): - logger.warning( - "Access denied for sender {} on channel {}. " + self.logger.warning( + "Access denied for sender {}. " "Add them to allowFrom list in config to grant access.", - sender_id, self.name, + sender_id, ) return diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 609a7fa54..72199fdf9 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -12,7 +12,6 @@ from typing import Any from urllib.parse import unquote, urljoin, urlparse import httpx -from loguru import logger from pydantic import Field from nanobot.bus.events import OutboundMessage @@ -113,7 +112,7 @@ class NanobotDingTalkHandler(CallbackHandler): content = content + "\n\nReceived files:\n" + file_list if not content: - logger.warning( + self.channel.logger.warning( "Received empty or unsupported message type: {}", chatbot_msg.message_type, ) @@ -128,7 +127,7 @@ class NanobotDingTalkHandler(CallbackHandler): or message.data.get("openConversationId") ) - logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content) + self.channel.logger.info("Received message from {} ({}): {}", sender_name, sender_id, content) # Forward to Nanobot via _on_message (non-blocking). # Store reference to prevent GC before task completes. @@ -146,8 +145,8 @@ class NanobotDingTalkHandler(CallbackHandler): return AckMessage.STATUS_OK, "OK" - except Exception as e: - logger.error("Error processing DingTalk message: {}", e) + except Exception: + self.channel.logger.exception("Error processing message") # Return OK to avoid retry loop from DingTalk server return AckMessage.STATUS_OK, "Error" @@ -204,20 +203,20 @@ class DingTalkChannel(BaseChannel): """Start the DingTalk bot with Stream Mode.""" try: if not DINGTALK_AVAILABLE: - logger.error( - "DingTalk Stream SDK not installed. Run: pip install dingtalk-stream" + self.logger.error( + "Stream SDK not installed. Run: pip install dingtalk-stream" ) return if not self.config.client_id or not self.config.client_secret: - logger.error("DingTalk client_id and client_secret not configured") + self.logger.error("client_id and client_secret not configured") return self._running = True self._http = httpx.AsyncClient() - logger.info( - "Initializing DingTalk Stream Client with Client ID: {}...", + self.logger.info( + "Initializing Stream Client with Client ID: {}...", self.config.client_id, ) credential = Credential(self.config.client_id, self.config.client_secret) @@ -227,20 +226,20 @@ class DingTalkChannel(BaseChannel): handler = NanobotDingTalkHandler(self) self._client.register_callback_handler(ChatbotMessage.TOPIC, handler) - logger.info("DingTalk bot started with Stream Mode") + self.logger.info("bot started with Stream Mode") # Reconnect loop: restart stream if SDK exits or crashes while self._running: try: await self._client.start() except Exception as e: - logger.warning("DingTalk stream error: {}", e) + self.logger.warning("stream error: {}", e) if self._running: - logger.info("Reconnecting DingTalk stream in 5 seconds...") + self.logger.info("Reconnecting stream in 5 seconds...") await asyncio.sleep(5) - except Exception as e: - logger.exception("Failed to start DingTalk channel: {}", e) + except Exception: + self.logger.exception("Failed to start channel") async def stop(self) -> None: """Stop the DingTalk bot.""" @@ -266,7 +265,7 @@ class DingTalkChannel(BaseChannel): } if not self._http: - logger.warning("DingTalk HTTP client not initialized, cannot refresh token") + self.logger.warning("HTTP client not initialized, cannot refresh token") return None try: @@ -277,8 +276,8 @@ class DingTalkChannel(BaseChannel): # Expire 60s early to be safe self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60 return self._access_token - except Exception as e: - logger.error("Failed to get DingTalk access token: {}", e) + except Exception: + self.logger.exception("Failed to get access token") return None @staticmethod @@ -317,8 +316,8 @@ class DingTalkChannel(BaseChannel): ) -> tuple[bytes, str, str | None]: ext = Path(filename).suffix.lower() if ext in self._ZIP_BEFORE_UPLOAD_EXTS or content_type == "text/html": - logger.info( - "DingTalk does not accept raw HTML attachments, zipping {} before upload", + self.logger.info( + "does not accept raw HTML attachments, zipping {} before upload", filename, ) return self._zip_bytes(filename, data) @@ -327,7 +326,7 @@ class DingTalkChannel(BaseChannel): def _validate_remote_media_url(self, media_ref: str) -> bool: ok, err = validate_url_target(media_ref) if not ok: - logger.warning("DingTalk remote media URL blocked ref={} reason={}", media_ref, err) + self.logger.warning("remote media URL blocked ref={} reason={}", media_ref, err) return False return True @@ -343,15 +342,15 @@ class DingTalkChannel(BaseChannel): def _next_remote_media_url(self, current_url: str, location: str | None) -> str | None: if not self.config.allow_remote_media_redirects: - logger.warning("DingTalk media download redirect refused ref={}", current_url) + self.logger.warning("media download redirect refused ref={}", current_url) return None if not location: - logger.warning("DingTalk media download redirect without Location ref={}", current_url) + self.logger.warning("media download redirect without Location ref={}", current_url) return None next_url = urljoin(current_url, location) if not self._redirect_host_allowed(current_url, next_url): - logger.warning( - "DingTalk media download cross-host redirect refused ref={} next={}", + self.logger.warning( + "media download cross-host redirect refused ref={} next={}", current_url, next_url, ) @@ -382,8 +381,8 @@ class DingTalkChannel(BaseChannel): async with stream("GET", current_url, follow_redirects=False) as resp: final_ok, final_err = validate_resolved_url(str(resp.url)) if not final_ok: - logger.warning( - "DingTalk remote media redirect blocked ref={} final={} reason={}", + self.logger.warning( + "remote media redirect blocked ref={} final={} reason={}", media_ref, resp.url, final_err, @@ -398,8 +397,8 @@ class DingTalkChannel(BaseChannel): current_url = next_url continue if resp.status_code >= 400: - logger.warning( - "DingTalk media download failed status={} ref={}", + self.logger.warning( + "media download failed status={} ref={}", resp.status_code, current_url, ) @@ -409,15 +408,15 @@ class DingTalkChannel(BaseChannel): async for chunk in resp.aiter_bytes(): total += len(chunk) if total > DINGTALK_MAX_REMOTE_MEDIA_BYTES: - logger.warning( - "DingTalk media download too large ref={} bytes>{}", + self.logger.warning( + "media download too large ref={} bytes>{}", current_url, DINGTALK_MAX_REMOTE_MEDIA_BYTES, ) return None, None chunks.append(chunk) return b"".join(chunks), (resp.headers.get("content-type") or "") - logger.warning("DingTalk media download exceeded redirect limit ref={}", media_ref) + self.logger.warning("media download exceeded redirect limit ref={}", media_ref) return None, None current_url = media_ref @@ -425,8 +424,8 @@ class DingTalkChannel(BaseChannel): resp = await self._http.get(current_url, follow_redirects=False) final_ok, final_err = validate_resolved_url(str(getattr(resp, "url", current_url))) if not final_ok: - logger.warning( - "DingTalk remote media redirect blocked ref={} final={} reason={}", + self.logger.warning( + "remote media redirect blocked ref={} final={} reason={}", media_ref, getattr(resp, "url", current_url), final_err, @@ -441,27 +440,27 @@ class DingTalkChannel(BaseChannel): current_url = next_url continue if resp.status_code >= 400: - logger.warning( - "DingTalk media download failed status={} ref={}", + self.logger.warning( + "media download failed status={} ref={}", resp.status_code, current_url, ) return None, None if len(resp.content) > DINGTALK_MAX_REMOTE_MEDIA_BYTES: - logger.warning( - "DingTalk media download too large ref={} bytes>{}", + self.logger.warning( + "media download too large ref={} bytes>{}", current_url, DINGTALK_MAX_REMOTE_MEDIA_BYTES, ) return None, None return resp.content, (resp.headers.get("content-type") or "") - logger.warning("DingTalk media download exceeded redirect limit ref={}", media_ref) + self.logger.warning("media download exceeded redirect limit ref={}", media_ref) return None, None - except httpx.TransportError as e: - logger.error("DingTalk media download network error ref={} err={}", media_ref, e) + except httpx.TransportError: + self.logger.exception("media download network error ref={}", media_ref) raise - except Exception as e: - logger.error("DingTalk media download error ref={} err={}", media_ref, e) + except Exception: + self.logger.exception("media download error ref={}", media_ref) return None, None async def _read_media_bytes( @@ -486,13 +485,13 @@ class DingTalkChannel(BaseChannel): else: local_path = Path(os.path.expanduser(media_ref)) if not local_path.is_file(): - logger.warning("DingTalk media file not found: {}", local_path) + self.logger.warning("media file not found: {}", local_path) return None, None, None data = await asyncio.to_thread(local_path.read_bytes) content_type = mimetypes.guess_type(local_path.name)[0] return data, local_path.name, content_type - except Exception as e: - logger.error("DingTalk media read error ref={} err={}", media_ref, e) + except Exception: + self.logger.exception("media read error ref={}", media_ref) return None, None, None async def _upload_media( @@ -514,23 +513,23 @@ class DingTalkChannel(BaseChannel): text = resp.text result = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} if resp.status_code >= 400: - logger.error("DingTalk media upload failed status={} type={} body={}", resp.status_code, media_type, text[:500]) + self.logger.error("media upload failed status={} type={} body={}", resp.status_code, media_type, text[:500]) return None errcode = result.get("errcode", 0) if errcode != 0: - logger.error("DingTalk media upload api error type={} errcode={} body={}", media_type, errcode, text[:500]) + self.logger.error("media upload api error type={} errcode={} body={}", media_type, errcode, text[:500]) return None sub = result.get("result") or {} media_id = result.get("media_id") or result.get("mediaId") or sub.get("media_id") or sub.get("mediaId") if not media_id: - logger.error("DingTalk media upload missing media_id body={}", text[:500]) + self.logger.error("media upload missing media_id body={}", text[:500]) return None return str(media_id) - except httpx.TransportError as e: - logger.error("DingTalk media upload network error type={} err={}", media_type, e) + except httpx.TransportError: + self.logger.exception("media upload network error type={}", media_type) raise - except Exception as e: - logger.error("DingTalk media upload error type={} err={}", media_type, e) + except Exception: + self.logger.exception("media upload error type={}", media_type) return None async def _send_batch_message( @@ -541,7 +540,7 @@ class DingTalkChannel(BaseChannel): msg_param: dict[str, Any], ) -> bool: if not self._http: - logger.warning("DingTalk HTTP client not initialized, cannot send") + self.logger.warning("HTTP client not initialized, cannot send") return False headers = {"x-acs-dingtalk-access-token": token} @@ -568,7 +567,7 @@ class DingTalkChannel(BaseChannel): resp = await self._http.post(url, json=payload, headers=headers) body = resp.text if resp.status_code != 200: - logger.error("DingTalk send failed msgKey={} status={} body={}", msg_key, resp.status_code, body[:500]) + self.logger.error("send failed msgKey={} status={} body={}", msg_key, resp.status_code, body[:500]) return False try: result = resp.json() @@ -576,15 +575,15 @@ class DingTalkChannel(BaseChannel): result = {} errcode = result.get("errcode") if errcode not in (None, 0): - logger.error("DingTalk send api error msgKey={} errcode={} body={}", msg_key, errcode, body[:500]) + self.logger.error("send api error msgKey={} errcode={} body={}", msg_key, errcode, body[:500]) return False - logger.debug("DingTalk message sent to {} with msgKey={}", chat_id, msg_key) + self.logger.debug("message sent to {} with msgKey={}", chat_id, msg_key) return True - except httpx.TransportError as e: - logger.error("DingTalk network error sending message msgKey={} err={}", msg_key, e) + except httpx.TransportError: + self.logger.exception("network error sending message msgKey={}", msg_key) raise - except Exception as e: - logger.error("Error sending DingTalk message msgKey={} err={}", msg_key, e) + except Exception: + self.logger.exception("Error sending message msgKey={}", msg_key) return False async def _send_markdown_text(self, token: str, chat_id: str, content: str) -> bool: @@ -610,11 +609,11 @@ class DingTalkChannel(BaseChannel): ) if ok: return True - logger.warning("DingTalk image url send failed, trying upload fallback: {}", media_ref) + self.logger.warning("image url send failed, trying upload fallback: {}", media_ref) data, filename, content_type = await self._read_media_bytes(media_ref) if not data: - logger.error("DingTalk media read failed: {}", media_ref) + self.logger.error("media read failed: {}", media_ref) return False filename = filename or self._guess_filename(media_ref, upload_type) @@ -646,7 +645,7 @@ class DingTalkChannel(BaseChannel): ) if ok: return True - logger.warning("DingTalk image media_id send failed, falling back to file: {}", media_ref) + self.logger.warning("image media_id send failed, falling back to file: {}", media_ref) return await self._send_batch_message( token, @@ -668,7 +667,7 @@ class DingTalkChannel(BaseChannel): ok = await self._send_media_ref(token, msg.chat_id, media_ref) if ok: continue - logger.error("DingTalk media send failed for {}", media_ref) + self.logger.error("media send failed for {}", media_ref) # Send visible fallback so failures are observable by the user. filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref)) await self._send_markdown_text( @@ -691,7 +690,7 @@ class DingTalkChannel(BaseChannel): permission checks before publishing to the bus. """ try: - logger.info("DingTalk inbound: {} from {}", content, sender_name) + self.logger.info("inbound: {} from {}", content, sender_name) is_group = conversation_type == "2" and conversation_id chat_id = f"group:{conversation_id}" if is_group else sender_id await self._handle_message( @@ -704,8 +703,8 @@ class DingTalkChannel(BaseChannel): "conversation_type": conversation_type, }, ) - except Exception as e: - logger.error("Error publishing DingTalk message: {}", e) + except Exception: + self.logger.exception("Error publishing message") async def _download_dingtalk_file( self, @@ -719,7 +718,7 @@ class DingTalkChannel(BaseChannel): try: token = await self._get_access_token() if not token or not self._http: - logger.error("DingTalk file download: no token or http client") + self.logger.error("file download: no token or http client") return None # Step 1: Exchange downloadCode for a temporary download URL @@ -728,19 +727,19 @@ class DingTalkChannel(BaseChannel): payload = {"downloadCode": download_code, "robotCode": self.config.client_id} resp = await self._http.post(api_url, json=payload, headers=headers) if resp.status_code != 200: - logger.error("DingTalk get download URL failed: status={}, body={}", resp.status_code, resp.text) + self.logger.error("get download URL failed: status={}, body={}", resp.status_code, resp.text) return None result = resp.json() download_url = result.get("downloadUrl") if not download_url: - logger.error("DingTalk download URL not found in response: {}", result) + self.logger.error("download URL not found in response: {}", result) return None # Step 2: Download the file content file_resp = await self._http.get(download_url, follow_redirects=True) if file_resp.status_code != 200: - logger.error("DingTalk file download failed: status={}", file_resp.status_code) + self.logger.error("file download failed: status={}", file_resp.status_code) return None # Save to media directory (accessible under workspace) @@ -748,8 +747,8 @@ class DingTalkChannel(BaseChannel): download_dir.mkdir(parents=True, exist_ok=True) file_path = download_dir / filename await asyncio.to_thread(file_path.write_bytes, file_resp.content) - logger.info("DingTalk file saved: {}", file_path) + self.logger.info("file saved: {}", file_path) return str(file_path) - except Exception as e: - logger.error("DingTalk file download error: {}", e) + except Exception: + self.logger.exception("file download error") return None diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index bb39b66b7..10d569692 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -10,7 +10,6 @@ from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, Literal -from loguru import logger from pydantic import Field from nanobot.bus.events import OutboundMessage @@ -86,12 +85,12 @@ if DISCORD_AVAILABLE: async def on_ready(self) -> None: self._channel._bot_user_id = str(self.user.id) if self.user else None - logger.info("Discord bot connected as user {}", self._channel._bot_user_id) + self._channel.logger.info("bot connected as user {}", self._channel._bot_user_id) try: synced = await self.tree.sync() - logger.info("Discord app commands synced: {}", len(synced)) + self._channel.logger.info("app commands synced: {}", len(synced)) except Exception as e: - logger.warning("Discord app command sync failed: {}", e) + self._channel.logger.warning("app command sync failed: {}", e) async def on_message(self, message: discord.Message) -> None: await self._channel._handle_discord_message(message) @@ -111,7 +110,7 @@ if DISCORD_AVAILABLE: await interaction.response.send_message(text, ephemeral=True) return True except Exception as e: - logger.warning("Discord interaction response failed: {}", e) + self._channel.logger.warning("interaction response failed: {}", e) return False async def _resolve_interaction_channel( @@ -126,7 +125,7 @@ if DISCORD_AVAILABLE: try: channel = await self.fetch_channel(channel_id) except Exception as e: - logger.warning("Discord interaction channel {} unavailable: {}", channel_id, e) + self._channel.logger.warning("interaction channel {} unavailable: {}", channel_id, e) return None self._channel._remember_channel(channel) return channel @@ -154,7 +153,7 @@ if DISCORD_AVAILABLE: channel_id = interaction.channel_id if channel_id is None: - logger.warning("Discord slash command missing channel_id: {}", command_text) + self._channel.logger.warning("slash command missing channel_id: {}", command_text) return if not self._channel.is_allowed(sender_id): @@ -226,8 +225,8 @@ if DISCORD_AVAILABLE: error: app_commands.AppCommandError, ) -> None: command_name = interaction.command.qualified_name if interaction.command else "?" - logger.warning( - "Discord app command failed user={} channel={} cmd={} error={}", + self._channel.logger.warning( + "app command failed user={} channel={} cmd={} error={}", interaction.user.id, interaction.channel_id, command_name, @@ -243,7 +242,7 @@ if DISCORD_AVAILABLE: try: channel = await self.fetch_channel(channel_id) except Exception as e: - logger.warning("Discord channel {} unavailable: {}", msg.chat_id, e) + self._channel.logger.warning("channel {} unavailable: {}", msg.chat_id, e) return reference, mention_settings = self._build_reply_context(channel, msg.reply_to) @@ -281,11 +280,11 @@ if DISCORD_AVAILABLE: """Send a file attachment via discord.py.""" path = Path(file_path) if not path.is_file(): - logger.warning("Discord file not found, skipping: {}", file_path) + self._channel.logger.warning("file not found, skipping: {}", file_path) return False if path.stat().st_size > MAX_ATTACHMENT_BYTES: - logger.warning("Discord file too large (>20MB), skipping: {}", path.name) + self._channel.logger.warning("file too large (>20MB), skipping: {}", path.name) return False try: @@ -294,10 +293,10 @@ if DISCORD_AVAILABLE: kwargs["reference"] = reference kwargs["allowed_mentions"] = mention_settings await channel.send(**kwargs) - logger.info("Discord file sent: {}", path.name) + self._channel.logger.info("file sent: {}", path.name) return True - except Exception as e: - logger.error("Error sending Discord file {}: {}", path.name, e) + except Exception: + self._channel.logger.exception("Error sending file {}", path.name) return False @staticmethod @@ -321,7 +320,7 @@ if DISCORD_AVAILABLE: try: message_id = int(reply_to) except ValueError: - logger.warning("Invalid Discord reply target: {}", reply_to) + self._channel.logger.warning("Invalid reply target: {}", reply_to) return None, mention_settings return channel.get_partial_message(message_id), mention_settings @@ -385,11 +384,11 @@ class DiscordChannel(BaseChannel): async def start(self) -> None: """Start the Discord client.""" if not DISCORD_AVAILABLE: - logger.error("discord.py not installed. Run: pip install nanobot-ai[discord]") + self.logger.error("discord.py not installed. Run: pip install nanobot-ai[discord]") return if not self.config.token: - logger.error("Discord bot token not configured") + self.logger.error("bot token not configured") return try: @@ -407,8 +406,8 @@ class DiscordChannel(BaseChannel): password=self.config.proxy_password, ) elif has_user != has_pass: - logger.warning( - "Discord proxy auth incomplete: both proxy_username and " + self.logger.warning( + "proxy auth incomplete: both proxy_username and " "proxy_password must be set; ignoring partial credentials", ) @@ -418,21 +417,21 @@ class DiscordChannel(BaseChannel): proxy=self.config.proxy, proxy_auth=proxy_auth, ) - except Exception as e: - logger.error("Failed to initialize Discord client: {}", e) + except Exception: + self.logger.exception("Failed to initialize client") self._client = None self._running = False return self._running = True - logger.info("Starting Discord client via discord.py...") + self.logger.info("Starting client via discord.py...") try: await self._client.start(self.config.token) except asyncio.CancelledError: raise - except Exception as e: - logger.error("Discord client startup failed: {}", e) + except Exception: + self.logger.exception("client startup failed") finally: self._running = False await self._reset_runtime_state(close_client=True) @@ -446,15 +445,15 @@ class DiscordChannel(BaseChannel): """Send a message through Discord using discord.py.""" client = self._client if client is None or not client.is_ready(): - logger.warning("Discord client not ready; dropping outbound message") + self.logger.warning("client not ready; dropping outbound message") return is_progress = bool((msg.metadata or {}).get("_progress")) try: await client.send_outbound(msg) - except Exception as e: - logger.error("Error sending Discord message: {}", e) + except Exception: + self.logger.exception("Error sending message") raise finally: if not is_progress: @@ -467,7 +466,7 @@ class DiscordChannel(BaseChannel): """Progressive Discord delivery: send once, then edit until the stream ends.""" client = self._client if client is None or not client.is_ready(): - logger.warning("Discord client not ready; dropping stream delta") + self.logger.warning("client not ready; dropping stream delta") return meta = metadata or {} @@ -497,7 +496,7 @@ class DiscordChannel(BaseChannel): target = await self._resolve_channel(chat_id) if target is None: - logger.warning("Discord stream target {} unavailable", chat_id) + self.logger.warning("stream target {} unavailable", chat_id) return now = time.monotonic() @@ -506,7 +505,7 @@ class DiscordChannel(BaseChannel): buf.message = await target.send(content=buf.text) buf.last_edit = now except Exception as e: - logger.warning("Discord stream initial send failed: {}", e) + self.logger.warning("stream initial send failed: {}", e) raise return @@ -517,7 +516,7 @@ class DiscordChannel(BaseChannel): await buf.message.edit(content=DiscordBotClient._build_chunks(buf.text, [], False)[0]) buf.last_edit = now except Exception as e: - logger.warning("Discord stream edit failed: {}", e) + self.logger.warning("stream edit failed: {}", e) raise async def _handle_discord_message(self, message: discord.Message) -> None: @@ -560,7 +559,7 @@ class DiscordChannel(BaseChannel): await message.add_reaction(self.config.read_receipt_emoji) self._pending_reactions[channel_id] = message except Exception as e: - logger.debug("Failed to add read receipt reaction: {}", e) + self.logger.debug("Failed to add read receipt reaction: {}", e) # Delayed working indicator (cosmetic — not tied to subagent lifecycle) async def _delayed_working_emoji() -> None: @@ -603,7 +602,7 @@ class DiscordChannel(BaseChannel): try: return await client.fetch_channel(channel_id) except Exception as e: - logger.warning("Discord channel {} unavailable: {}", chat_id, e) + self.logger.warning("channel {} unavailable: {}", chat_id, e) return None async def _finalize_stream(self, chat_id: str, buf: _StreamBuf) -> None: @@ -616,12 +615,12 @@ class DiscordChannel(BaseChannel): try: await buf.message.edit(content=chunks[0]) except Exception as e: - logger.warning("Discord final stream edit failed: {}", e) + self.logger.warning("final stream edit failed: {}", e) raise target = getattr(buf.message, "channel", None) or await self._resolve_channel(chat_id) if target is None: - logger.warning("Discord stream follow-up target {} unavailable", chat_id) + self.logger.warning("stream follow-up target {} unavailable", chat_id) self._stream_bufs.pop(chat_id, None) return @@ -673,7 +672,7 @@ class DiscordChannel(BaseChannel): media_paths.append(str(file_path)) markers.append(f"[attachment: {file_path.name}]") except Exception as e: - logger.warning("Failed to download Discord attachment: {}", e) + self.logger.warning("Failed to download attachment: {}", e) markers.append(f"[attachment: {filename} - download failed]") return media_paths, markers @@ -715,8 +714,8 @@ class DiscordChannel(BaseChannel): if bot_user_id is None and self._client and self._client.user: bot_user_id = str(self._client.user.id) if bot_user_id is None: - logger.debug( - "Discord message in {} ignored (bot identity unavailable)", message.channel.id + self.logger.debug( + "message in {} ignored (bot identity unavailable)", message.channel.id ) return False @@ -729,7 +728,7 @@ class DiscordChannel(BaseChannel): if self._references_bot_message(message, bot_user_id): return True - logger.debug("Discord message in {} ignored (bot not mentioned)", message.channel.id) + self.logger.debug("message in {} ignored (bot not mentioned)", message.channel.id) return False return True @@ -759,7 +758,7 @@ class DiscordChannel(BaseChannel): except asyncio.CancelledError: return except Exception as e: - logger.debug("Discord typing indicator failed for {}: {}", channel_id, e) + self.logger.debug("typing indicator failed for {}: {}", channel_id, e) return self._typing_tasks[channel_id] = asyncio.create_task(typing_loop()) @@ -803,6 +802,6 @@ class DiscordChannel(BaseChannel): try: await self._client.close() except Exception as e: - logger.warning("Discord client close failed: {}", e) + self.logger.warning("client close failed: {}", e) self._client = None self._bot_user_id = None diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index 36cafc995..f729d18e4 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -128,7 +128,7 @@ class EmailChannel(BaseChannel): async def start(self) -> None: """Start polling IMAP for inbound emails.""" if not self.config.consent_granted: - logger.warning( + self.logger.warning( "Email channel disabled: consent_granted is false. " "Set channels.email.consentGranted=true after explicit user permission." ) @@ -139,12 +139,12 @@ class EmailChannel(BaseChannel): self._running = True if not self.config.verify_dkim and not self.config.verify_spf: - logger.warning( - "Email channel: DKIM and SPF verification are both DISABLED. " + self.logger.warning( + "DKIM and SPF verification are both DISABLED. " "Emails with spoofed From headers will be accepted. " "Set verify_dkim=true and verify_spf=true for anti-spoofing protection." ) - logger.info("Starting Email channel (IMAP polling mode)...") + self.logger.info("Starting Email channel (IMAP polling mode)...") poll_seconds = max(5, int(self.config.poll_interval_seconds)) while self._running: @@ -167,8 +167,8 @@ class EmailChannel(BaseChannel): media=item.get("media") or None, metadata=item.get("metadata", {}), ) - except Exception as e: - logger.error("Email polling error: {}", e) + except Exception: + self.logger.exception("Polling error") await asyncio.sleep(poll_seconds) @@ -179,16 +179,16 @@ class EmailChannel(BaseChannel): async def send(self, msg: OutboundMessage) -> None: """Send email via SMTP.""" if not self.config.consent_granted: - logger.warning("Skip email send: consent_granted is false") + self.logger.warning("Skip email send: consent_granted is false") return if not self.config.smtp_host: - logger.warning("Email channel SMTP host not configured") + self.logger.warning("SMTP host not configured") return to_addr = msg.chat_id.strip() if not to_addr: - logger.warning("Email channel missing recipient address") + self.logger.warning("Missing recipient address") return # Determine if this is a reply (recipient has sent us an email before) @@ -197,7 +197,7 @@ class EmailChannel(BaseChannel): # autoReplyEnabled only controls automatic replies, not proactive sends if is_reply and not self.config.auto_reply_enabled and not force_send: - logger.info("Skip automatic email reply to {}: auto_reply_enabled is false", to_addr) + self.logger.info("Skip automatic reply to {}: auto_reply_enabled is false", to_addr) return base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply") @@ -220,8 +220,8 @@ class EmailChannel(BaseChannel): try: await asyncio.to_thread(self._smtp_send, email_msg) - except Exception as e: - logger.error("Error sending email to {}: {}", to_addr, e) + except Exception: + self.logger.exception("Error sending to {}", to_addr) raise def _validate_config(self) -> bool: @@ -240,7 +240,7 @@ class EmailChannel(BaseChannel): missing.append("smtp_password") if missing: - logger.error("Email channel not configured, missing: {}", ', '.join(missing)) + self.logger.error("Channel not configured, missing: {}", ', '.join(missing)) return False return True @@ -321,7 +321,7 @@ class EmailChannel(BaseChannel): except Exception as exc: if attempt == 1 or not self._is_stale_imap_error(exc): raise - logger.warning("Email IMAP connection went stale, retrying once: {}", exc) + self.logger.warning("IMAP connection went stale, retrying once: {}", exc) return messages @@ -348,11 +348,11 @@ class EmailChannel(BaseChannel): status, _ = client.select(mailbox) except Exception as exc: if self._is_missing_mailbox_error(exc): - logger.warning("Email mailbox unavailable, skipping poll for {}: {}", mailbox, exc) + self.logger.warning("Mailbox unavailable, skipping poll for {}: {}", mailbox, exc) return messages raise if status != "OK": - logger.warning("Email mailbox select returned {}, skipping poll for {}", status, mailbox) + self.logger.warning("Mailbox select returned {}, skipping poll for {}", status, mailbox) return messages status, data = client.search(None, *search_criteria) @@ -382,7 +382,7 @@ class EmailChannel(BaseChannel): if not sender: continue if self._is_self_address(sender): - logger.info("Email from {} ignored: matches bot-owned address", sender) + self.logger.info("From {} ignored: matches bot-owned address", sender) self._remember_processed_uid(uid, dedupe, cycle_uids) if mark_seen: client.store(imap_id, "+FLAGS", "\\Seen") @@ -391,22 +391,28 @@ class EmailChannel(BaseChannel): # --- Anti-spoofing: verify Authentication-Results --- spf_pass, dkim_pass = self._check_authentication_results(parsed) if self.config.verify_spf and not spf_pass: - logger.warning( - "Email from {} rejected: SPF verification failed " + self.logger.warning( + "From {} rejected: SPF verification failed " "(no 'spf=pass' in Authentication-Results header)", sender, ) self._remember_processed_uid(uid, dedupe, cycle_uids) continue if self.config.verify_dkim and not dkim_pass: - logger.warning( - "Email from {} rejected: DKIM verification failed " + self.logger.warning( + "From {} rejected: DKIM verification failed " "(no 'dkim=pass' in Authentication-Results header)", sender, ) self._remember_processed_uid(uid, dedupe, cycle_uids) continue + if not self.is_allowed(sender): + self._remember_processed_uid(uid, dedupe, cycle_uids) + if mark_seen: + client.store(imap_id, "+FLAGS", "\\Seen") + continue + subject = self._decode_header_value(parsed.get("Subject", "")) date_value = parsed.get("Date", "") message_id = parsed.get("Message-ID", "").strip() @@ -635,7 +641,7 @@ class EmailChannel(BaseChannel): content_type = part.get_content_type() if not any(fnmatch(content_type, pat) for pat in allowed_types): - logger.debug("Email attachment skipped (type {}): not in allowed list", content_type) + logger.debug("Attachment skipped (type {}): not in allowed list", content_type) continue payload = part.get_payload(decode=True) @@ -643,7 +649,7 @@ class EmailChannel(BaseChannel): continue if len(payload) > max_size: logger.warning( - "Email attachment skipped: size {} exceeds limit {}", + "Attachment skipped: size {} exceeds limit {}", len(payload), max_size, ) @@ -656,9 +662,9 @@ class EmailChannel(BaseChannel): try: dest.write_bytes(payload) saved.append(dest) - logger.info("Email attachment saved: {}", dest) + logger.info("Attachment saved: {}", dest) except Exception as exc: - logger.warning("Failed to save email attachment {}: {}", dest, exc) + logger.warning("Failed to save attachment {}: {}", dest, exc) return saved diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index f617b93db..91022b9af 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -15,7 +15,6 @@ from typing import Any, Literal from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1 from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN -from loguru import logger from pydantic import Field from nanobot.bus.events import OutboundMessage @@ -23,6 +22,7 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_media_dir from nanobot.config.schema import Base +from nanobot.utils.logging_bridge import redirect_lib_logging FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None @@ -320,15 +320,17 @@ class FeishuChannel(BaseChannel): async def start(self) -> None: """Start the Feishu bot with WebSocket long connection.""" if not FEISHU_AVAILABLE: - logger.error("Feishu SDK not installed. Run: pip install lark-oapi") + self.logger.error("SDK not installed. Run: pip install lark-oapi") return if not self.config.app_id or not self.config.app_secret: - logger.error("Feishu app_id and app_secret not configured") + self.logger.error("app_id and app_secret not configured") return import lark_oapi as lark + redirect_lib_logging("Lark") + self._running = True self._loop = asyncio.get_running_loop() @@ -390,7 +392,7 @@ class FeishuChannel(BaseChannel): try: self._ws_client.start() except Exception as e: - logger.warning("Feishu WebSocket error: {}", e) + self.logger.warning("WebSocket error: {}", e) if self._running: time.sleep(5) finally: @@ -404,12 +406,12 @@ class FeishuChannel(BaseChannel): None, self._fetch_bot_open_id ) if self._bot_open_id: - logger.info("Feishu bot open_id: {}", self._bot_open_id) + self.logger.info("bot open_id: {}", self._bot_open_id) else: - logger.warning("Could not fetch bot open_id; @mention matching may be inaccurate") + self.logger.warning("Could not fetch bot open_id; @mention matching may be inaccurate") - logger.info("Feishu bot started with WebSocket long connection") - logger.info("No public IP required - using WebSocket to receive events") + self.logger.info("bot started with WebSocket long connection") + self.logger.info("No public IP required - using WebSocket to receive events") # Keep running until stopped while self._running: @@ -424,7 +426,7 @@ class FeishuChannel(BaseChannel): Reference: https://github.com/larksuite/oapi-sdk-python/blob/v2_main/lark_oapi/ws/client.py#L86 """ self._running = False - logger.info("Feishu bot stopped") + self.logger.info("bot stopped") def _fetch_bot_open_id(self) -> str | None: """Fetch the bot's own open_id via GET /open-apis/bot/v3/info.""" @@ -445,10 +447,10 @@ class FeishuChannel(BaseChannel): data = json.loads(response.raw.content) bot = (data.get("data") or data).get("bot") or data.get("bot") or {} return bot.get("open_id") - logger.warning("Failed to get bot info: code={}, msg={}", response.code, response.msg) + self.logger.warning("Failed to get bot info: code={}, msg={}", response.code, response.msg) return None except Exception as e: - logger.warning("Error fetching bot info: {}", e) + self.logger.warning("Error fetching bot info: {}", e) return None @staticmethod @@ -539,15 +541,15 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.message_reaction.create(request) if not response.success(): - logger.warning( + self.logger.warning( "Failed to add reaction: code={}, msg={}", response.code, response.msg ) return None else: - logger.debug("Added {} reaction to message {}", emoji_type, message_id) + self.logger.debug("Added {} reaction to message {}", emoji_type, message_id) return response.data.reaction_id if response.data else None except Exception as e: - logger.warning("Error adding reaction: {}", e) + self.logger.warning("Error adding reaction: {}", e) return None async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> str | None: @@ -579,13 +581,13 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.message_reaction.delete(request) if response.success(): - logger.debug("Removed reaction {} from message {}", reaction_id, message_id) + self.logger.debug("Removed reaction {} from message {}", reaction_id, message_id) else: - logger.debug( + self.logger.debug( "Failed to remove reaction: code={}, msg={}", response.code, response.msg ) except Exception as e: - logger.debug("Error removing reaction: {}", e) + self.logger.debug("Error removing reaction: {}", e) async def _remove_reaction(self, message_id: str, reaction_id: str) -> None: """ @@ -607,7 +609,7 @@ class FeishuChannel(BaseChannel): try: task.result() except Exception as exc: - logger.warning("Background task failed: {}", exc) + self.logger.warning("Background task failed: {}", exc) def _on_reaction_added(self, message_id: str, task: asyncio.Task) -> None: """Callback: store reaction_id after background add-reaction completes.""" @@ -917,15 +919,15 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.image.create(request) if response.success(): image_key = response.data.image_key - logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key) + self.logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key) return image_key else: - logger.error( + self.logger.error( "Failed to upload image: code={}, msg={}", response.code, response.msg ) return None - except Exception as e: - logger.error("Error uploading image {}: {}", file_path, e) + except Exception: + self.logger.exception("Error uploading image {}", file_path) return None def _upload_file_sync(self, file_path: str) -> str | None: @@ -951,15 +953,15 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.file.create(request) if response.success(): file_key = response.data.file_key - logger.debug("Uploaded file {}: {}", file_name, file_key) + self.logger.debug("Uploaded file {}: {}", file_name, file_key) return file_key else: - logger.error( + self.logger.error( "Failed to upload file: code={}, msg={}", response.code, response.msg ) return None - except Exception as e: - logger.error("Error uploading file {}: {}", file_path, e) + except Exception: + self.logger.exception("Error uploading file {}", file_path) return None def _download_image_sync( @@ -984,12 +986,12 @@ class FeishuChannel(BaseChannel): file_data = file_data.read() return file_data, response.file_name else: - logger.error( + self.logger.error( "Failed to download image: code={}, msg={}", response.code, response.msg ) return None, None - except Exception as e: - logger.error("Error downloading image {}: {}", image_key, e) + except Exception: + self.logger.exception("Error downloading image {}", image_key) return None, None def _download_file_sync( @@ -1018,7 +1020,7 @@ class FeishuChannel(BaseChannel): file_data = file_data.read() return file_data, response.file_name else: - logger.error( + self.logger.error( "Failed to download {}: code={}, msg={}", resource_type, response.code, @@ -1026,7 +1028,7 @@ class FeishuChannel(BaseChannel): ) return None, None except Exception: - logger.exception("Error downloading {} {}", resource_type, file_key) + self.logger.exception("Error downloading {} {}", resource_type, file_key) return None, None async def _download_and_save_media( @@ -1055,10 +1057,10 @@ class FeishuChannel(BaseChannel): elif msg_type in ("audio", "file", "media"): file_key = content_json.get("file_key") if not file_key: - logger.warning("Feishu {} message missing file_key: {}", msg_type, content_json) + self.logger.warning("{} message missing file_key: {}", msg_type, content_json) return None, f"[{msg_type}: missing file_key]" if not message_id: - logger.warning("Feishu {} message missing message_id", msg_type) + self.logger.warning("{} message missing message_id", msg_type) return None, f"[{msg_type}: missing message_id]" data, filename = await loop.run_in_executor( @@ -1066,7 +1068,7 @@ class FeishuChannel(BaseChannel): ) if not data: - logger.warning("Feishu {} download failed: file_key={}", msg_type, file_key) + self.logger.warning("{} download failed: file_key={}", msg_type, file_key) return None, f"[{msg_type}: download failed]" if not filename: @@ -1081,8 +1083,9 @@ class FeishuChannel(BaseChannel): if data and filename: file_path = media_dir / filename file_path.write_bytes(data) - logger.debug("Downloaded {} to {}", msg_type, file_path) - return str(file_path), f"[{msg_type}: {filename}]" + path_str = str(file_path) + self.logger.debug("Downloaded {} to {}", msg_type, path_str) + return path_str, f"[{msg_type}: {path_str}]" return None, f"[{msg_type}: download failed]" @@ -1099,8 +1102,8 @@ class FeishuChannel(BaseChannel): request = GetMessageRequest.builder().message_id(message_id).build() response = self._client.im.v1.message.get(request) if not response.success(): - logger.debug( - "Feishu: could not fetch parent message {}: code={}, msg={}", + self.logger.debug( + "could not fetch parent message {}: code={}, msg={}", message_id, response.code, response.msg, @@ -1132,7 +1135,7 @@ class FeishuChannel(BaseChannel): text = text[: self._REPLY_CONTEXT_MAX_LEN] + "..." return f"[Reply to: {text}]" except Exception as e: - logger.debug("Feishu: error fetching parent message {}: {}", message_id, e) + self.logger.debug("error fetching parent message {}: {}", message_id, e) return None def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str, *, reply_in_thread: bool = False) -> bool: @@ -1156,18 +1159,18 @@ class FeishuChannel(BaseChannel): ) response = self._client.im.v1.message.reply(request) if not response.success(): - logger.error( - "Failed to reply to Feishu message {}: code={}, msg={}, log_id={}", + self.logger.error( + "Failed to reply to message {}: code={}, msg={}, log_id={}", parent_message_id, response.code, response.msg, response.get_log_id(), ) return False - logger.debug("Feishu reply sent to message {}", parent_message_id) + self.logger.debug("reply sent to message {}", parent_message_id) return True - except Exception as e: - logger.error("Error replying to Feishu message {}: {}", parent_message_id, e) + except Exception: + self.logger.exception("Error replying to message {}", parent_message_id) return False def _should_use_reply_in_thread(self, metadata: dict[str, Any]) -> bool: @@ -1206,8 +1209,8 @@ class FeishuChannel(BaseChannel): ) response = self._client.im.v1.message.create(request) if not response.success(): - logger.error( - "Failed to send Feishu {} message: code={}, msg={}, log_id={}", + self.logger.error( + "Failed to send {} message: code={}, msg={}, log_id={}", msg_type, response.code, response.msg, @@ -1215,10 +1218,10 @@ class FeishuChannel(BaseChannel): ) return None msg_id = getattr(response.data, "message_id", None) - logger.debug("Feishu {} message sent to {}: {}", msg_type, receive_id, msg_id) + self.logger.debug("{} message sent to {}: {}", msg_type, receive_id, msg_id) return msg_id - except Exception as e: - logger.error("Error sending Feishu {} message: {}", msg_type, e) + except Exception: + self.logger.exception("Error sending {} message", msg_type) return None def _create_streaming_card_sync( @@ -1258,7 +1261,7 @@ class FeishuChannel(BaseChannel): ) response = self._client.cardkit.v1.card.create(request) if not response.success(): - logger.warning( + self.logger.warning( "Failed to create streaming card: code={}, msg={}", response.code, response.msg ) return None @@ -1278,12 +1281,12 @@ class FeishuChannel(BaseChannel): ) is not None if sent: return card_id - logger.warning( + self.logger.warning( "Created streaming card {} but failed to send it to {}", card_id, chat_id ) return None except Exception as e: - logger.warning("Error creating streaming card: {}", e) + self.logger.warning("Error creating streaming card: {}", e) return None def _stream_update_text_sync(self, card_id: str, content: str, sequence: int) -> bool: @@ -1308,7 +1311,7 @@ class FeishuChannel(BaseChannel): ) response = self._client.cardkit.v1.card_element.content(request) if not response.success(): - logger.warning( + self.logger.warning( "Failed to stream-update card {}: code={}, msg={}", card_id, response.code, @@ -1317,7 +1320,7 @@ class FeishuChannel(BaseChannel): return False return True except Exception as e: - logger.warning("Error stream-updating card {}: {}", card_id, e) + self.logger.warning("Error stream-updating card {}: {}", card_id, e) return False def _close_streaming_mode_sync(self, card_id: str, sequence: int) -> bool: @@ -1345,7 +1348,7 @@ class FeishuChannel(BaseChannel): ) response = self._client.cardkit.v1.card.settings(request) if not response.success(): - logger.warning( + self.logger.warning( "Failed to close streaming on card {}: code={}, msg={}", card_id, response.code, @@ -1354,7 +1357,7 @@ class FeishuChannel(BaseChannel): return False return True except Exception as e: - logger.warning("Error closing streaming on card {}: {}", card_id, e) + self.logger.warning("Error closing streaming on card {}: {}", card_id, e) return False async def send_delta( @@ -1415,7 +1418,7 @@ class FeishuChannel(BaseChannel): buf.sequence, ) return - logger.warning( + self.logger.warning( "Streaming card {} final update failed, falling back to regular card", buf.card_id, ) @@ -1483,7 +1486,7 @@ class FeishuChannel(BaseChannel): async def send(self, msg: OutboundMessage) -> None: """Send a message through Feishu, including media (images/files) if present.""" if not self._client: - logger.warning("Feishu client not initialized") + self.logger.warning("client not initialized") return try: @@ -1565,7 +1568,7 @@ class FeishuChannel(BaseChannel): for file_path in msg.media: if not os.path.isfile(file_path): - logger.warning("Media file not found: {}", file_path) + self.logger.warning("Media file not found: {}", file_path) continue ext = os.path.splitext(file_path)[1].lower() if ext in self._IMAGE_EXTS: @@ -1621,8 +1624,8 @@ class FeishuChannel(BaseChannel): json.dumps(card, ensure_ascii=False), ) - except Exception as e: - logger.error("Error sending Feishu message: {}", e) + except Exception: + self.logger.exception("Error sending message") raise def _on_message_sync(self, data: Any) -> None: @@ -1640,18 +1643,10 @@ class FeishuChannel(BaseChannel): message = event.message sender = event.sender - logger.debug("Feishu raw message: {}", message.content) - logger.debug("Feishu mentions: {}", getattr(message, "mentions", None)) + self.logger.debug("raw message: {}", message.content) + self.logger.debug("mentions: {}", getattr(message, "mentions", None)) - # Deduplication check message_id = message.message_id - if message_id in self._processed_message_ids: - return - self._processed_message_ids[message_id] = None - - # Trim cache - while len(self._processed_message_ids) > 1000: - self._processed_message_ids.popitem(last=False) # Skip bot messages if sender.sender_type == "bot": @@ -1662,10 +1657,22 @@ class FeishuChannel(BaseChannel): chat_type = message.chat_type msg_type = message.message_type - if chat_type == "group" and not self._is_group_message_for_bot(message): - logger.debug("Feishu: skipping group message (not mentioned)") + if not self.is_allowed(sender_id): return + if chat_type == "group" and not self._is_group_message_for_bot(message): + self.logger.debug("skipping group message (not mentioned)") + return + + # Deduplication check + if message_id in self._processed_message_ids: + return + self._processed_message_ids[message_id] = None + + # Trim cache + while len(self._processed_message_ids) > 1000: + self._processed_message_ids.popitem(last=False) + # Add reaction (non-blocking — tracked background task) task = asyncio.create_task( self._add_reaction(message_id, self.config.react_emoji) @@ -1779,8 +1786,8 @@ class FeishuChannel(BaseChannel): session_key=session_key, ) - except Exception as e: - logger.error("Error processing Feishu message: {}", e) + except Exception: + self.logger.exception("Error processing message") def _on_reaction_created(self, data: Any) -> None: """Ignore reaction events so they do not generate SDK noise.""" @@ -1796,7 +1803,7 @@ class FeishuChannel(BaseChannel): def _on_bot_p2p_chat_entered(self, data: Any) -> None: """Ignore p2p-enter events when a user opens a bot chat.""" - logger.debug("Bot entered p2p chat (user opened chat window)") + self.logger.debug("Bot entered p2p chat (user opened chat window)") pass @staticmethod diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 95806008a..783aac966 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -174,8 +174,8 @@ class ChannelManager: """Start a channel and log any exceptions.""" try: await channel.start() - except Exception as e: - logger.error("Failed to start channel {}: {}", name, e) + except Exception: + logger.exception("Failed to start channel {}", name) async def start_all(self) -> None: """Start all channels and the outbound dispatcher.""" @@ -230,8 +230,8 @@ class ChannelManager: try: await channel.stop() logger.info("Stopped {} channel", name) - except Exception as e: - logger.error("Error stopping {}: {}", name, e) + except Exception: + logger.exception("Error stopping {}", name) @staticmethod def _fingerprint_content(content: str) -> str: @@ -392,9 +392,9 @@ class ChannelManager: raise # Propagate cancellation for graceful shutdown except Exception as e: if attempt == max_attempts - 1: - logger.error( - "Failed to send to {} after {} attempts: {} - {}", - msg.channel, max_attempts, type(e).__name__, e + logger.exception( + "Failed to send to {} after {} attempts", + msg.channel, max_attempts ) return delay = _SEND_RETRY_DELAYS[min(attempt, len(_SEND_RETRY_DELAYS) - 1)] diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 0d1989b03..6919be874 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -2,7 +2,6 @@ import asyncio import json -import logging import mimetypes import time from contextlib import suppress @@ -10,7 +9,6 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Literal, TypeAlias -from loguru import logger from pydantic import Field try: @@ -47,6 +45,7 @@ from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_data_dir, get_media_dir from nanobot.config.schema import Base from nanobot.utils.helpers import safe_filename +from nanobot.utils.logging_bridge import redirect_lib_logging TYPING_NOTICE_TIMEOUT_MS = 30_000 # Must stay below TYPING_NOTICE_TIMEOUT_MS so the indicator doesn't expire mid-processing. @@ -178,28 +177,6 @@ def _build_matrix_text_content( return content -class _NioLoguruHandler(logging.Handler): - """Route matrix-nio stdlib logs into Loguru.""" - - def emit(self, record: logging.LogRecord) -> None: - try: - level = logger.level(record.levelname).name - except ValueError: - level = record.levelno - frame, depth = logging.currentframe(), 2 - while frame and frame.f_code.co_filename == logging.__file__: - frame, depth = frame.f_back, depth + 1 - logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) - - -def _configure_nio_logging_bridge() -> None: - """Bridge matrix-nio logs to Loguru (idempotent).""" - nio_logger = logging.getLogger("nio") - if not any(isinstance(h, _NioLoguruHandler) for h in nio_logger.handlers): - nio_logger.handlers = [_NioLoguruHandler()] - nio_logger.propagate = False - - class MatrixConfig(Base): """Matrix (Element) channel configuration.""" @@ -259,7 +236,7 @@ class MatrixChannel(BaseChannel): """Start Matrix client and begin sync loop.""" self._running = True self._started_at_ms = int(time.time() * 1000) - _configure_nio_logging_bridge() + redirect_lib_logging("nio", level="WARNING") self.store_path = get_data_dir() / "matrix-store" self.store_path.mkdir(parents=True, exist_ok=True) @@ -283,15 +260,15 @@ class MatrixChannel(BaseChannel): self._register_response_callbacks() if not self.config.e2ee_enabled: - logger.warning("Matrix E2EE disabled; encrypted rooms may be undecryptable.") + self.logger.warning("E2EE disabled; encrypted rooms may be undecryptable.") if self.config.password: if self.config.access_token or self.config.device_id: - logger.warning("Password-based Matrix login active; access_token and device_id fields will be ignored.") + self.logger.warning("Password-based login active; access_token and device_id fields will be ignored.") create_new_session = True if self.session_path.exists(): - logger.info("Found session.json at {}; attempting to use existing session...", self.session_path) + self.logger.info("Found session.json at {}; attempting to use existing session...", self.session_path) try: with open(self.session_path, "r", encoding="utf-8") as f: session = json.load(f) @@ -299,20 +276,20 @@ class MatrixChannel(BaseChannel): self.client.access_token = session["access_token"] self.client.device_id = session["device_id"] self.client.load_store() - logger.info("Successfully loaded from existing session") + self.logger.info("Successfully loaded from existing session") create_new_session = False except Exception as e: - logger.warning("Failed to load from existing session: {}", e) - logger.info("Falling back to password login...") + self.logger.warning("Failed to load from existing session: {}", e) + self.logger.info("Falling back to password login...") if create_new_session: - logger.info("Using password login...") + self.logger.info("Using password login...") resp = await self.client.login(self.config.password) if isinstance(resp, LoginResponse): - logger.info("Logged in using a password; saving details to disk") + self.logger.info("Logged in using a password; saving details to disk") self._write_session_to_disk(resp) else: - logger.error("Failed to log in: {}", resp) + self.logger.error("Failed to log in: {}", resp) return elif self.config.access_token and self.config.device_id: @@ -321,12 +298,12 @@ class MatrixChannel(BaseChannel): self.client.access_token = self.config.access_token self.client.device_id = self.config.device_id self.client.load_store() - logger.info("Successfully loaded from existing session") + self.logger.info("Successfully loaded from existing session") except Exception as e: - logger.warning("Failed to load from existing session: {}", e) + self.logger.warning("Failed to load from existing session: {}", e) else: - logger.warning("Unable to load a Matrix session due to missing password, access_token, or device_id; encryption may not work") + self.logger.warning("Unable to load a session due to missing password, access_token, or device_id; encryption may not work") return self._sync_task = asyncio.create_task(self._sync_loop()) @@ -358,9 +335,9 @@ class MatrixChannel(BaseChannel): try: with open(self.session_path, "w", encoding="utf-8") as f: json.dump(session, f, indent=2) - logger.info("Session saved to {}", self.session_path) + self.logger.info("Session saved to {}", self.session_path) except Exception as e: - logger.warning("Failed to save session: {}", e) + self.logger.warning("Failed to save session: {}", e) def _is_workspace_path_allowed(self, path: Path) -> bool: """Check path is inside workspace (when restriction enabled).""" @@ -598,14 +575,14 @@ class MatrixChannel(BaseChannel): def _log_response_error(self, label: str, response: Any) -> None: """Log Matrix response errors — auth errors at ERROR level, rest at WARNING.""" is_fatal = self._is_fatal_auth_response(response) - (logger.error if is_fatal else logger.warning)("Matrix {} failed: {}", label, response) + (self.logger.error if is_fatal else self.logger.warning)("{} failed: {}", label, response) async def _on_sync_error(self, response: SyncError) -> None: self._log_response_error("sync", response) if self._is_fatal_auth_response(response): # Auth errors won't recover by retry; stop the sync loop instead of # spamming the homeserver every 2s (#1851). - logger.error("Matrix authentication failed irrecoverably; stopping sync loop") + self.logger.error("Authentication failed irrecoverably; stopping sync loop") self._running = False if self.client: with suppress(Exception): @@ -625,7 +602,7 @@ class MatrixChannel(BaseChannel): response = await self.client.room_typing(room_id=room_id, typing_state=typing, timeout=TYPING_NOTICE_TIMEOUT_MS) if isinstance(response, RoomTypingError): - logger.debug("Matrix typing failed for {}: {}", room_id, response) + self.logger.debug("typing failed for {}: {}", room_id, response) async def _start_typing_keepalive(self, room_id: str) -> None: """Start periodic typing refresh (spec-recommended keepalive).""" @@ -796,7 +773,7 @@ class MatrixChannel(BaseChannel): return None response = await self.client.download(mxc=mxc_url) if isinstance(response, DownloadError): - logger.warning("Matrix download failed for {}: {}", mxc_url, response) + self.logger.warning("download failed for {}: {}", mxc_url, response) return None body = getattr(response, "body", None) if isinstance(body, (bytes, bytearray)): @@ -821,7 +798,7 @@ class MatrixChannel(BaseChannel): try: return decrypt_attachment(ciphertext, key, sha256, iv) except (EncryptionError, ValueError, TypeError): - logger.warning("Matrix decrypt failed for event {}", getattr(event, "event_id", "")) + self.logger.warning("decrypt failed for event {}", getattr(event, "event_id", "")) return None async def _fetch_media_attachment( diff --git a/nanobot/channels/mochat.py b/nanobot/channels/mochat.py index 110b454cc..dfe225640 100644 --- a/nanobot/channels/mochat.py +++ b/nanobot/channels/mochat.py @@ -11,7 +11,6 @@ from datetime import datetime from typing import Any import httpx -from loguru import logger from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus @@ -303,7 +302,7 @@ class MochatChannel(BaseChannel): async def start(self) -> None: """Start Mochat channel workers and websocket connection.""" if not self.config.claw_token: - logger.error("Mochat claw_token not configured") + self.logger.error("claw_token not configured") return self._running = True @@ -348,7 +347,7 @@ class MochatChannel(BaseChannel): async def send(self, msg: OutboundMessage) -> None: """Send outbound message to session or panel.""" if not self.config.claw_token: - logger.warning("Mochat claw_token missing, skip send") + self.logger.warning("claw_token missing, skip send") return parts = ([msg.content.strip()] if msg.content and msg.content.strip() else []) @@ -360,7 +359,7 @@ class MochatChannel(BaseChannel): target = resolve_mochat_target(msg.chat_id) if not target.id: - logger.warning("Mochat outbound target is empty") + self.logger.warning("outbound target is empty") return is_panel = (target.is_panel or target.id in self._panel_set) and not target.id.startswith("session_") @@ -371,8 +370,8 @@ class MochatChannel(BaseChannel): else: await self._api_send("/api/claw/sessions/send", "sessionId", target.id, content, msg.reply_to) - except Exception as e: - logger.error("Failed to send Mochat message: {}", e) + except Exception: + self.logger.exception("Failed to send message") raise # ---- config / init helpers --------------------------------------------- @@ -395,7 +394,7 @@ class MochatChannel(BaseChannel): async def _start_socket_client(self) -> bool: if not SOCKETIO_AVAILABLE: - logger.warning("python-socketio not installed, Mochat using polling fallback") + self.logger.warning("python-socketio not installed, using polling fallback") return False serializer = "default" @@ -403,7 +402,7 @@ class MochatChannel(BaseChannel): if MSGPACK_AVAILABLE: serializer = "msgpack" else: - logger.warning("msgpack not installed but socket_disable_msgpack=false; using JSON") + self.logger.warning("msgpack not installed but socket_disable_msgpack=false; using JSON") client = socketio.AsyncClient( reconnection=True, @@ -416,7 +415,7 @@ class MochatChannel(BaseChannel): @client.event async def connect() -> None: self._ws_connected, self._ws_ready = True, False - logger.info("Mochat websocket connected") + self.logger.info("websocket connected") subscribed = await self._subscribe_all() self._ws_ready = subscribed await (self._stop_fallback_workers() if subscribed else self._ensure_fallback_workers()) @@ -426,12 +425,12 @@ class MochatChannel(BaseChannel): if not self._running: return self._ws_connected = self._ws_ready = False - logger.warning("Mochat websocket disconnected") + self.logger.warning("websocket disconnected") await self._ensure_fallback_workers() @client.event async def connect_error(data: Any) -> None: - logger.error("Mochat websocket connect error: {}", data) + self.logger.error("websocket connect error: {}", data) @client.on("claw.session.events") async def on_session_events(payload: dict[str, Any]) -> None: @@ -457,8 +456,8 @@ class MochatChannel(BaseChannel): wait_timeout=max(1.0, self.config.socket_connect_timeout_ms / 1000.0), ) return True - except Exception as e: - logger.error("Failed to connect Mochat websocket: {}", e) + except Exception: + self.logger.exception("Failed to connect websocket") with suppress(Exception): await client.disconnect() self._socket = None @@ -493,7 +492,7 @@ class MochatChannel(BaseChannel): "limit": self.config.watch_limit, }) if not ack.get("result"): - logger.error("Mochat subscribeSessions failed: {}", ack.get('message', 'unknown error')) + self.logger.error("subscribeSessions failed: {}", ack.get('message', 'unknown error')) return False data = ack.get("data") @@ -515,7 +514,7 @@ class MochatChannel(BaseChannel): return True ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids}) if not ack.get("result"): - logger.error("Mochat subscribePanels failed: {}", ack.get('message', 'unknown error')) + self.logger.error("subscribePanels failed: {}", ack.get('message', 'unknown error')) return False return True @@ -537,7 +536,7 @@ class MochatChannel(BaseChannel): try: await self._refresh_targets(subscribe_new=self._ws_ready) except Exception as e: - logger.warning("Mochat refresh failed: {}", e) + self.logger.warning("refresh failed: {}", e) if self._fallback_mode: await self._ensure_fallback_workers() @@ -551,7 +550,7 @@ class MochatChannel(BaseChannel): try: response = await self._post_json("/api/claw/sessions/list", {}) except Exception as e: - logger.warning("Mochat listSessions failed: {}", e) + self.logger.warning("listSessions failed: {}", e) return sessions = response.get("sessions") @@ -585,7 +584,7 @@ class MochatChannel(BaseChannel): try: response = await self._post_json("/api/claw/groups/get", {}) except Exception as e: - logger.warning("Mochat getWorkspaceGroup failed: {}", e) + self.logger.warning("getWorkspaceGroup failed: {}", e) return raw_panels = response.get("panels") @@ -647,7 +646,7 @@ class MochatChannel(BaseChannel): except asyncio.CancelledError: break except Exception as e: - logger.warning("Mochat watch fallback error ({}): {}", session_id, e) + self.logger.warning("watch fallback error ({}): {}", session_id, e) await asyncio.sleep(max(0.1, self.config.retry_delay_ms / 1000.0)) async def _panel_poll_worker(self, panel_id: str) -> None: @@ -674,7 +673,7 @@ class MochatChannel(BaseChannel): except asyncio.CancelledError: break except Exception as e: - logger.warning("Mochat panel polling error ({}): {}", panel_id, e) + self.logger.warning("panel polling error ({}): {}", panel_id, e) await asyncio.sleep(sleep_s) # ---- inbound event processing ------------------------------------------ @@ -885,7 +884,7 @@ class MochatChannel(BaseChannel): try: data = json.loads(self._cursor_path.read_text("utf-8")) except Exception as e: - logger.warning("Failed to read Mochat cursor file: {}", e) + self.logger.warning("Failed to read cursor file: {}", e) return cursors = data.get("cursors") if isinstance(data, dict) else None if isinstance(cursors, dict): @@ -901,7 +900,7 @@ class MochatChannel(BaseChannel): "cursors": self._session_cursor, }, ensure_ascii=False, indent=2) + "\n", "utf-8") except Exception as e: - logger.warning("Failed to save Mochat cursor file: {}", e) + self.logger.warning("Failed to save cursor file: {}", e) # ---- HTTP helpers ------------------------------------------------------ diff --git a/nanobot/channels/msteams.py b/nanobot/channels/msteams.py index f30b1af61..cdb0ae904 100644 --- a/nanobot/channels/msteams.py +++ b/nanobot/channels/msteams.py @@ -32,7 +32,6 @@ except ImportError: # pragma: no cover fcntl = None import httpx -from loguru import logger from pydantic import Field from nanobot.bus.events import OutboundMessage @@ -134,16 +133,16 @@ class MSTeamsChannel(BaseChannel): async def start(self) -> None: """Start the Teams webhook listener.""" if not MSTEAMS_AVAILABLE: - logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]") + self.logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]") return if not self.config.app_id or not self.config.app_password: - logger.error("MSTeams app_id/app_password not configured") + self.logger.error("app_id/app_password not configured") return if not self.config.validate_inbound_auth: - logger.warning( - "MSTeams inbound auth validation was explicitly DISABLED in config. " + self.logger.warning( + "Inbound auth validation was explicitly DISABLED in config. " "Anyone who knows the webhook URL can send messages as any user. " "Only disable this for local development or controlled testing." ) @@ -166,7 +165,7 @@ class MSTeamsChannel(BaseChannel): raw = self.rfile.read(length) if length > 0 else b"{}" payload = json.loads(raw.decode("utf-8")) except Exception as e: - logger.warning("MSTeams invalid request body: {}", e) + channel.logger.warning("Invalid request body: {}", e) self.send_response(400) self.end_headers() return @@ -180,7 +179,7 @@ class MSTeamsChannel(BaseChannel): ) fut.result(timeout=15) except Exception as e: - logger.warning("MSTeams inbound auth validation failed: {}", e) + channel.logger.warning("Inbound auth validation failed: {}", e) self.send_response(401) self.send_header("Content-Type", "application/json") self.end_headers() @@ -193,7 +192,7 @@ class MSTeamsChannel(BaseChannel): ) fut.result(timeout=15) except Exception as e: - logger.warning("MSTeams activity handling failed: {}", e) + channel.logger.warning("Activity handling failed: {}", e) self.send_response(200) self.send_header("Content-Type", "application/json") @@ -211,8 +210,8 @@ class MSTeamsChannel(BaseChannel): ) self._server_thread.start() - logger.info( - "MSTeams webhook listening on http://{}:{}{}", + self.logger.info( + "Webhook listening on http://{}:{}{}", self.config.host, self.config.port, self.config.path, @@ -261,10 +260,10 @@ class MSTeamsChannel(BaseChannel): try: resp = await self._http.post(base_url, headers=headers, json=payload) resp.raise_for_status() - logger.info("MSTeams message sent to {}", ref.conversation_id) + self.logger.info("Message sent to {}", ref.conversation_id) self._touch_conversation_ref(str(msg.chat_id), persist=True) - except Exception as e: - logger.error("MSTeams send failed: {}", e) + except Exception: + self.logger.exception("Send failed") raise async def _handle_activity(self, activity: dict[str, Any]) -> None: @@ -291,18 +290,18 @@ class MSTeamsChannel(BaseChannel): # DM-only MVP: ignore group/channel traffic for now if conversation_type and conversation_type not in ("personal", ""): - logger.debug("MSTeams ignoring non-DM conversation {}", conversation_type) + self.logger.debug("Ignoring non-DM conversation {}", conversation_type) return text = self._sanitize_inbound_text(activity) if not text: text = self.config.mention_only_response.strip() if not text: - logger.debug("MSTeams ignoring empty message after Teams text sanitization") + self.logger.debug("Ignoring empty message after Teams text sanitization") return if not self.is_allowed(sender_id): - logger.warning( + self.logger.warning( "Access denied for sender {} on channel {}. " "Add them to allowFrom list in config to grant access.", sender_id, self.name, @@ -554,7 +553,7 @@ class MSTeamsChannel(BaseChannel): if isinstance(loaded, dict): main_data = loaded except Exception as e: - logger.warning("Failed to load MSTeams conversation refs: {}", e) + self.logger.warning("Failed to load conversation refs: {}", e) if meta_exists: try: @@ -562,7 +561,7 @@ class MSTeamsChannel(BaseChannel): if isinstance(loaded_meta, dict): meta_data = loaded_meta except Exception as e: - logger.warning("Failed to load MSTeams conversation refs metadata: {}", e) + self.logger.warning("Failed to load conversation refs metadata: {}", e) return main_data, meta_data, meta_exists @@ -660,8 +659,8 @@ class MSTeamsChannel(BaseChannel): for key in keys_to_drop: self._conversation_refs.pop(key, None) - logger.info( - "MSTeams pruned {} stale/unsupported conversation refs (ttl={} days)", + self.logger.info( + "Pruned {} stale/unsupported conversation refs (ttl={} days)", len(keys_to_drop), ttl_days, ) @@ -742,7 +741,7 @@ class MSTeamsChannel(BaseChannel): self._write_json_atomically(self._refs_path, refs_data) self._write_json_atomically(self._refs_meta_path, refs_meta) except Exception as e: - logger.warning("Failed to save MSTeams conversation refs: {}", e) + self.logger.warning("Failed to save conversation refs: {}", e) def _save_refs(self, *, prune: bool = True) -> None: """Persist conversation references.""" diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 00338229a..4ef63238c 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -38,7 +38,7 @@ from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import Base -from nanobot.security.network import validate_url_target +from nanobot.utils.logging_bridge import redirect_lib_logging try: from nanobot.config.paths import get_media_dir @@ -187,24 +187,25 @@ class QQChannel(BaseChannel): root = Path.home() / ".nanobot" / "media" / "qq" root.mkdir(parents=True, exist_ok=True) - logger.info("QQ media directory: {}", str(root)) + self.logger.info("media directory: {}", str(root)) return root async def start(self) -> None: """Start the QQ bot with auto-reconnect loop.""" + redirect_lib_logging("botpy", level="WARNING") if not QQ_AVAILABLE: - logger.error("QQ SDK not installed. Run: pip install qq-botpy") + self.logger.error("SDK not installed. Run: pip install qq-botpy") return if not self.config.app_id or not self.config.secret: - logger.error("QQ app_id and secret not configured") + self.logger.error("app_id and secret not configured") return self._running = True self._http = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=120)) self._client = _make_bot_class(self)() - logger.info("QQ bot started (C2C & Group supported)") + self.logger.info("bot started (C2C & Group supported)") await self._run_bot() async def _run_bot(self) -> None: @@ -213,9 +214,9 @@ class QQChannel(BaseChannel): try: await self._client.start(appid=self.config.app_id, secret=self.config.secret) except Exception as e: - logger.warning("QQ bot error: {}", e) + self.logger.warning("bot error: {}", e) if self._running: - logger.info("Reconnecting QQ bot in 5 seconds...") + self.logger.info("Reconnecting bot in 5 seconds...") await asyncio.sleep(5) async def stop(self) -> None: @@ -231,7 +232,7 @@ class QQChannel(BaseChannel): await self._http.close() self._http = None - logger.info("QQ bot stopped") + self.logger.info("bot stopped") # --------------------------- # Outbound (send) @@ -241,7 +242,7 @@ class QQChannel(BaseChannel): """Send attachments first, then text.""" try: if not self._client: - logger.warning("QQ client not initialized") + self.logger.warning("client not initialized") return msg_id = msg.metadata.get("message_id") @@ -281,7 +282,7 @@ class QQChannel(BaseChannel): # Network / transport errors — propagate so ChannelManager can retry raise except Exception: - logger.exception("Error sending QQ message to chat_id={}", msg.chat_id) + self.logger.exception("Error sending message to chat_id={}", msg.chat_id) async def _send_text_only( self, @@ -339,7 +340,7 @@ class QQChannel(BaseChannel): srv_send_msg=False, ) if not media_obj: - logger.error("QQ media upload failed: empty response") + self.logger.error("media upload failed: empty response") return False self._msg_seq += 1 @@ -360,15 +361,15 @@ class QQChannel(BaseChannel): media=media_obj, ) - logger.info("QQ media sent: {}", filename) + self.logger.info("media sent: {}", filename) return True except (aiohttp.ClientError, OSError) as e: # Network / transport errors — propagate for retry by caller - logger.warning("QQ send media network error filename={} err={}", filename, e) + self.logger.warning("send media network error filename={} err={}", filename, e) raise - except Exception as e: + except Exception: # API-level or other non-network errors — return False so send() can fallback - logger.error("QQ send media failed filename={} err={}", filename, e) + self.logger.exception("send media failed filename={}", filename) return False async def _read_media_bytes(self, media_ref: str) -> tuple[bytes | None, str | None]: @@ -389,19 +390,19 @@ class QQChannel(BaseChannel): local_path = Path(os.path.expanduser(media_ref)) if not local_path.is_file(): - logger.warning("QQ outbound media file not found: {}", str(local_path)) + self.logger.warning("outbound media file not found: {}", str(local_path)) return None, None data = await asyncio.to_thread(local_path.read_bytes) return data, local_path.name except Exception as e: - logger.warning("QQ outbound media read error ref={} err={}", media_ref, e) + self.logger.warning("outbound media read error ref={} err={}", media_ref, e) return None, None # Remote URL ok, err = validate_url_target(media_ref) if not ok: - logger.warning("QQ outbound media URL validation failed url={} err={}", media_ref, err) + self.logger.warning("outbound media URL validation failed url={} err={}", media_ref, err) return None, None if not self._http: @@ -409,8 +410,8 @@ class QQChannel(BaseChannel): try: async with self._http.get(media_ref, allow_redirects=True) as resp: if resp.status >= 400: - logger.warning( - "QQ outbound media download failed status={} url={}", + self.logger.warning( + "outbound media download failed status={} url={}", resp.status, media_ref, ) @@ -421,7 +422,7 @@ class QQChannel(BaseChannel): filename = os.path.basename(urlparse(media_ref).path) or "file.bin" return data, filename except Exception as e: - logger.warning("QQ outbound media download error url={} err={}", media_ref, e) + self.logger.warning("outbound media download error url={} err={}", media_ref, e) return None, None # https://github.com/tencent-connect/botpy/issues/198 @@ -474,24 +475,28 @@ class QQChannel(BaseChannel): async def _on_message(self, data: C2CMessage | GroupMessage, is_group: bool = False) -> None: """Parse inbound message, download attachments, and publish to the bus.""" try: - if data.id in self._processed_ids: - return - self._processed_ids.append(data.id) - if is_group: chat_id = data.group_openid user_id = data.author.member_openid - self._chat_type_cache[chat_id] = "group" + chat_type = "group" else: chat_id = str( getattr(data.author, "id", None) or getattr(data.author, "user_openid", "unknown") ) user_id = chat_id - self._chat_type_cache[chat_id] = "c2c" + chat_type = "c2c" content = (data.content or "").strip() + if not self.is_allowed(user_id): + return + + if data.id in self._processed_ids: + return + self._processed_ids.append(data.id) + self._chat_type_cache[chat_id] = chat_type + # the data used by tests don't contain attachments property # so we use getattr with a default of [] to avoid AttributeError in tests attachments = getattr(data, "attachments", None) or [] @@ -521,7 +526,7 @@ class QQChannel(BaseChannel): content=self.config.ack_message, ) except Exception: - logger.debug("QQ ack message failed for chat_id={}", chat_id) + self.logger.debug("ack message failed for chat_id={}", chat_id) await self._handle_message( sender_id=user_id, @@ -534,7 +539,7 @@ class QQChannel(BaseChannel): }, ) except Exception: - logger.exception("Error handling QQ inbound message id={}", getattr(data, "id", "?")) + self.logger.exception("Error handling inbound message id={}", getattr(data, "id", "?")) async def _handle_attachments( self, @@ -553,7 +558,7 @@ class QQChannel(BaseChannel): filename = getattr(att, "filename", None) or "" ctype = getattr(att, "content_type", None) or "" - logger.info("Downloading file from QQ: {}", filename or url) + self.logger.info("Downloading file: {}", filename or url) local_path = await self._download_to_media_dir_chunked(url, filename_hint=filename) att_meta.append( @@ -604,7 +609,7 @@ class QQChannel(BaseChannel): allow_redirects=True, ) as resp: if resp.status != 200: - logger.warning("QQ download failed: status={} url={}", resp.status, url) + self.logger.warning("download failed: status={} url={}", resp.status, url) return None ctype = (resp.headers.get("Content-Type") or "").lower() @@ -658,8 +663,8 @@ class QQChannel(BaseChannel): continue downloaded += len(chunk) if downloaded > max_bytes: - logger.warning( - "QQ download exceeded max_bytes={} url={} -> abort", + self.logger.warning( + "download exceeded max_bytes={} url={} -> abort", max_bytes, url, ) @@ -671,11 +676,11 @@ class QQChannel(BaseChannel): # Atomic rename await asyncio.to_thread(os.replace, tmp_path, target) tmp_path = None # mark as moved - logger.info("QQ file saved: {}", str(target)) + self.logger.info("file saved: {}", str(target)) return str(target) - except Exception as e: - logger.error("QQ download error: {}", e) + except Exception: + self.logger.exception("download error") return None finally: # Cleanup partial file diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 0bdeedc78..dc8899861 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -6,7 +6,6 @@ from pathlib import Path from typing import Any import httpx -from loguru import logger from pydantic import Field from slack_sdk.socket_mode.request import SocketModeRequest from slack_sdk.socket_mode.response import SocketModeResponse @@ -84,10 +83,10 @@ class SlackChannel(BaseChannel): async def start(self) -> None: """Start the Slack Socket Mode client.""" if not self.config.bot_token or not self.config.app_token: - logger.error("Slack bot/app token not configured") + self.logger.error("bot/app token not configured") return if self.config.mode != "socket": - logger.error("Unsupported Slack mode: {}", self.config.mode) + self.logger.error("Unsupported mode: {}", self.config.mode) return self._running = True @@ -104,11 +103,11 @@ class SlackChannel(BaseChannel): try: auth = await self._web_client.auth_test() self._bot_user_id = auth.get("user_id") - logger.info("Slack bot connected as {}", self._bot_user_id) + self.logger.info("bot connected as {}", self._bot_user_id) except Exception as e: - logger.warning("Slack auth_test failed: {}", e) + self.logger.warning("auth_test failed: {}", e) - logger.info("Starting Slack Socket Mode client...") + self.logger.info("Starting Socket Mode client...") await self._socket_client.connect() while self._running: @@ -121,13 +120,13 @@ class SlackChannel(BaseChannel): try: await self._socket_client.close() except Exception as e: - logger.warning("Slack socket close failed: {}", e) + self.logger.warning("socket close failed: {}", e) self._socket_client = None async def send(self, msg: OutboundMessage) -> None: """Send a message through Slack.""" if not self._web_client: - logger.warning("Slack client not running") + self.logger.warning("client not running") return try: target_chat_id = await self._resolve_target_chat_id(msg.chat_id) @@ -162,16 +161,16 @@ class SlackChannel(BaseChannel): file=media_path, thread_ts=thread_ts_param, ) - except Exception as e: - logger.error("Failed to upload file {}: {}", media_path, e) + except Exception: + self.logger.exception("Failed to upload file {}", media_path) # Update reaction emoji when the final (non-progress) response is sent if not (msg.metadata or {}).get("_progress"): event = slack_meta.get("event", {}) await self._update_react_emoji(origin_chat_id, event.get("ts")) - except Exception as e: - logger.error("Error sending Slack message: {}", e) + except Exception: + self.logger.exception("Error sending message") raise async def _resolve_target_chat_id(self, target: str) -> str: @@ -328,8 +327,8 @@ class SlackChannel(BaseChannel): return # Debug: log basic event shape - logger.debug( - "Slack event: type={} subtype={} user={} channel={} channel_type={} text={}", + self.logger.debug( + "event: type={} subtype={} user={} channel={} channel_type={} text={}", event_type, subtype, sender_id, @@ -371,7 +370,7 @@ class SlackChannel(BaseChannel): timestamp=event.get("ts"), ) except Exception as e: - logger.debug("Slack reactions_add failed: {}", e) + self.logger.debug("reactions_add failed: {}", e) # Thread-scoped session key whenever the user is in a real thread # (raw_thread_ts is set). DM threads get their own session, separate @@ -420,7 +419,7 @@ class SlackChannel(BaseChannel): session_key=session_key, ) except Exception: - logger.exception("Error handling Slack message from {}", sender_id) + self.logger.exception("Error handling message from {}", sender_id) async def _download_slack_file(self, file_info: dict[str, Any]) -> tuple[str | None, str]: """Download a Slack private file to the local media directory.""" @@ -453,7 +452,7 @@ class SlackChannel(BaseChannel): path.write_bytes(response.content) return str(path), marker except Exception as e: - logger.warning("Failed to download Slack file {}: {}", file_id, e) + self.logger.warning("Failed to download file {}: {}", file_id, e) return None, self._download_failure_marker(marker_type, name, "download failed") @staticmethod @@ -500,7 +499,7 @@ class SlackChannel(BaseChannel): session_key=session_key, ) except Exception: - logger.exception("Error handling Slack button click from {}", sender_id) + self.logger.exception("Error handling button click from {}", sender_id) async def _with_thread_context( self, @@ -537,7 +536,7 @@ class SlackChannel(BaseChannel): limit=max(1, self.config.thread_context_limit), ) except Exception as e: - logger.warning("Slack thread context unavailable for {}: {}", key, e) + self.logger.warning("thread context unavailable for {}: {}", key, e) return text lines = self._format_thread_context( @@ -597,7 +596,7 @@ class SlackChannel(BaseChannel): timestamp=ts, ) except Exception as e: - logger.debug("Slack reactions_remove failed: {}", e) + self.logger.debug("reactions_remove failed: {}", e) if self.config.done_emoji: try: await self._web_client.reactions_add( @@ -606,7 +605,7 @@ class SlackChannel(BaseChannel): timestamp=ts, ) except Exception as e: - logger.debug("Slack done reaction failed: {}", e) + self.logger.debug("done reaction failed: {}", e) def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool: if channel_type == "im": diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 793419917..5c97cddf9 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -11,7 +11,6 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Literal -from loguru import logger from pydantic import Field from telegram import ( BotCommand, @@ -320,7 +319,7 @@ class TelegramChannel(BaseChannel): async def start(self) -> None: """Start the Telegram bot with long polling.""" if not self.config.token: - logger.error("Telegram bot token not configured") + self.logger.error("bot token not configured") return self._running = True @@ -382,11 +381,11 @@ class TelegramChannel(BaseChannel): if self.config.inline_keyboards: self._app.add_handler(CallbackQueryHandler(self._on_callback_query)) allowed_updates = ["message", "callback_query"] - logger.debug("Telegram inline keyboards enabled") + self.logger.debug("inline keyboards enabled") else: allowed_updates = ["message"] - logger.info("Starting Telegram bot (polling mode)...") + self.logger.info("Starting bot (polling mode)...") # Initialize and start polling await self._app.initialize() @@ -396,13 +395,13 @@ class TelegramChannel(BaseChannel): bot_info = await self._app.bot.get_me() self._bot_user_id = getattr(bot_info, "id", None) self._bot_username = getattr(bot_info, "username", None) - logger.info("Telegram bot @{} connected", bot_info.username) + self.logger.info("bot @{} connected", bot_info.username) try: await self._app.bot.set_my_commands(self.BOT_COMMANDS) - logger.debug("Telegram bot commands registered") + self.logger.debug("bot commands registered") except Exception as e: - logger.warning("Failed to register bot commands: {}", e) + self.logger.warning("Failed to register bot commands: {}", e) # Start polling (this runs until stopped) await self._app.updater.start_polling( @@ -429,7 +428,7 @@ class TelegramChannel(BaseChannel): self._media_group_buffers.clear() if self._app: - logger.info("Stopping Telegram bot...") + self.logger.info("Stopping bot...") await self._app.updater.stop() await self._app.stop() await self._app.shutdown() @@ -456,7 +455,7 @@ class TelegramChannel(BaseChannel): async def send(self, msg: OutboundMessage) -> None: """Send a message through Telegram.""" if not self._app: - logger.warning("Telegram bot not running") + self.logger.warning("bot not running") return # Only stop typing indicator and remove reaction for final responses @@ -469,7 +468,7 @@ class TelegramChannel(BaseChannel): try: chat_id = int(msg.chat_id) except ValueError: - logger.error("Invalid chat_id: {}", msg.chat_id) + self.logger.exception("Invalid chat_id: {}", msg.chat_id) return reply_to_message_id = msg.metadata.get("message_id") message_thread_id = msg.metadata.get("message_thread_id") @@ -533,9 +532,9 @@ class TelegramChannel(BaseChannel): **extra, **send_kwargs, ) - except Exception as e: + except Exception: filename = media_path.rsplit("/", 1)[-1] - logger.error("Failed to send media {}: {}", media_path, e) + self.logger.exception("Failed to send media {}", media_path) await self._app.bot.send_message( chat_id=chat_id, text=f"[Failed to send: {filename}]", @@ -572,8 +571,8 @@ class TelegramChannel(BaseChannel): if attempt == _SEND_MAX_RETRIES: raise delay = _SEND_RETRY_BASE_DELAY * (2 ** (attempt - 1)) - logger.warning( - "Telegram timeout (attempt {}/{}), retrying in {:.1f}s", + self.logger.warning( + "timeout (attempt {}/{}), retrying in {:.1f}s", attempt, _SEND_MAX_RETRIES, delay, ) await asyncio.sleep(delay) @@ -581,8 +580,8 @@ class TelegramChannel(BaseChannel): if attempt == _SEND_MAX_RETRIES: raise delay = float(e.retry_after) - logger.warning( - "Telegram Flood Control (attempt {}/{}), retrying in {:.1f}s", + self.logger.warning( + "Flood Control (attempt {}/{}), retrying in {:.1f}s", attempt, _SEND_MAX_RETRIES, delay, ) await asyncio.sleep(delay) @@ -607,7 +606,7 @@ class TelegramChannel(BaseChannel): **(thread_kwargs or {}), ) except BadRequest as e: - logger.warning("HTML parse failed, falling back to plain text: {}", e) + self.logger.warning("HTML parse failed, falling back to plain text: {}", e) try: await self._call_with_retry( self._app.bot.send_message, @@ -617,8 +616,8 @@ class TelegramChannel(BaseChannel): reply_markup=reply_markup, **(thread_kwargs or {}), ) - except Exception as e2: - logger.error("Error sending Telegram message: {}", e2) + except Exception: + self.logger.exception("Error sending message") raise @staticmethod @@ -666,10 +665,10 @@ class TelegramChannel(BaseChannel): # Network errors (TimedOut, NetworkError) should propagate immediately # to avoid doubling connection demand during pool exhaustion. if self._is_not_modified_error(e): - logger.debug("Final stream edit already applied for {}", chat_id) + self.logger.debug("Final stream edit already applied for {}", chat_id) self._stream_bufs.pop(chat_id, None) return - logger.debug("Final stream edit failed (HTML), trying plain: {}", e) + self.logger.debug("Final stream edit failed (HTML), trying plain: {}", e) # Fall back to raw markdown (not HTML) so users don't see raw tags. primary_plain = split_message(raw_text, TELEGRAM_MAX_MESSAGE_LEN)[0] if len(raw_text) > TELEGRAM_MAX_MESSAGE_LEN else raw_text try: @@ -680,9 +679,9 @@ class TelegramChannel(BaseChannel): ) except Exception as e2: if self._is_not_modified_error(e2): - logger.debug("Final stream plain edit already applied for {}", chat_id) + self.logger.debug("Final stream plain edit already applied for {}", chat_id) else: - logger.warning("Final stream edit failed: {}", e2) + self.logger.warning("Final stream edit failed: {}", e2) raise # Let ChannelManager handle retry for extra_html_chunk in extra_html_chunks: try: @@ -724,7 +723,7 @@ class TelegramChannel(BaseChannel): buf.message_id = sent.message_id buf.last_edit = now except Exception as e: - logger.warning("Stream initial send failed: {}", e) + self.logger.warning("Stream initial send failed: {}", e) raise # Let ChannelManager handle retry elif (now - buf.last_edit) >= self.config.stream_edit_interval: if len(buf.text) > TELEGRAM_MAX_MESSAGE_LEN: @@ -743,7 +742,7 @@ class TelegramChannel(BaseChannel): if self._is_not_modified_error(e): buf.last_edit = now return - logger.warning("Stream edit failed: {}", e) + self.logger.warning("Stream edit failed: {}", e) raise # Let ChannelManager handle retry async def _flush_stream_overflow( @@ -769,7 +768,7 @@ class TelegramChannel(BaseChannel): ) except Exception as e: if not self._is_not_modified_error(e): - logger.warning("Stream overflow edit failed: {}", e) + self.logger.warning("Stream overflow edit failed: {}", e) raise for chunk in chunks[1:-1]: await self._call_with_retry( @@ -790,6 +789,8 @@ class TelegramChannel(BaseChannel): return user = update.effective_user + if not self.is_allowed(self._sender_id(user)): + return await update.message.reply_text( f"👋 Hi {user.first_name}! I'm nanobot.\n\n" "Send me a message and I'll respond!\n" @@ -797,8 +798,10 @@ class TelegramChannel(BaseChannel): ) async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle /help command, bypassing ACL so all users can access it.""" - if not update.message: + """Handle /help command for allowed users only.""" + if not update.message or not update.effective_user: + return + if not self.is_allowed(self._sender_id(update.effective_user)): return await update.message.reply_text(build_help_text()) @@ -899,12 +902,12 @@ class TelegramChannel(BaseChannel): if media_type in ("voice", "audio"): transcription = await self.transcribe_audio(file_path) if transcription: - logger.info("Transcribed {}: {}...", media_type, transcription[:50]) + self.logger.info("Transcribed {}: {}...", media_type, transcription[:50]) return [path_str], [f"[transcription: {transcription}]"] return [path_str], [f"[{media_type}: {path_str}]"] return [path_str], [f"[{media_type}: {path_str}]"] except Exception as e: - logger.warning("Failed to download message media: {}", e) + self.logger.warning("Failed to download message media: {}", e) if add_failure_content: return [], [f"[{media_type}: download failed]"] return [], [] @@ -989,6 +992,9 @@ class TelegramChannel(BaseChannel): return message = update.message user = update.effective_user + sender_id = self._sender_id(user) + if not self.is_allowed(sender_id): + return self._remember_thread_context(message) # Strip @bot_username suffix if present @@ -1000,7 +1006,7 @@ class TelegramChannel(BaseChannel): content = self._normalize_telegram_command(content) await self._handle_message( - sender_id=self._sender_id(user), + sender_id=sender_id, chat_id=str(message.chat_id), content=content, metadata=self._build_message_metadata(message, user), @@ -1016,6 +1022,8 @@ class TelegramChannel(BaseChannel): user = update.effective_user chat_id = message.chat_id sender_id = self._sender_id(user) + if not self.is_allowed(sender_id): + return self._remember_thread_context(message) # Store chat_id for replies @@ -1047,7 +1055,7 @@ class TelegramChannel(BaseChannel): media_paths.extend(current_media_paths) content_parts.extend(current_media_parts) if current_media_paths: - logger.debug("Downloaded message media to {}", current_media_paths[0]) + self.logger.debug("Downloaded message media to {}", current_media_paths[0]) # Reply context: text and/or media from the replied-to message reply = getattr(message, "reply_to_message", None) @@ -1056,13 +1064,13 @@ class TelegramChannel(BaseChannel): reply_media, reply_media_parts = await self._download_message_media(reply) if reply_media: media_paths = reply_media + media_paths - logger.debug("Attached replied-to media: {}", reply_media[0]) + self.logger.debug("Attached replied-to media: {}", reply_media[0]) tag = reply_ctx or (f"[Reply to: {reply_media_parts[0]}]" if reply_media_parts else None) if tag: content_parts.insert(0, tag) content = "\n".join(content_parts) if content_parts else "[empty message]" - logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) + self.logger.debug("message from {}: {}...", sender_id, content[:50]) str_chat_id = str(chat_id) metadata = self._build_message_metadata(message, user) @@ -1141,7 +1149,7 @@ class TelegramChannel(BaseChannel): reaction=[ReactionTypeEmoji(emoji=emoji)], ) except Exception as e: - logger.debug("Telegram reaction failed: {}", e) + self.logger.debug("reaction failed: {}", e) async def _remove_reaction(self, chat_id: str, message_id: int) -> None: """Remove emoji reaction from a message (best-effort, non-blocking).""" @@ -1154,7 +1162,7 @@ class TelegramChannel(BaseChannel): reaction=[], ) except Exception as e: - logger.debug("Telegram reaction removal failed: {}", e) + self.logger.debug("reaction removal failed: {}", e) async def _typing_loop(self, chat_id: str) -> None: """Repeatedly send 'typing' action until cancelled.""" @@ -1164,7 +1172,7 @@ class TelegramChannel(BaseChannel): await self._app.bot.send_chat_action(chat_id=int(chat_id), action="typing") await asyncio.sleep(4) except Exception as e: - logger.debug("Typing indicator stopped for {}: {}", chat_id, e) + self.logger.debug("Typing indicator stopped for {}: {}", chat_id, e) @staticmethod def _format_telegram_error(exc: Exception) -> str: @@ -1184,18 +1192,18 @@ class TelegramChannel(BaseChannel): """Keep long-polling network failures to a single readable line.""" summary = self._format_telegram_error(exc) if isinstance(exc, (NetworkError, TimedOut)): - logger.warning("Telegram polling network issue: {}", summary) + self.logger.warning("polling network issue: {}", summary) else: - logger.error("Telegram polling error: {}", summary) + self.logger.error("polling error: {}", summary) async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: """Log polling / handler errors instead of silently swallowing them.""" summary = self._format_telegram_error(context.error) if isinstance(context.error, (NetworkError, TimedOut)): - logger.warning("Telegram network issue: {}", summary) + self.logger.warning("network issue: {}", summary) else: - logger.error("Telegram error: {}", summary) + self.logger.error("error: {}", summary) def _get_extension( self, @@ -1256,14 +1264,16 @@ class TelegramChannel(BaseChannel): chat_id = query.message.chat_id if query.message else None sender_id = self._sender_id(user) if not chat_id: - logger.warning("Callback query without chat_id") + self.logger.warning("Callback query without chat_id") + return + if not self.is_allowed(sender_id): return button_label = query.data or "" await query.answer() if query.message: with suppress(Exception): await query.message.edit_reply_markup(reply_markup=None) - logger.debug("Inline button tap from {}: {}", sender_id, button_label) + self.logger.debug("Inline button tap from {}: {}", sender_id, button_label) self._start_typing(str(chat_id)) await self._handle_message( sender_id=sender_id, diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index eba9ed79a..7d4d20625 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -32,6 +32,7 @@ from websockets.http11 import Response from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.command.builtin import builtin_command_palette from nanobot.config.paths import get_media_dir from nanobot.config.schema import Base from nanobot.utils.helpers import safe_filename @@ -128,6 +129,17 @@ class WebSocketConfig(Base): raise ValueError("token_issue_path must differ from path (the WebSocket upgrade path)") return self + @model_validator(mode="after") + def wildcard_host_requires_auth(self) -> Self: + if self.host not in ("0.0.0.0", "::"): + return self + if self.token.strip() or self.token_issue_secret.strip(): + return self + raise ValueError( + "host is 0.0.0.0 (all interfaces) but neither token nor " + "token_issue_secret is set — set one to prevent unauthenticated access" + ) + def _http_json_response(data: dict[str, Any], *, status: int = 200) -> Response: body = json.dumps(data, ensure_ascii=False).encode("utf-8") @@ -448,7 +460,7 @@ class WebSocketChannel(BaseChannel): except ConnectionClosed: self._cleanup_connection(connection) except Exception as e: - logger.warning("websocket: failed to send {} event: {}", event, e) + self.logger.warning("failed to send {} event: {}", event, e) @classmethod def default_config(cls) -> dict[str, Any]: @@ -464,7 +476,7 @@ class WebSocketChannel(BaseChannel): return None if not cert or not key: raise ValueError( - "websocket: ssl_certfile and ssl_keyfile must both be set for WSS, or both left empty" + "ssl_certfile and ssl_keyfile must both be set for WSS, or both left empty" ) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.minimum_version = ssl.TLSVersion.TLSv1_2 @@ -501,14 +513,14 @@ class WebSocketChannel(BaseChannel): if not _issue_route_secret_matches(request.headers, secret): return connection.respond(401, "Unauthorized") else: - logger.warning( - "websocket: token_issue_path is set but token_issue_secret is empty; " + self.logger.warning( + "token_issue_path is set but token_issue_secret is empty; " "any client can obtain connection tokens — set token_issue_secret for production." ) self._purge_expired_issued_tokens() if len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS: - logger.error( - "websocket: too many outstanding issued tokens ({}), rejecting issuance", + self.logger.error( + "too many outstanding issued tokens ({}), rejecting issuance", len(self._issued_tokens), ) return _http_json_response({"error": "too many outstanding tokens"}, status=429) @@ -531,9 +543,9 @@ class WebSocketChannel(BaseChannel): if got == issue_expected: return self._handle_token_issue_http(connection, request) - # 2. WebUI bootstrap: localhost-only, mints tokens for the embedded UI. + # 2. WebUI bootstrap: mints tokens for the embedded UI. if got == "/webui/bootstrap": - return self._handle_webui_bootstrap(connection) + return self._handle_webui_bootstrap(connection, request) # 3. REST surface for the embedded UI. if got == "/api/sessions": @@ -542,6 +554,9 @@ class WebSocketChannel(BaseChannel): if got == "/api/settings": return self._handle_settings(request) + if got == "/api/commands": + return self._handle_commands(request) + if got == "/api/settings/update": return self._handle_settings_update(request) @@ -606,8 +621,16 @@ class WebSocketChannel(BaseChannel): if now > expiry: self._api_tokens.pop(token_key, None) - def _handle_webui_bootstrap(self, connection: Any) -> Response: - if not _is_localhost(connection): + def _handle_webui_bootstrap(self, connection: Any, request: Any) -> Response: + # When a secret is configured (token_issue_secret or static token), + # validate it regardless of source IP. This secures deployments + # behind a reverse proxy where all connections appear as localhost. + secret = self.config.token_issue_secret.strip() or self.config.token.strip() + if secret: + if not _issue_route_secret_matches(request.headers, secret): + return _http_error(401, "Unauthorized") + elif not _is_localhost(connection): + # No secret configured: only allow localhost (local dev mode). return _http_error(403, "webui bootstrap is localhost-only") # Cap outstanding tokens to avoid runaway growth from a misbehaving client. self._purge_expired_issued_tokens() @@ -689,6 +712,11 @@ class WebSocketChannel(BaseChannel): return _http_error(401, "Unauthorized") return _http_json_response(self._settings_payload()) + def _handle_commands(self, request: WsRequest) -> Response: + if not self._check_api_token(request): + return _http_error(401, "Unauthorized") + return _http_json_response({"commands": builtin_command_palette()}) + def _handle_settings_update(self, request: WsRequest) -> Response: if not self._check_api_token(request): return _http_error(401, "Unauthorized") @@ -821,7 +849,7 @@ class WebSocketChannel(BaseChannel): staged = media_dir / f"{uuid.uuid4().hex[:12]}-{safe_name}" shutil.copyfile(path, staged) except OSError as exc: - logger.warning("websocket: failed to stage outbound media {}: {}", path, exc) + self.logger.warning("failed to stage outbound media {}: {}", path, exc) return None signed = self._sign_media_path(staged) if signed is None: @@ -917,7 +945,7 @@ class WebSocketChannel(BaseChannel): try: body = candidate.read_bytes() except OSError as e: - logger.warning("websocket static: failed to read {}: {}", candidate, e) + self.logger.warning("static: failed to read {}: {}", candidate, e) return _http_error(500, "Internal Server Error") ctype, _ = mimetypes.guess_type(candidate.name) if ctype is None: @@ -972,7 +1000,7 @@ class WebSocketChannel(BaseChannel): async def handler(connection: ServerConnection) -> None: await self._connection_loop(connection) - logger.info( + self.logger.info( "WebSocket server listening on {}://{}:{}{}", scheme, self.config.host, @@ -980,7 +1008,7 @@ class WebSocketChannel(BaseChannel): self.config.path, ) if self.config.token_issue_path: - logger.info( + self.logger.info( "WebSocket token issue route: {}://{}:{}{}", scheme, self.config.host, @@ -1014,7 +1042,7 @@ class WebSocketChannel(BaseChannel): if not client_id: client_id = f"anon-{uuid.uuid4().hex[:12]}" elif len(client_id) > 128: - logger.warning("websocket: client_id too long ({} chars), truncating", len(client_id)) + self.logger.warning("client_id too long ({} chars), truncating", len(client_id)) client_id = client_id[:128] default_chat_id = str(uuid.uuid4()) @@ -1039,7 +1067,7 @@ class WebSocketChannel(BaseChannel): try: raw = raw.decode("utf-8") except UnicodeDecodeError: - logger.warning("websocket: ignoring non-utf8 binary frame") + self.logger.warning("ignoring non-utf8 binary frame") continue envelope = _parse_envelope(raw) @@ -1057,7 +1085,7 @@ class WebSocketChannel(BaseChannel): metadata={"remote": getattr(connection, "remote_address", None)}, ) except Exception as e: - logger.debug("websocket connection ended: {}", e) + self.logger.debug("connection ended: {}", e) finally: self._cleanup_connection(connection) @@ -1097,8 +1125,8 @@ class WebSocketChannel(BaseChannel): try: Path(p).unlink(missing_ok=True) except OSError as exc: - logger.warning( - "websocket: failed to unlink partial media {}: {}", p, exc + self.logger.warning( + "failed to unlink partial media {}: {}", p, exc ) return [], reason @@ -1122,7 +1150,7 @@ class WebSocketChannel(BaseChannel): except FileSizeExceeded: return _abort("size") except Exception as exc: - logger.warning("websocket: media decode failed: {}", exc) + self.logger.warning("media decode failed: {}", exc) return _abort("decode") if saved is None: return _abort("decode") @@ -1184,12 +1212,15 @@ class WebSocketChannel(BaseChannel): # Auto-attach on first use so clients can one-shot without a separate attach. self._attach(connection, cid) + metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)} + if envelope.get("webui") is True: + metadata["webui"] = True await self._handle_message( sender_id=client_id, chat_id=cid, content=content, media=media_paths or None, - metadata={"remote": getattr(connection, "remote_address", None)}, + metadata=metadata, ) return await self._send_event(connection, "error", detail=f"unknown type: {t!r}") @@ -1204,7 +1235,7 @@ class WebSocketChannel(BaseChannel): try: await self._server_task except Exception as e: - logger.warning("websocket: server task error during shutdown: {}", e) + self.logger.warning("server task error during shutdown: {}", e) self._server_task = None self._subs.clear() self._conn_chats.clear() @@ -1218,16 +1249,23 @@ class WebSocketChannel(BaseChannel): await connection.send(raw) except ConnectionClosed: self._cleanup_connection(connection) - logger.warning("websocket{}connection gone", label) - except Exception as e: - logger.error("websocket{}send failed: {}", label, e) + self.logger.warning("connection gone{}", label) + except Exception: + self.logger.exception("send failed{}", label) raise async def send(self, msg: OutboundMessage) -> None: # Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe. conns = list(self._subs.get(msg.chat_id, ())) if not conns: - logger.warning("websocket: no active subscribers for chat_id={}", msg.chat_id) + self.logger.warning("no active subscribers for chat_id={}", msg.chat_id) + return + # Signal that the agent has fully finished processing the current turn. + if msg.metadata.get("_turn_end"): + await self.send_turn_end(msg.chat_id) + return + if msg.metadata.get("_session_updated"): + await self.send_session_updated(msg.chat_id) return text = msg.content if msg.buttons: @@ -1285,3 +1323,23 @@ class WebSocketChannel(BaseChannel): raw = json.dumps(body, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" stream ") + + async def send_turn_end(self, chat_id: str) -> None: + """Signal that the agent has fully finished processing the current turn.""" + conns = list(self._subs.get(chat_id, ())) + if not conns: + return + body: dict[str, Any] = {"event": "turn_end", "chat_id": chat_id} + raw = json.dumps(body, ensure_ascii=False) + for connection in conns: + await self._safe_send_to(connection, raw, label=" turn_end ") + + async def send_session_updated(self, chat_id: str) -> None: + """Notify clients that session metadata changed outside the main turn.""" + conns = list(self._subs.get(chat_id, ())) + if not conns: + return + body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id} + raw = json.dumps(body, ensure_ascii=False) + for connection in conns: + await self._safe_send_to(connection, raw, label=" session_updated ") diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py index 69bdf3f08..2dd9f8856 100644 --- a/nanobot/channels/wecom.py +++ b/nanobot/channels/wecom.py @@ -10,14 +10,13 @@ from collections import OrderedDict from pathlib import Path from typing import Any -from loguru import logger +from pydantic import Field from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_media_dir from nanobot.config.schema import Base -from pydantic import Field WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None @@ -103,11 +102,11 @@ class WecomChannel(BaseChannel): async def start(self) -> None: """Start the WeCom bot with WebSocket long connection.""" if not WECOM_AVAILABLE: - logger.error("WeCom SDK not installed. Run: pip install nanobot-ai[wecom]") + self.logger.error("SDK not installed. Run: pip install nanobot-ai[wecom]") return if not self.config.bot_id or not self.config.secret: - logger.error("WeCom bot_id and secret not configured") + self.logger.error("bot_id and secret not configured") return from wecom_aibot_sdk import WSClient, generate_req_id @@ -137,8 +136,8 @@ class WecomChannel(BaseChannel): self._client.on("message.mixed", self._on_mixed_message) self._client.on("event.enter_chat", self._on_enter_chat) - logger.info("WeCom bot starting with WebSocket long connection") - logger.info("No public IP required - using WebSocket to receive events") + self.logger.info("bot starting with WebSocket long connection") + self.logger.info("No public IP required - using WebSocket to receive events") # Connect await self._client.connect_async() @@ -152,24 +151,24 @@ class WecomChannel(BaseChannel): self._running = False if self._client: await self._client.disconnect() - logger.info("WeCom bot stopped") + self.logger.info("bot stopped") async def _on_connected(self, frame: Any) -> None: """Handle WebSocket connected event.""" - logger.info("WeCom WebSocket connected") + self.logger.info("WebSocket connected") async def _on_authenticated(self, frame: Any) -> None: """Handle authentication success event.""" - logger.info("WeCom authenticated successfully") + self.logger.info("authenticated successfully") async def _on_disconnected(self, frame: Any) -> None: """Handle WebSocket disconnected event.""" reason = frame.body if hasattr(frame, 'body') else str(frame) - logger.warning("WeCom WebSocket disconnected: {}", reason) + self.logger.warning("WebSocket disconnected: {}", reason) async def _on_error(self, frame: Any) -> None: """Handle error event.""" - logger.error("WeCom error: {}", frame) + self.logger.error("error: {}", frame) async def _on_text_message(self, frame: Any) -> None: """Handle text message.""" @@ -204,13 +203,16 @@ class WecomChannel(BaseChannel): chat_id = body.get("chatid", "") if isinstance(body, dict) else "" + if chat_id and not self.is_allowed(chat_id): + return + if chat_id and self.config.welcome_message: await self._client.reply_welcome(frame, { "msgtype": "text", "text": {"content": self.config.welcome_message}, }) - except Exception as e: - logger.error("Error handling enter_chat: {}", e) + except Exception: + self.logger.exception("Error handling enter_chat") async def _process_message(self, frame: Any, msg_type: str) -> None: """Process incoming message and forward to bus.""" @@ -225,7 +227,7 @@ class WecomChannel(BaseChannel): # Ensure body is a dict if not isinstance(body, dict): - logger.warning("Invalid body type: {}", type(body)) + self.logger.warning("Invalid body type: {}", type(body)) return # Extract message info @@ -233,6 +235,12 @@ class WecomChannel(BaseChannel): if not msg_id: msg_id = f"{body.get('chatid', '')}_{body.get('sendertime', '')}" + # Extract sender info from "from" field (SDK format) + from_info = body.get("from", {}) + sender_id = from_info.get("userid", "unknown") if isinstance(from_info, dict) else "unknown" + if not self.is_allowed(sender_id): + return + # Deduplication check if msg_id in self._processed_message_ids: return @@ -242,10 +250,6 @@ class WecomChannel(BaseChannel): while len(self._processed_message_ids) > 1000: self._processed_message_ids.popitem(last=False) - # Extract sender info from "from" field (SDK format) - from_info = body.get("from", {}) - sender_id = from_info.get("userid", "unknown") if isinstance(from_info, dict) else "unknown" - # For single chat, chatid is the sender's userid # For group chat, chatid is provided in body chat_type = body.get("chattype", "single") @@ -345,8 +349,8 @@ class WecomChannel(BaseChannel): } ) - except Exception as e: - logger.error("Error processing WeCom message: {}", e) + except Exception: + self.logger.exception("Error processing message") async def _download_and_save_media( self, @@ -365,12 +369,12 @@ class WecomChannel(BaseChannel): data, fname = await self._client.download_file(file_url, aes_key) if not data: - logger.warning("Failed to download media from WeCom") + self.logger.warning("Failed to download media") return None if len(data) > WECOM_UPLOAD_MAX_BYTES: - logger.warning( - "WeCom inbound media too large: {} bytes (max {})", + self.logger.warning( + "inbound media too large: {} bytes (max {})", len(data), WECOM_UPLOAD_MAX_BYTES, ) @@ -383,11 +387,11 @@ class WecomChannel(BaseChannel): file_path = media_dir / filename await asyncio.to_thread(file_path.write_bytes, data) - logger.debug("Downloaded {} to {}", media_type, file_path) + self.logger.debug("Downloaded {} to {}", media_type, file_path) return str(file_path) - except Exception as e: - logger.error("Error downloading media: {}", e) + except Exception: + self.logger.exception("Error downloading media") return None async def _upload_media_ws( @@ -424,9 +428,9 @@ class WecomChannel(BaseChannel): # MD5 is used for file integrity only, not cryptographic security md5_hash = hashlib.md5(data).hexdigest() - CHUNK_SIZE = 512 * 1024 # 512 KB raw (before base64) + chunk_size = 512 * 1024 # 512 KB raw (before base64) mv = memoryview(data) - chunk_list = [bytes(mv[i : i + CHUNK_SIZE]) for i in range(0, file_size, CHUNK_SIZE)] + chunk_list = [bytes(mv[i : i + chunk_size]) for i in range(0, file_size, chunk_size)] n_chunks = len(chunk_list) del mv, data @@ -440,11 +444,11 @@ class WecomChannel(BaseChannel): "md5": md5_hash, }, "aibot_upload_media_init") if resp.errcode != 0: - logger.warning("WeCom upload init failed ({}): {}", resp.errcode, resp.errmsg) + self.logger.warning("upload init failed ({}): {}", resp.errcode, resp.errmsg) return None, None upload_id = resp.body.get("upload_id") if resp.body else None if not upload_id: - logger.warning("WeCom upload init: no upload_id in response") + self.logger.warning("upload init: no upload_id in response") return None, None # Step 2: send chunks @@ -456,7 +460,7 @@ class WecomChannel(BaseChannel): "base64_data": base64.b64encode(chunk).decode(), }, "aibot_upload_media_chunk") if resp.errcode != 0: - logger.warning("WeCom upload chunk {} failed ({}): {}", i, resp.errcode, resp.errmsg) + self.logger.warning("upload chunk {} failed ({}): {}", i, resp.errcode, resp.errmsg) return None, None # Step 3: finish @@ -465,29 +469,29 @@ class WecomChannel(BaseChannel): "upload_id": upload_id, }, "aibot_upload_media_finish") if resp.errcode != 0: - logger.warning("WeCom upload finish failed ({}): {}", resp.errcode, resp.errmsg) + self.logger.warning("upload finish failed ({}): {}", resp.errcode, resp.errmsg) return None, None media_id = resp.body.get("media_id") if resp.body else None if not media_id: - logger.warning("WeCom upload finish: no media_id in response body={}", resp.body) + self.logger.warning("upload finish: no media_id in response body={}", resp.body) return None, None suffix = "..." if len(media_id) > 16 else "" - logger.debug("WeCom uploaded {} ({}) → media_id={}", fname, media_type, media_id[:16] + suffix) + self.logger.debug("uploaded {} ({}) → media_id={}", fname, media_type, media_id[:16] + suffix) return media_id, media_type except ValueError as e: - logger.warning("WeCom upload skipped for {}: {}", file_path, e) + self.logger.warning("upload skipped for {}: {}", file_path, e) return None, None - except Exception as e: - logger.error("WeCom _upload_media_ws error for {}: {}", file_path, e) + except Exception: + self.logger.exception("_upload_media_ws error for {}", file_path) return None, None async def send(self, msg: OutboundMessage) -> None: """Send a message through WeCom.""" if not self._client: - logger.warning("WeCom client not initialized") + self.logger.warning("client not initialized") return try: @@ -500,7 +504,7 @@ class WecomChannel(BaseChannel): # Send media files via WebSocket upload for file_path in msg.media or []: if not os.path.isfile(file_path): - logger.warning("WeCom media file not found: {}", file_path) + self.logger.warning("media file not found: {}", file_path) continue media_id, media_type = await self._upload_media_ws(self._client, file_path) if media_id: @@ -514,7 +518,7 @@ class WecomChannel(BaseChannel): "msgtype": media_type, media_type: {"media_id": media_id}, }) - logger.debug("WeCom sent {} → {}", media_type, msg.chat_id) + self.logger.debug("sent {} → {}", media_type, msg.chat_id) else: content += f"\n[file upload failed: {os.path.basename(file_path)}]" @@ -532,8 +536,8 @@ class WecomChannel(BaseChannel): content, finish=not is_progress, ) - logger.debug( - "WeCom {} sent to {}", + self.logger.debug( + "{} sent to {}", "progress" if is_progress else "message", msg.chat_id, ) @@ -543,7 +547,7 @@ class WecomChannel(BaseChannel): "msgtype": "markdown", "markdown": {"content": content}, }) - logger.info("WeCom proactive send to {}", msg.chat_id) + self.logger.info("proactive send to {}", msg.chat_id) except Exception: - logger.exception("Error sending WeCom message to chat_id={}", msg.chat_id) + self.logger.exception("Error sending message to chat_id={}", msg.chat_id) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 68fbed85d..dff830613 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -366,14 +366,14 @@ class WeixinChannel(BaseChannel): if base_url: self.config.base_url = base_url self._save_state() - logger.info( - "WeChat login successful! bot_id={} user_id={}", + self.logger.info( + "login successful! bot_id={} user_id={}", bot_id, user_id, ) return True else: - logger.error("Login confirmed but no bot_token in response") + self.logger.error("Login confirmed but no bot_token in response") return False elif status == "scaned_but_redirect": redirect_host = str(status_data.get("redirect_host", "") or "").strip() @@ -387,7 +387,7 @@ class WeixinChannel(BaseChannel): elif status == "expired": refresh_count += 1 if refresh_count > MAX_QR_REFRESH_COUNT: - logger.warning( + self.logger.warning( "QR code expired too many times ({}/{}), giving up.", refresh_count - 1, MAX_QR_REFRESH_COUNT, @@ -401,8 +401,8 @@ class WeixinChannel(BaseChannel): await asyncio.sleep(1) - except Exception as e: - logger.error("WeChat QR login failed: {}", e) + except Exception: + self.logger.exception("QR login failed") return False @@ -469,11 +469,11 @@ class WeixinChannel(BaseChannel): self._token = self.config.token elif not self._load_state(): if not await self._qr_login(): - logger.error("WeChat login failed. Run 'nanobot channels login weixin' to authenticate.") + self.logger.error("login failed. Run 'nanobot channels login weixin' to authenticate.") self._running = False return - logger.info("WeChat channel starting with long-poll...") + self.logger.info("channel starting with long-poll...") consecutive_failures = 0 while self._running: @@ -551,8 +551,8 @@ class WeixinChannel(BaseChannel): if errcode == ERRCODE_SESSION_EXPIRED or ret == ERRCODE_SESSION_EXPIRED: self._pause_session() remaining = self._session_pause_remaining_s() - logger.warning( - "WeChat session expired (errcode {}). Pausing {} min.", + self.logger.warning( + "session expired (errcode {}). Pausing {} min.", errcode, max((remaining + 59) // 60, 1), ) @@ -588,20 +588,24 @@ class WeixinChannel(BaseChannel): if msg.get("message_type") == MESSAGE_TYPE_BOT: return - # Deduplication by message_id msg_id = str(msg.get("message_id", "") or msg.get("seq", "")) if not msg_id: msg_id = f"{msg.get('from_user_id', '')}_{msg.get('create_time_ms', '')}" + + from_user_id = msg.get("from_user_id", "") or "" + if not from_user_id: + return + + if not self.is_allowed(from_user_id): + return + + # Deduplication by message_id if msg_id in self._processed_ids: return self._processed_ids[msg_id] = None while len(self._processed_ids) > 1000: self._processed_ids.popitem(last=False) - from_user_id = msg.get("from_user_id", "") or "" - if not from_user_id: - return - # Cache context_token (required for all replies — inbound.ts:23-27) ctx_token = msg.get("context_token", "") if ctx_token: @@ -755,8 +759,8 @@ class WeixinChannel(BaseChannel): if not content: return - logger.info( - "WeChat inbound: from={} items={} bodyLen={}", + self.logger.info( + "inbound: from={} items={} bodyLen={}", from_user_id, ",".join(str(i.get("type", 0)) for i in item_list), len(content), @@ -839,8 +843,8 @@ class WeixinChannel(BaseChannel): and self._is_retryable_media_download_error(e) ) if should_fallback: - logger.warning( - "WeChat media download failed via full_url, falling back to encrypt_query_param: type={} err={}", + self.logger.warning( + "media download failed via full_url, falling back to encrypt_query_param: type={} err={}", media_type, e, ) @@ -865,8 +869,8 @@ class WeixinChannel(BaseChannel): file_path.write_bytes(data) return str(file_path) - except Exception as e: - logger.error("Error downloading WeChat media: {}", e) + except Exception: + self.logger.exception("Error downloading media") return None # ------------------------------------------------------------------ @@ -936,12 +940,8 @@ class WeixinChannel(BaseChannel): async def send(self, msg: OutboundMessage) -> None: if not self._client or not self._token: - logger.warning("WeChat client not initialized or not authenticated") - return - try: - self._assert_session_active() - except RuntimeError: - return + raise RuntimeError("WeChat client not initialized or not authenticated") + self._assert_session_active() is_progress = bool((msg.metadata or {}).get("_progress", False)) if not is_progress: @@ -950,11 +950,9 @@ class WeixinChannel(BaseChannel): content = msg.content.strip() ctx_token = self._context_tokens.get(msg.chat_id, "") if not ctx_token: - logger.warning( - "WeChat: no context_token for chat_id={}, cannot send", - msg.chat_id, + raise RuntimeError( + f"WeChat context_token missing for chat_id={msg.chat_id}, cannot send" ) - return typing_ticket = "" with suppress(Exception): @@ -976,14 +974,13 @@ class WeixinChannel(BaseChannel): for media_path in (msg.media or []): try: await self._send_media_file(msg.chat_id, media_path, ctx_token) - except (httpx.TimeoutException, httpx.TransportError) as net_err: + except (httpx.TimeoutException, httpx.TransportError): # Network/transport errors: do NOT fall back to text — # the text send would also likely fail, and the outer # except will re-raise so ChannelManager retries properly. - logger.error( - "Network error sending WeChat media {}: {}", + self.logger.opt(exception=True).warning( + "Network error sending media {}", media_path, - net_err, ) raise except httpx.HTTPStatusError as http_err: @@ -994,27 +991,26 @@ class WeixinChannel(BaseChannel): ) if status_code >= 500: # Server-side / retryable HTTP error — same as network. - logger.error( - "Server error ({} {}) sending WeChat media {}: {}", + self.logger.exception( + "Server error ({} {}) sending media {}", status_code, http_err.response.reason_phrase if http_err.response is not None else "", media_path, - http_err, ) raise # 4xx client errors are NOT retryable — fall back to text. filename = Path(media_path).name - logger.error("Failed to send WeChat media {}: {}", media_path, http_err) + self.logger.exception("Failed to send media {}", media_path) await self._send_text( msg.chat_id, f"[Failed to send: {filename}]", ctx_token, ) - except Exception as e: + except Exception: # Non-network errors (format, file-not-found, etc.): # notify the user via text fallback. filename = Path(media_path).name - logger.error("Failed to send WeChat media {}: {}", media_path, e) + self.logger.exception("Failed to send media {}", media_path) # Notify user about failure via text await self._send_text( msg.chat_id, f"[Failed to send: {filename}]", ctx_token, @@ -1027,8 +1023,8 @@ class WeixinChannel(BaseChannel): chunks = split_message(content, WEIXIN_MAX_MESSAGE_LEN) for chunk in chunks: await self._send_text(msg.chat_id, chunk, ctx_token) - except Exception as e: - logger.error("Error sending WeChat message: {}", e) + except Exception: + self.logger.exception("Error sending message") raise finally: if typing_keepalive_task: @@ -1052,7 +1048,7 @@ class WeixinChannel(BaseChannel): return await self._send_typing(chat_id, ticket, TYPING_STATUS_TYPING) except Exception as e: - logger.debug("WeChat typing indicator start failed for {}: {}", chat_id, e) + self.logger.debug("typing indicator start failed for {}: {}", chat_id, e) return stop_event = asyncio.Event() @@ -1091,7 +1087,7 @@ class WeixinChannel(BaseChannel): try: await self._send_typing(chat_id, ticket, TYPING_STATUS_CANCEL) except Exception as e: - logger.debug("WeChat typing clear failed for {}: {}", chat_id, e) + self.logger.debug("typing clear failed for {}: {}", chat_id, e) async def _send_text( self, @@ -1126,10 +1122,8 @@ class WeixinChannel(BaseChannel): data = await self._api_post("ilink/bot/sendmessage", body) errcode = data.get("errcode", 0) if errcode and errcode != 0: - logger.warning( - "WeChat send error (code {}): {}", - errcode, - data.get("errmsg", ""), + raise RuntimeError( + f"WeChat send text error (code {errcode}): {data.get('errmsg', '')}" ) async def _send_media_file( diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 74d53203f..bd0620334 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -8,8 +8,8 @@ import os import secrets import shutil import subprocess -from contextlib import suppress from collections import OrderedDict +from contextlib import suppress from pathlib import Path from typing import Any, Literal @@ -99,15 +99,15 @@ class WhatsAppChannel(BaseChannel): """ try: bridge_dir = _ensure_bridge_setup() - except RuntimeError as e: - logger.error("{}", e) + except RuntimeError: + self.logger.exception("bridge setup failed") return False env = {**os.environ} env["BRIDGE_TOKEN"] = self._effective_bridge_token() env["AUTH_DIR"] = str(_bridge_token_path().parent) - logger.info("Starting WhatsApp bridge for QR login...") + self.logger.info("Starting WhatsApp bridge for QR login...") try: subprocess.run( [shutil.which("npm"), "start"], cwd=bridge_dir, check=True, env=env @@ -123,7 +123,7 @@ class WhatsAppChannel(BaseChannel): bridge_url = self.config.bridge_url - logger.info("Connecting to WhatsApp bridge at {}...", bridge_url) + self.logger.info("Connecting to WhatsApp bridge at {}...", bridge_url) self._running = True @@ -135,24 +135,24 @@ class WhatsAppChannel(BaseChannel): json.dumps({"type": "auth", "token": self._effective_bridge_token()}) ) self._connected = True - logger.info("Connected to WhatsApp bridge") + self.logger.info("Connected to WhatsApp bridge") # Listen for messages async for message in ws: try: await self._handle_bridge_message(message) - except Exception as e: - logger.error("Error handling bridge message: {}", e) + except Exception: + self.logger.exception("Error handling bridge message") except asyncio.CancelledError: break except Exception as e: self._connected = False self._ws = None - logger.warning("WhatsApp bridge connection error: {}", e) + self.logger.warning("WhatsApp bridge connection error: {}", e) if self._running: - logger.info("Reconnecting in 5 seconds...") + self.logger.info("Reconnecting in 5 seconds...") await asyncio.sleep(5) async def stop(self) -> None: @@ -167,7 +167,7 @@ class WhatsAppChannel(BaseChannel): async def send(self, msg: OutboundMessage) -> None: """Send a message through WhatsApp.""" if not self._ws or not self._connected: - logger.warning("WhatsApp bridge not connected") + self.logger.warning("WhatsApp bridge not connected") return chat_id = msg.chat_id @@ -176,8 +176,8 @@ class WhatsAppChannel(BaseChannel): try: payload = {"type": "send", "to": chat_id, "text": msg.content} await self._ws.send(json.dumps(payload, ensure_ascii=False)) - except Exception as e: - logger.error("Error sending WhatsApp message: {}", e) + except Exception: + self.logger.exception("Error sending message") raise for media_path in msg.media or []: @@ -191,8 +191,8 @@ class WhatsAppChannel(BaseChannel): "fileName": media_path.rsplit("/", 1)[-1], } await self._ws.send(json.dumps(payload, ensure_ascii=False)) - except Exception as e: - logger.error("Error sending WhatsApp media {}: {}", media_path, e) + except Exception: + self.logger.exception("Error sending media {}", media_path) raise async def _handle_bridge_message(self, raw: str) -> None: @@ -200,7 +200,7 @@ class WhatsAppChannel(BaseChannel): try: data = json.loads(raw) except json.JSONDecodeError: - logger.warning("Invalid JSON from bridge: {}", raw[:100]) + self.logger.warning("Invalid JSON from bridge: {}", raw[:100]) return msg_type = data.get("type") @@ -214,13 +214,6 @@ class WhatsAppChannel(BaseChannel): content = data.get("content", "") message_id = data.get("id", "") - if message_id: - if message_id in self._processed_message_ids: - return - self._processed_message_ids[message_id] = None - while len(self._processed_message_ids) > 1000: - self._processed_message_ids.popitem(last=False) - # Extract just the phone number or lid as chat_id is_group = data.get("isGroup", False) was_mentioned = data.get("wasMentioned", False) @@ -246,11 +239,21 @@ class WhatsAppChannel(BaseChannel): elif extracted and not phone_id: phone_id = extracted # best guess for bare values + sender_id = phone_id or self._lid_to_phone.get(lid_id, "") or lid_id or id_a or id_b + if not self.is_allowed(sender_id): + return + + if message_id: + if message_id in self._processed_message_ids: + return + self._processed_message_ids[message_id] = None + while len(self._processed_message_ids) > 1000: + self._processed_message_ids.popitem(last=False) + if phone_id and lid_id: self._lid_to_phone[lid_id] = phone_id - sender_id = phone_id or self._lid_to_phone.get(lid_id, "") or lid_id or id_a or id_b - logger.info("Sender phone={} lid={} → sender_id={}", phone_id or "(empty)", lid_id or "(empty)", sender_id) + self.logger.info("Sender phone={} lid={} → sender_id={}", phone_id or "(empty)", lid_id or "(empty)", sender_id) # Extract media paths (images/documents/videos downloaded by the bridge) media_paths = data.get("media") or [] @@ -258,11 +261,11 @@ class WhatsAppChannel(BaseChannel): # Handle voice transcription if it's a voice message if content == "[Voice Message]": if media_paths: - logger.info("Transcribing voice message from {}...", sender_id) + self.logger.info("Transcribing voice message from {}...", sender_id) transcription = await self.transcribe_audio(media_paths[0]) if transcription: content = transcription - logger.info("Transcribed voice from {}: {}...", sender_id, transcription[:50]) + self.logger.info("Transcribed voice from {}: {}...", sender_id, transcription[:50]) else: content = "[Voice Message: Transcription failed]" else: @@ -291,7 +294,7 @@ class WhatsAppChannel(BaseChannel): elif msg_type == "status": # Connection status update status = data.get("status") - logger.info("WhatsApp status: {}", status) + self.logger.info("Status: {}", status) if status == "connected": self._connected = True @@ -300,10 +303,10 @@ class WhatsAppChannel(BaseChannel): elif msg_type == "qr": # QR code for authentication - logger.info("Scan QR code in the bridge terminal to connect WhatsApp") + self.logger.info("Scan QR code in the bridge terminal to connect WhatsApp") elif msg_type == "error": - logger.error("WhatsApp bridge error: {}", data.get("error")) + self.logger.error("Bridge error: {}", data.get("error")) def _ensure_bridge_setup() -> Path: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2243b2fa6..1f0186f1d 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -21,6 +21,22 @@ if sys.platform == "win32": import typer from loguru import logger + +# Remove default handler and re-add with unified nanobot format +logger.remove() +_log_handler_id = logger.add( + sys.stderr, + format=( + "{time:YYYY-MM-DD HH:mm:ss} | " + "{level: <5} | " + "{extra[channel]} | " + "{message}" + ), + level="INFO", + colorize=None, + filter=lambda record: record["extra"].setdefault("channel", "-") or True, +) + from prompt_toolkit import PromptSession, print_formatted_text from prompt_toolkit.application import run_in_terminal from prompt_toolkit.formatted_text import ANSI, HTML @@ -217,6 +233,29 @@ async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner await _print_interactive_line(text) +async def _maybe_print_interactive_progress( + msg: Any, + thinking: ThinkingSpinner | None, + channels_config: Any, +) -> bool: + metadata = msg.metadata or {} + if metadata.get("_retry_wait"): + await _print_interactive_progress_line(msg.content, thinking) + return True + + if not metadata.get("_progress"): + return False + + is_tool_hint = metadata.get("_tool_hint", False) + if channels_config and is_tool_hint and not channels_config.send_tool_hints: + return True + if channels_config and not is_tool_hint and not channels_config.send_progress: + return True + + await _print_interactive_progress_line(msg.content, thinking) + return True + + def _is_exit_command(command: str) -> bool: """Return True when input should end interactive chat.""" return command.lower() in EXIT_COMMANDS @@ -575,9 +614,19 @@ def gateway( ): """Start the nanobot gateway.""" if verbose: - import logging - - logging.basicConfig(level=logging.DEBUG) + logger.remove(_log_handler_id) + logger.add( + sys.stderr, + format=( + "{time:YYYY-MM-DD HH:mm:ss} | " + "{level: <5} | " + "{extra[channel]} | " + "{message}" + ), + level="DEBUG", + colorize=None, + filter=lambda record: record["extra"].setdefault("channel", "-") or True, + ) cfg = _load_runtime_config(config, workspace) _run_gateway(cfg, port=port) @@ -1131,15 +1180,11 @@ def agent( turn_done.set() continue - if msg.metadata.get("_progress"): - is_tool_hint = msg.metadata.get("_tool_hint", False) - ch = agent_loop.channels_config - if ch and is_tool_hint and not ch.send_tool_hints: - pass - elif ch and not is_tool_hint and not ch.send_progress: - pass - else: - await _print_interactive_progress_line(msg.content, _thinking) + if await _maybe_print_interactive_progress( + msg, + _thinking, + agent_loop.channels_config, + ): continue if not turn_done.is_set(): diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index 4c5700892..5eadb43d9 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -840,7 +840,7 @@ def _get_channel_info() -> dict[str, tuple[str, type[BaseModel]]]: display_name = getattr(channel_cls, "display_name", name.capitalize()) result[name] = (display_name, config_cls) except Exception: - logger.warning(f"Failed to load channel module: {name}") + logger.warning("Failed to load channel module: {}", name) return result diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 32444a4ba..b71a77f91 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -6,6 +6,7 @@ import asyncio import os import sys from contextlib import suppress +from dataclasses import dataclass from nanobot import __version__ from nanobot.bus.events import OutboundMessage @@ -14,6 +15,88 @@ from nanobot.utils.helpers import build_status_content from nanobot.utils.restart import set_restart_notice_to_env +@dataclass(frozen=True) +class BuiltinCommandSpec: + command: str + title: str + description: str + icon: str + arg_hint: str = "" + + def as_dict(self) -> dict[str, str]: + return { + "command": self.command, + "title": self.title, + "description": self.description, + "icon": self.icon, + "arg_hint": self.arg_hint, + } + + +BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = ( + BuiltinCommandSpec( + "/new", + "New chat", + "Stop the current task and start a fresh conversation.", + "square-pen", + ), + BuiltinCommandSpec( + "/stop", + "Stop current task", + "Cancel the active agent turn for this chat.", + "square", + ), + BuiltinCommandSpec( + "/restart", + "Restart nanobot", + "Restart the bot process in place.", + "rotate-cw", + ), + BuiltinCommandSpec( + "/status", + "Show status", + "Display runtime, provider, and channel status.", + "activity", + ), + BuiltinCommandSpec( + "/history", + "Show conversation history", + "Print the last N persisted conversation messages.", + "history", + "[n]", + ), + BuiltinCommandSpec( + "/dream", + "Run Dream", + "Manually trigger memory consolidation.", + "sparkles", + ), + BuiltinCommandSpec( + "/dream-log", + "Show Dream log", + "Show what the last Dream consolidation changed.", + "book-open", + ), + BuiltinCommandSpec( + "/dream-restore", + "Restore memory", + "Revert memory to a previous Dream snapshot.", + "undo-2", + ), + BuiltinCommandSpec( + "/help", + "Show help", + "List available slash commands.", + "circle-help", + ), +) + + +def builtin_command_palette() -> list[dict[str, str]]: + """Return structured command metadata for UI command palettes.""" + return [spec.as_dict() for spec in BUILTIN_COMMAND_SPECS] + + async def cmd_stop(ctx: CommandContext) -> OutboundMessage: """Cancel all active tasks and subagents for the session.""" loop = ctx.loop @@ -378,18 +461,12 @@ async def cmd_help(ctx: CommandContext) -> OutboundMessage: def build_help_text() -> str: """Build canonical help text shared across channels.""" - lines = [ - "🐈 nanobot commands:", - "/new — Stop current task and start a new conversation", - "/stop — Stop the current task", - "/restart — Restart the bot", - "/status — Show bot status", - "/history [n] — Show the last N conversation messages (default 10)", - "/dream — Manually trigger Dream consolidation", - "/dream-log — Show what the last Dream changed", - "/dream-restore — Revert memory to a previous state", - "/help — Show available commands", - ] + lines = ["🐈 nanobot commands:"] + for spec in BUILTIN_COMMAND_SPECS: + command = spec.command + if spec.arg_hint: + command = f"{command} {spec.arg_hint}" + lines.append(f"{command} — {spec.description}") return "\n".join(lines) diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index d663105f5..e0808e107 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -49,7 +49,7 @@ def load_config(config_path: Path | None = None) -> Config: data = _migrate_config(data) config = Config.model_validate(data) except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e: - logger.warning(f"Failed to load config from {path}: {e}") + logger.warning("Failed to load config from {}: {}", path, e) logger.warning("Using default configuration.") _apply_ssrf_whitelist(config) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 1cc858ce9..31c5b50a7 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -2,8 +2,10 @@ import asyncio import json +import os import time import uuid +from contextlib import suppress from dataclasses import asdict from datetime import datetime from pathlib import Path @@ -12,7 +14,14 @@ from typing import Any, Callable, Coroutine, Literal from filelock import FileLock from loguru import logger -from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronRunRecord, CronSchedule, CronStore +from nanobot.cron.types import ( + CronJob, + CronJobState, + CronPayload, + CronRunRecord, + CronSchedule, + CronStore, +) def _now_ms() -> int: @@ -83,8 +92,20 @@ class CronService: self._timer_active = False self.max_sleep_ms = max_sleep_ms - def _load_jobs(self) -> tuple[list[CronJob], int]: - jobs = [] + def _load_jobs(self) -> tuple[list[CronJob], int] | None: + """Load jobs from disk. + + Returns: + ``(jobs, version)`` tuple on success or when no store file exists + (in which case an empty list and version 1 are returned). + ``None`` when the store file exists but cannot be parsed; the + corrupt file is preserved with a ``.corrupt-`` suffix so the + caller can decide whether to overwrite or bail out. Returning a + sentinel here is important: silently treating a parse error as an + empty job list would cause the next ``_save_store`` to wipe every + job from disk. + """ + jobs: list[CronJob] = [] version = 1 if self.store_path.exists(): try: @@ -135,8 +156,22 @@ class CronService: updated_at_ms=j.get("updatedAtMs", 0), delete_after_run=j.get("deleteAfterRun", False), )) - except Exception as e: - logger.warning("Failed to load cron store: {}", e) + except Exception: + # Preserve the corrupt file for forensic recovery instead of + # letting the next save overwrite it with an empty job list. + backup = self.store_path.with_suffix( + self.store_path.suffix + f".corrupt-{int(time.time())}" + ) + with suppress(OSError): + self.store_path.rename(backup) + logger.exception( + "Failed to load cron store at {}. " + "Corrupt file preserved at {}. " + "Refusing to overwrite to avoid data loss.", + self.store_path, + backup, + ) + return None return jobs, version def _merge_action(self): @@ -166,8 +201,8 @@ class CronService: else: _update(action.get("params", {})) changed = True - except Exception as exp: - logger.debug(f"load action line error: {exp}") + except Exception: + logger.exception("load action line error") continue self._store.jobs = list(jobs_map.values()) if self._running and changed: @@ -175,15 +210,28 @@ class CronService: self._save_store() return - def _load_store(self) -> CronStore: + def _load_store(self) -> CronStore | None: """Load jobs from disk. Reloads automatically if file was modified externally. - Reload every time because it needs to merge operations on the jobs object from other instances. - During _on_timer execution, return the existing store to prevent concurrent _load_store calls (e.g. from list_jobs polling) from replacing it mid-execution. + - When the on-disk store exists but is unreadable: keep using the + previous in-memory ``self._store`` if we already have one (so a + transient corruption does not drop live jobs); only the very first + load (during ``start``) can return ``None`` to signal an unrecoverable + state to the caller. """ if self._timer_active and self._store: return self._store - jobs, version = self._load_jobs() + loaded = self._load_jobs() + if loaded is None: + # Corrupt store on disk. Prefer the last good in-memory snapshot + # over wiping live jobs; ``_load_jobs`` has already moved the + # corrupt file aside with a ``.corrupt-`` suffix. + if self._store is not None: + return self._store + return None + jobs, version = loaded self._store = CronStore(version=version, jobs=jobs) self._merge_action() @@ -242,12 +290,56 @@ class CronService: ] } - self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + self._atomic_write(self.store_path, json.dumps(data, indent=2, ensure_ascii=False)) + + @staticmethod + def _atomic_write(path: Path, content: str) -> None: + """Write *content* to *path* atomically with fsync. + + Uses a temp-file + ``os.replace`` + ``fsync`` pattern so a crash or + SIGKILL mid-write cannot leave the destination truncated or invalid. + Mirrors ``nanobot.session.manager.SessionManager.save`` (see + commit 512bf59, ``fix(session): fsync sessions on graceful shutdown + to prevent data loss``). Without this, ``jobs.json`` could be + corrupted on container shutdown and silently re-created empty on + next start, wiping every scheduled job. + """ + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(path.suffix + ".tmp") + try: + with open(tmp_path, "w", encoding="utf-8") as f: + f.write(content) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, path) + # fsync the parent directory so the rename itself is durable. + # Skip on Windows where opening a directory raises PermissionError; + # NTFS journals metadata synchronously so this is a no-op there. + with suppress(PermissionError): + fd = os.open(str(path.parent), os.O_RDONLY) + try: + os.fsync(fd) + finally: + os.close(fd) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise async def start(self) -> None: """Start the cron service.""" self._running = True - self._load_store() + loaded = self._load_store() + if loaded is None: + # Store file existed but was corrupt and has been preserved with + # a ``.corrupt-`` suffix. Bail out instead of starting with + # an empty store; that would call ``_save_store`` and overwrite + # the now-renamed (but still recoverable) data with []. + self._running = False + raise RuntimeError( + f"cron store at {self.store_path} is corrupt and was preserved; " + "refusing to start with an empty job list. " + "Inspect the .corrupt- backup and restore manually." + ) self._recompute_next_runs() self._save_store() self._arm_timer() @@ -302,6 +394,9 @@ class CronService: async def _on_timer(self) -> None: """Handle timer tick - run due jobs.""" self._load_store() + # If a hot reload found a corrupt store on disk, ``self._store`` may + # still hold the previous, known-good in-memory snapshot. Keep using + # it rather than crashing the timer or wiping live jobs. if not self._store: self._arm_timer() return @@ -338,7 +433,7 @@ class CronService: except Exception as e: job.state.last_status = "error" job.state.last_error = str(e) - logger.error("Cron: job '{}' failed: {}", job.name, e) + logger.exception("Cron: job '{}' failed", job.name) end_ms = _now_ms() job.state.last_run_at_ms = start_ms diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index fea2c51b6..b41ee7a1e 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -144,8 +144,8 @@ class HeartbeatService: await self._tick() except asyncio.CancelledError: break - except Exception as e: - logger.error("Heartbeat error: {}", e) + except Exception: + logger.exception("Heartbeat error") @staticmethod def _is_deliverable(response: str) -> bool: diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index decdccb3a..555d0e5e0 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -449,59 +449,6 @@ class OpenAICompatProvider(LLMProvider): clean["content"] = self._coerce_content_to_string(clean.get("content")) return self._enforce_role_alternation(sanitized) - def _drop_deepseek_incomplete_reasoning_history( - self, - messages: list[dict[str, Any]], - model_name: str, - reasoning_effort: str | None, - ) -> list[dict[str, Any]]: - if ( - not self._spec - or self._spec.name != "deepseek" - ): - return messages - - semantic_effort = reasoning_effort.lower() if isinstance(reasoning_effort, str) else None - if semantic_effort in {"none", "minimal", "minimum"}: - return messages - - # DeepSeek-V4 can require reasoning_content even when the config did - # not explicitly request reasoning_effort. Keep that implicit-thinking - # cleanup scoped to known thinking-capable DeepSeek models so normal - # deepseek-chat history is not trimmed. - if semantic_effort is None: - model_lower = model_name.lower() - if not any(token in model_lower for token in ("deepseek-v4", "deepseek-reasoner")): - return messages - - bad_idx = None - for idx, msg in enumerate(messages): - if ( - msg.get("role") == "assistant" - and msg.get("tool_calls") - and not msg.get("reasoning_content") - ): - bad_idx = idx - if bad_idx is None: - return messages - - keep_from = None - for idx in range(bad_idx + 1, len(messages)): - if messages[idx].get("role") == "user": - keep_from = idx - break - - if keep_from is None: - trimmed = messages[:bad_idx] - else: - prefix = [msg for msg in messages[:keep_from] if msg.get("role") == "system"] - trimmed = prefix + messages[keep_from:] - logger.warning( - "Dropped {} DeepSeek thinking history message(s) with incomplete reasoning_content", - len(messages) - len(trimmed), - ) - return trimmed - # ------------------------------------------------------------------ # Build kwargs # ------------------------------------------------------------------ @@ -542,11 +489,6 @@ class OpenAICompatProvider(LLMProvider): if spec and spec.strip_model_prefix: model_name = model_name.split("/")[-1] - messages = self._drop_deepseek_incomplete_reasoning_history( - messages, - model_name, - reasoning_effort, - ) kwargs: dict[str, Any] = { "model": model_name, "messages": self._sanitize_messages(self._sanitize_empty_content(messages)), @@ -611,22 +553,22 @@ class OpenAICompatProvider(LLMProvider): kwargs["tools"] = tools kwargs["tool_choice"] = tool_choice or "auto" - # Backfill reasoning_content on legacy assistant messages. - # DeepSeek V4 (and potentially others) rejects thinking-mode - # requests that contain assistant messages without reasoning_content - # — even on turns that had no tool calls. This happens when a - # session was started with a non-thinking model or without - # reasoning_effort, then the user switches thinking mode on - # mid-session. Injecting an empty string satisfies the API - # without altering semantics (the model treats it as "no - # thinking happened on that turn"). - thinking_active = ( - (spec and spec.thinking_style and reasoning_effort is not None - and semantic_effort not in ("none", "minimal")) - or (reasoning_effort is not None and _is_kimi_thinking_model(model_name) - and semantic_effort not in ("none", "minimal")) + # Backfill reasoning_content="" on assistants missing it: DeepSeek + # thinking mode rejects history otherwise (#3554, #3584); "" reads + # as "no thinking that turn". DeepSeek-V4/reasoner reason natively, + # so backfill even without explicit reasoning_effort. + explicit_thinking = ( + reasoning_effort is not None + and semantic_effort not in ("none", "minimal") + and ((spec and spec.thinking_style) or _is_kimi_thinking_model(model_name)) ) - if thinking_active: + implicit_deepseek_thinking = ( + spec is not None + and spec.name == "deepseek" + and semantic_effort not in ("none", "minimal", "minimum") + and any(t in model_name.lower() for t in ("deepseek-v4", "deepseek-reasoner")) + ) + if explicit_thinking or implicit_deepseek_thinking: for msg in kwargs["messages"]: if msg.get("role") == "assistant" and "reasoning_content" not in msg: msg["reasoning_content"] = "" diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 06c7317d0..859d2cca8 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -547,10 +547,13 @@ class SessionManager: data = json.loads(first_line) if data.get("_type") == "metadata": key = data.get("key") or path.stem.replace("_", ":", 1) + metadata = data.get("metadata", {}) + title = metadata.get("title") if isinstance(metadata, dict) else None sessions.append({ "key": key, "created_at": data.get("created_at"), "updated_at": data.get("updated_at"), + "title": title if isinstance(title, str) else "", "path": str(path) }) except Exception: @@ -560,6 +563,11 @@ class SessionManager: "key": repaired.key, "created_at": repaired.created_at.isoformat(), "updated_at": repaired.updated_at.isoformat(), + "title": ( + repaired.metadata.get("title") + if isinstance(repaired.metadata.get("title"), str) + else "" + ), "path": str(path) }) continue diff --git a/nanobot/utils/document.py b/nanobot/utils/document.py index 3a1ea9067..53039e97f 100644 --- a/nanobot/utils/document.py +++ b/nanobot/utils/document.py @@ -93,7 +93,7 @@ def _extract_pdf(path: Path) -> str: pages.append(f"--- Page {i} ---\n{text}") return _truncate("\n\n".join(pages), _MAX_TEXT_LENGTH) except Exception as e: - logger.error("Failed to extract PDF {}: {}", path, e) + logger.exception("Failed to extract PDF {}", path) return f"[error: failed to extract PDF: {e!s}]" @@ -108,7 +108,7 @@ def _extract_docx(path: Path) -> str: paragraphs: list[str] = [p.text for p in doc.paragraphs if p.text.strip()] return _truncate("\n\n".join(paragraphs), _MAX_TEXT_LENGTH) except Exception as e: - logger.error("Failed to extract DOCX {}: {}", path, e) + logger.exception("Failed to extract DOCX {}", path) return f"[error: failed to extract DOCX: {e!s}]" @@ -135,7 +135,7 @@ def _extract_xlsx(path: Path) -> str: finally: wb.close() except Exception as e: - logger.error("Failed to extract XLSX {}: {}", path, e) + logger.exception("Failed to extract XLSX {}", path) return f"[error: failed to extract XLSX: {e!s}]" @@ -156,7 +156,7 @@ def _extract_pptx(path: Path) -> str: slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text)) return _truncate("\n\n".join(slides), _MAX_TEXT_LENGTH) except Exception as e: - logger.error("Failed to extract PPTX {}: {}", path, e) + logger.exception("Failed to extract PPTX {}", path) return f"[error: failed to extract PPTX: {e!s}]" @@ -195,7 +195,7 @@ def _extract_text_file(path: Path) -> str: content = path.read_text(encoding="latin-1") return _truncate(content, _MAX_TEXT_LENGTH) except Exception as e: - logger.error("Failed to read text file {}: {}", path, e) + logger.exception("Failed to read text file {}", path) return f"[error: failed to read file: {e!s}]" diff --git a/nanobot/utils/gitstore.py b/nanobot/utils/gitstore.py index d9b528c97..6e05ca128 100644 --- a/nanobot/utils/gitstore.py +++ b/nanobot/utils/gitstore.py @@ -113,7 +113,7 @@ class GitStore: logger.info("Git store initialized at {}", self._workspace) return True except Exception: - logger.warning("Git store init failed for {}", self._workspace) + logger.exception("Git store init failed for {}", self._workspace) return False # -- daily operations ------------------------------------------------------ @@ -149,7 +149,7 @@ class GitStore: logger.debug("Git auto-commit: {} ({})", sha, message) return sha except Exception: - logger.warning("Git auto-commit failed: {}", message) + logger.exception("Git auto-commit failed: {}", message) return None # -- internal helpers ------------------------------------------------------ @@ -243,7 +243,7 @@ class GitStore: return entries except Exception: - logger.warning("Git log failed") + logger.exception("Git log failed") return [] def line_ages(self, file_path: str) -> list[LineAge]: @@ -266,7 +266,7 @@ class GitStore: annotated = porcelain.annotate(str(self._workspace), file_path) except Exception: - logger.warning("Git line_ages annotate failed for {}", file_path) + logger.exception("Git line_ages annotate failed for {}", file_path) return [] if not annotated: @@ -296,7 +296,7 @@ class GitStore: ) return out.getvalue().decode("utf-8", errors="replace") except Exception: - logger.warning("Git diff_commits failed") + logger.exception("Git diff_commits failed") return "" def find_commit(self, short_sha: str, max_entries: int = 20) -> CommitInfo | None: @@ -367,7 +367,7 @@ class GitStore: msg = f"revert: undo {commit}" return self.auto_commit(msg) except Exception: - logger.warning("Git revert failed for {}", commit) + logger.exception("Git revert failed for {}", commit) return None @staticmethod diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 0afe193cc..b047e24d2 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -268,8 +268,8 @@ def maybe_persist_tool_result( bucket = ensure_dir(root / safe_filename(session_key or "default")) try: _cleanup_tool_result_buckets(root, bucket) - except Exception as exc: - logger.warning("Failed to clean stale tool result buckets in {}: {}", root, exc) + except Exception: + logger.exception("Failed to clean stale tool result buckets in {}", root) path = bucket / f"{safe_filename(tool_call_id)}.{suffix}" if not path.exists(): if suffix == "json" and isinstance(content, list): @@ -540,6 +540,6 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str] ) gs.init() except Exception: - logger.warning("Failed to initialize git store for {}", workspace) + logger.exception("Failed to initialize git store for {}", workspace) return added diff --git a/nanobot/utils/logging_bridge.py b/nanobot/utils/logging_bridge.py new file mode 100644 index 000000000..a20e2e888 --- /dev/null +++ b/nanobot/utils/logging_bridge.py @@ -0,0 +1,47 @@ +"""Utilities for redirecting stdlib logging to loguru.""" +from __future__ import annotations + +import logging + +from loguru import logger + + +class _LoguruBridge(logging.Handler): + """Route stdlib log records into loguru with consistent formatting.""" + + _LEVEL_MAP: dict[int, str] = { + logging.DEBUG: "DEBUG", + logging.INFO: "INFO", + logging.WARNING: "WARNING", + logging.ERROR: "ERROR", + logging.CRITICAL: "CRITICAL", + } + + def __init__(self, lib_name: str) -> None: + super().__init__() + self.lib_name = lib_name + + def emit(self, record: logging.LogRecord) -> None: + level = self._LEVEL_MAP.get(record.levelno, "INFO") + frame, depth = logging.currentframe(), 2 + while frame and frame.f_code.co_filename == logging.__file__: + frame, depth = frame.f_back, depth + 1 + logger.opt(depth=depth, exception=record.exc_info).log( + level, "[{lib}] {message}", lib=self.lib_name, message=record.getMessage() + ) + + +def redirect_lib_logging(name: str, level: str | None = None) -> None: + """Redirect stdlib logging from *name* into loguru. + + Adds a bridge handler if one is not already present and disables + propagation so messages are not duplicated. When *level* is None the + handler does not filter — loguru's own level controls visibility. + """ + lib_logger = logging.getLogger(name) + if not any(isinstance(h, _LoguruBridge) for h in lib_logger.handlers): + handler = _LoguruBridge(name) + if level is not None: + handler.setLevel(getattr(logging, level.upper(), logging.WARNING)) + lib_logger.handlers = [handler] + lib_logger.propagate = False diff --git a/nanobot/utils/runtime.py b/nanobot/utils/runtime.py index 39822fd48..4157b396f 100644 --- a/nanobot/utils/runtime.py +++ b/nanobot/utils/runtime.py @@ -2,6 +2,8 @@ from __future__ import annotations +import re +from pathlib import Path from typing import Any from loguru import logger @@ -10,6 +12,9 @@ from nanobot.utils.helpers import stringify_text_blocks _MAX_REPEAT_EXTERNAL_LOOKUPS = 2 +# Third same-target workspace violation in a turn escalates to "stop retrying". +_MAX_REPEAT_WORKSPACE_VIOLATIONS = 2 + EMPTY_FINAL_RESPONSE_MESSAGE = ( "I completed the tool steps but couldn't produce a final answer. " "Please try again or narrow the task." @@ -95,3 +100,71 @@ def repeated_external_lookup_error( "Error: repeated external lookup blocked. " "Use the results you already have to answer, or try a meaningfully different source." ) + + +# Workspace-boundary violations are soft errors, with per-target throttling. + +_OUTSIDE_PATH_PATTERN = re.compile(r"(?:^|[\s|>'\"])((?:/[^\s\"'>;|<]+)|(?:~[^\s\"'>;|<]+))") + + +def workspace_violation_signature( + tool_name: str, + arguments: dict[str, Any], +) -> str | None: + """Return a stable cross-tool signature for the outside-workspace target.""" + for key in ("path", "file_path", "target", "source", "destination"): + val = arguments.get(key) + if isinstance(val, str) and val.strip(): + return _normalize_violation_target(val.strip()) + + if tool_name in {"exec", "shell"}: + cmd = str(arguments.get("command") or "").strip() + if cmd: + match = _OUTSIDE_PATH_PATTERN.search(cmd) + if match: + return _normalize_violation_target(match.group(1)) + cwd = str(arguments.get("working_dir") or "").strip() + if cwd: + return _normalize_violation_target(cwd) + + return None + + +def _normalize_violation_target(raw: str) -> str: + """Normalize *raw* path so that equivalent spellings collide on the same key.""" + try: + normalized = Path(raw).expanduser().resolve().as_posix() + except Exception: + normalized = raw.replace("\\", "/") + return f"violation:{normalized}".lower() + + +def repeated_workspace_violation_error( + tool_name: str, + arguments: dict[str, Any], + seen_counts: dict[str, int], +) -> str | None: + """Return an escalated error after repeated bypass attempts.""" + signature = workspace_violation_signature(tool_name, arguments) + if signature is None: + return None + count = seen_counts.get(signature, 0) + 1 + seen_counts[signature] = count + if count <= _MAX_REPEAT_WORKSPACE_VIOLATIONS: + return None + logger.warning( + "Escalating repeated workspace bypass attempt {} (attempt {})", + signature[:160], + count, + ) + target = signature.split("violation:", 1)[1] if "violation:" in signature else signature + return ( + "Error: refusing repeated workspace-bypass attempts.\n" + f"You have tried to access '{target}' (or an equivalent path) " + f"{count} times in this turn. This is a hard policy boundary -- " + "switching tools, shell tricks, working_dir overrides, symlinks, " + "or base64 piping will NOT change the answer. Stop retrying. " + "If the user genuinely needs this resource, tell them you cannot " + "access it and ask how they want to proceed (e.g. copy the file " + "into the workspace, or disable restrict_to_workspace for this run)." + ) diff --git a/nanobot/utils/webui_titles.py b/nanobot/utils/webui_titles.py new file mode 100644 index 000000000..2d363f926 --- /dev/null +++ b/nanobot/utils/webui_titles.py @@ -0,0 +1,138 @@ +"""Helpers for WebUI chat title generation.""" + +from __future__ import annotations + +import re +from typing import Any + +from loguru import logger + +from nanobot.providers.base import LLMProvider +from nanobot.session.manager import Session, SessionManager +from nanobot.utils.helpers import truncate_text + +WEBUI_SESSION_METADATA_KEY = "webui" +WEBUI_TITLE_METADATA_KEY = "title" +WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited" +TITLE_MAX_CHARS = 60 + + +def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool: + """Persist a WebUI marker only when the inbound websocket frame opted in.""" + if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: + return False + session.metadata[WEBUI_SESSION_METADATA_KEY] = True + return True + + +def clean_generated_title(raw: str | None) -> str: + text = (raw or "").strip() + if not text: + return "" + text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE) + text = text.strip().strip("\"'`“”‘’") + text = re.sub(r"\s+", " ", text).strip() + text = text.rstrip("。.!!??,,;;:") + if len(text) > TITLE_MAX_CHARS: + text = text[: TITLE_MAX_CHARS - 1].rstrip() + "…" + return text + + +def _title_inputs(session: Session) -> tuple[str, str]: + user_text = "" + assistant_text = "" + for message in session.messages: + role = message.get("role") + content = message.get("content") + if not isinstance(content, str) or not content.strip(): + continue + if role == "user" and not user_text: + user_text = content.strip() + elif role == "assistant" and not assistant_text: + assistant_text = content.strip() + if user_text and assistant_text: + break + return user_text, assistant_text + + +async def maybe_generate_webui_title( + *, + sessions: SessionManager, + session_key: str, + provider: LLMProvider, + model: str, +) -> bool: + """Generate and persist a short title for WebUI-owned sessions only.""" + session = sessions.get_or_create(session_key) + if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: + return False + if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True: + return False + current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY) + if isinstance(current_title, str) and current_title.strip(): + return False + + user_text, assistant_text = _title_inputs(session) + if not user_text: + return False + + prompt = ( + "Generate a concise title for this chat.\n" + "Rules:\n" + "- Use the same language as the user when practical.\n" + "- 3 to 8 words.\n" + "- No quotes.\n" + "- No punctuation at the end.\n" + "- Return only the title.\n\n" + f"User: {truncate_text(user_text, 1_000)}" + ) + if assistant_text: + prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}" + + try: + response = await provider.chat_with_retry( + [ + { + "role": "system", + "content": ( + "You write short, neutral chat titles. " + "Return only the title text." + ), + }, + {"role": "user", "content": prompt}, + ], + tools=None, + model=model, + max_tokens=32, + temperature=0.2, + retry_mode="standard", + ) + except Exception: + logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True) + return False + + title = clean_generated_title(response.content) + if not title or title.lower().startswith("error"): + return False + session.metadata[WEBUI_TITLE_METADATA_KEY] = title + sessions.save(session) + return True + + +async def maybe_generate_webui_title_after_turn( + *, + channel: str, + metadata: dict[str, Any], + sessions: SessionManager, + session_key: str, + provider: LLMProvider, + model: str, +) -> bool: + if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: + return False + return await maybe_generate_webui_title( + sessions=sessions, + session_key=session_key, + provider=provider, + model=model, + ) diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py index 0c32a8f16..ee3f1e3db 100644 --- a/tests/agent/test_loop_progress.py +++ b/tests/agent/test_loop_progress.py @@ -1,5 +1,6 @@ """Tests for structured tool-event progress metadata emitted by AgentLoop.""" +import asyncio from pathlib import Path from unittest.mock import AsyncMock, MagicMock @@ -130,11 +131,44 @@ class TestToolEventProgress: assert finish["result"] == "file.txt" @pytest.mark.asyncio - async def test_bus_progress_streams_provider_deltas_for_codex_style_provider( + async def test_non_streaming_channel_does_not_publish_codex_progress_deltas( self, tmp_path: Path, ) -> None: - """Providers that opt in can stream content deltas through _progress messages.""" + """Non-streaming channels should get one final reply, not token progress spam.""" + bus = MessageBus() + provider = MagicMock() + provider.supports_progress_deltas = True + provider.get_default_model.return_value = "openai-codex/gpt-5.5" + provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Hello", tool_calls=[])) + provider.chat_stream_with_retry = AsyncMock() + loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5") + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + + await loop._dispatch(InboundMessage( + channel="whatsapp", + sender_id="u1", + chat_id="chat1", + content="say hello", + )) + + outbound = [] + while bus.outbound_size > 0: + outbound.append(await bus.consume_outbound()) + + assert [m.content for m in outbound] == ["Hello"] + assert not any(m.metadata.get("_progress") for m in outbound) + assert not any(m.metadata.get("_streamed") for m in outbound) + provider.chat_stream_with_retry.assert_not_awaited() + provider.chat_with_retry.assert_awaited_once() + + @pytest.mark.asyncio + async def test_streaming_channel_streams_provider_deltas_for_codex_style_provider( + self, + tmp_path: Path, + ) -> None: + """Streaming channels still receive provider deltas through _stream_delta messages.""" bus = MessageBus() provider = MagicMock() provider.supports_progress_deltas = True @@ -149,23 +183,34 @@ class TestToolEventProgress: provider.chat_with_retry = AsyncMock() loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5") loop.tools.get_definitions = MagicMock(return_value=[]) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] await loop._dispatch(InboundMessage( channel="websocket", sender_id="u1", chat_id="chat1", content="say hello", + metadata={"_wants_stream": True}, )) outbound = [] while bus.outbound_size > 0: outbound.append(await bus.consume_outbound()) - progress = [m for m in outbound if m.metadata.get("_progress")] - final = [m for m in outbound if not m.metadata.get("_progress")] + deltas = [m for m in outbound if m.metadata.get("_stream_delta")] + stream_end = [m for m in outbound if m.metadata.get("_stream_end")] + final = [ + m for m in outbound + if not m.metadata.get("_stream_delta") + and not m.metadata.get("_stream_end") + and not m.metadata.get("_turn_end") + ] - assert [m.content for m in progress] == ["Hel", "lo"] + assert [m.content for m in deltas] == ["Hel", "lo"] + assert len(stream_end) == 1 assert final[-1].content == "Hello" + assert final[-1].metadata.get("_streamed") is True + assert outbound[-1].metadata.get("_turn_end") is True provider.chat_with_retry.assert_not_awaited() @pytest.mark.asyncio @@ -195,8 +240,12 @@ class TestToolEventProgress: loop.tools.prepare_call = MagicMock(return_value=(None, {"path": "foo.txt"}, None)) loop.tools.execute = AsyncMock(return_value="ok") + streamed: list[str] = [] progress: list[tuple[str, bool, list[dict] | None]] = [] + async def on_stream(delta: str) -> None: + streamed.append(delta) + async def on_progress( content: str, *, @@ -205,12 +254,107 @@ class TestToolEventProgress: ) -> None: progress.append((content, tool_hint, tool_events)) - final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress) + final_content, _, _, _, _ = await loop._run_agent_loop( + [], + on_progress=on_progress, + on_stream=on_stream, + ) assert final_content == "Done" - assert [item[0] for item in progress[:3]] == [ - "I will", - " inspect it.", - 'custom_tool("foo.txt")', - ] + assert streamed == ["I will", " inspect it."] + assert progress[0][0] == 'custom_tool("foo.txt")' assert all(item[0] != "I will inspect it." for item in progress) + + @pytest.mark.asyncio + async def test_websocket_dispatch_publishes_final_turn_end_marker(self, tmp_path: Path) -> None: + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[])) + loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + + await loop._dispatch(InboundMessage( + channel="websocket", + sender_id="u1", + chat_id="chat1", + content="say hello", + )) + + outbound = [] + while bus.outbound_size > 0: + outbound.append(await bus.consume_outbound()) + + assert outbound[-2].content == "Done" + assert (outbound[-2].metadata or {}).get("_turn_end") is not True + assert outbound[-1].content == "" + assert (outbound[-1].metadata or {}).get("_turn_end") is True + assert outbound[-1].chat_id == "chat1" + + @pytest.mark.asyncio + async def test_webui_title_generation_runs_after_turn_end(self, tmp_path: Path) -> None: + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + title_started = asyncio.Event() + release_title = asyncio.Event() + calls = 0 + + async def chat_with_retry(*_args: object, **_kwargs: object) -> LLMResponse: + nonlocal calls + calls += 1 + if calls == 1: + return LLMResponse(content="Done", tool_calls=[]) + title_started.set() + await release_title.wait() + return LLMResponse(content="Generated title", tool_calls=[]) + + provider.chat_with_retry = AsyncMock(side_effect=chat_with_retry) + loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + + await asyncio.wait_for(loop._dispatch(InboundMessage( + channel="websocket", + sender_id="u1", + chat_id="chat1", + content="say hello", + metadata={"webui": True}, + )), timeout=0.5) + + outbound = [await bus.consume_outbound(), await bus.consume_outbound()] + assert outbound[0].content == "Done" + assert (outbound[1].metadata or {}).get("_turn_end") is True + + await asyncio.wait_for(title_started.wait(), timeout=0.5) + release_title.set() + session_updated = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5) + + assert (session_updated.metadata or {}).get("_session_updated") is True + assert provider.chat_with_retry.await_count == 2 + + @pytest.mark.asyncio + async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None: + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[])) + loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + + await loop._dispatch(InboundMessage( + channel="slack", + sender_id="u1", + chat_id="chat1", + content="say hello", + )) + + outbound = [] + while bus.outbound_size > 0: + outbound.append(await bus.consume_outbound()) + + assert len(outbound) == 1 + assert outbound[0].content == "Done" + assert (outbound[0].metadata or {}).get("_turn_end") is not True diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index c3dd90af2..36b133999 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -8,7 +8,13 @@ from nanobot.agent.context import ContextBuilder from nanobot.agent.loop import AgentLoop from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus +from nanobot.providers.base import LLMResponse from nanobot.session.manager import Session +from nanobot.utils.webui_titles import ( + WEBUI_SESSION_METADATA_KEY, + WEBUI_TITLE_METADATA_KEY, + maybe_generate_webui_title, +) def _mk_loop() -> AgentLoop: @@ -22,9 +28,56 @@ def _mk_loop() -> AgentLoop: def _make_full_loop(tmp_path: Path) -> AgentLoop: provider = MagicMock() provider.get_default_model.return_value = "test-model" + provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Test title")) return AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model") +@pytest.mark.asyncio +async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None: + loop = _make_full_loop(tmp_path) + loop.provider.chat_with_retry = AsyncMock( + return_value=LLMResponse(content='"优化 WebUI 侧边栏。"', finish_reason="stop") + ) + session = loop.sessions.get_or_create("websocket:chat-title") + session.metadata[WEBUI_SESSION_METADATA_KEY] = True + session.add_message("user", "帮我优化一下 webui 的 sidebar") + session.add_message("assistant", "可以,我会先调整布局和视觉层级。") + loop.sessions.save(session) + + generated = await maybe_generate_webui_title( + sessions=loop.sessions, + session_key="websocket:chat-title", + provider=loop.provider, + model=loop.model, + ) + + assert generated is True + assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏" + loop.provider.chat_with_retry.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_generate_webui_title_skips_plain_websocket_sessions(tmp_path: Path) -> None: + loop = _make_full_loop(tmp_path) + loop.provider.chat_with_retry = AsyncMock( + return_value=LLMResponse(content="Plain websocket title", finish_reason="stop") + ) + session = loop.sessions.get_or_create("websocket:custom-client") + session.add_message("user", "hello from a custom websocket client") + loop.sessions.save(session) + + generated = await maybe_generate_webui_title( + sessions=loop.sessions, + session_key="websocket:custom-client", + provider=loop.provider, + model=loop.model, + ) + + assert generated is False + assert WEBUI_TITLE_METADATA_KEY not in session.metadata + loop.provider.chat_with_retry.assert_not_awaited() + + def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None: loop = _mk_loop() session = Session(key="test:runtime-only") diff --git a/tests/agent/test_runner.py b/tests/agent/test_runner.py index aa558b4ff..b821d9bab 100644 --- a/tests/agent/test_runner.py +++ b/tests/agent/test_runner.py @@ -313,21 +313,33 @@ async def test_runner_returns_structured_tool_error(): @pytest.mark.asyncio -async def test_runner_stops_on_workspace_violation_without_fail_on_tool_error(): +async def test_runner_does_not_abort_on_workspace_violation_anymore(): + """v2 behavior: workspace-bound rejections are *soft* tool errors. + + Previously (PR #3493) any workspace boundary error became a fatal + RuntimeError that aborted the turn. That silently killed legitimate + workspace commands once the heuristic guard misfired (#3599 #3605), so + we now hand the error back to the LLM as a recoverable tool result and + rely on ``repeated_workspace_violation_error`` to throttle bypass loops. + """ from nanobot.agent.runner import AgentRunSpec, AgentRunner provider = MagicMock() provider.chat_with_retry = AsyncMock(side_effect=[ LLMResponse( - content="working", - tool_calls=[ToolCallRequest(id="call_1", name="read_file", arguments={"path": "/tmp/outside.md"})], + content="trying outside", + tool_calls=[ToolCallRequest( + id="call_1", name="read_file", arguments={"path": "/tmp/outside.md"}, + )], ), - LLMResponse(content="should not continue", tool_calls=[]), + LLMResponse(content="ok, telling the user instead", tool_calls=[]), ]) tools = MagicMock() tools.get_definitions.return_value = [] tools.execute = AsyncMock( - side_effect=PermissionError("Path /tmp/outside.md is outside allowed directory /workspace") + side_effect=PermissionError( + "Path /tmp/outside.md is outside allowed directory /workspace" + ) ) runner = AgentRunner(provider) @@ -336,41 +348,202 @@ async def test_runner_stops_on_workspace_violation_without_fail_on_tool_error(): initial_messages=[], tools=tools, model="test-model", - max_iterations=2, + max_iterations=3, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, )) - assert provider.chat_with_retry.await_count == 1 - assert result.stop_reason == "tool_error" - assert "outside allowed directory" in (result.error or "") - assert result.tool_events == [ - { - "name": "read_file", - "status": "error", - "detail": "workspace_violation: Path /tmp/outside.md is outside allowed directory /workspace", - } - ] + assert provider.chat_with_retry.await_count == 2, ( + "workspace violation must NOT short-circuit the loop" + ) + assert result.stop_reason != "tool_error" + assert result.error is None + assert result.final_content == "ok, telling the user instead" + assert result.tool_events and result.tool_events[0]["status"] == "error" + # Detail still carries the workspace_violation breadcrumb for telemetry, + # but the runner did not raise. + assert "workspace_violation" in result.tool_events[0]["detail"] -def test_is_workspace_violation_recognizes_ssrf_block(): - """Internal/private URL block must be classified as a fatal workspace violation. - - Regression guard: the deny/allowlist filter messages were intentionally split - out of `_WORKSPACE_BLOCK_MARKERS` so the LLM can retry, but SSRF rejections - are a hard security boundary and must remain fatal. - """ +def test_is_ssrf_violation_recognizes_private_url_blocks(): + """SSRF rejections are classified separately from workspace boundaries.""" from nanobot.agent.runner import AgentRunner ssrf_msg = "Error: Command blocked by safety guard (internal/private URL detected)" - assert AgentRunner._is_workspace_violation(ssrf_msg) is True + assert AgentRunner._is_ssrf_violation(ssrf_msg) is True + assert AgentRunner._is_ssrf_violation( + "URL validation failed: Blocked: host resolves to private/internal address 192.168.1.2" + ) is True - # Sanity: deny/allowlist filter messages are deliberately *not* fatal. - assert AgentRunner._is_workspace_violation( + # Workspace-bound markers are NOT classified as SSRF. + assert AgentRunner._is_ssrf_violation( + "Error: Command blocked by safety guard (path outside working dir)" + ) is False + assert AgentRunner._is_ssrf_violation( + "Path /tmp/x is outside allowed directory /ws" + ) is False + # Deny / allowlist filter messages stay non-fatal too. + assert AgentRunner._is_ssrf_violation( "Error: Command blocked by deny pattern filter" ) is False - assert AgentRunner._is_workspace_violation( - "Error: Command blocked by allowlist filter (not in allowlist)" - ) is False + + +@pytest.mark.asyncio +async def test_runner_returns_non_retryable_hint_on_ssrf_violation(): + """SSRF stays blocked, but the runtime gives the LLM a final chance to recover.""" + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock() + provider.chat_with_retry = AsyncMock(side_effect=[ + LLMResponse( + content="curl-ing metadata", + tool_calls=[ToolCallRequest( + id="call_ssrf", + name="exec", + arguments={"command": "curl http://169.254.169.254"}, + )], + ), + LLMResponse( + content="I cannot access that private URL. Please share local files.", + tool_calls=[], + ), + ]) + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock(return_value=( + "Error: Command blocked by safety guard (internal/private URL detected)" + )) + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[], + tools=tools, + model="test-model", + max_iterations=3, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + )) + + assert provider.chat_with_retry.await_count == 2 + assert result.stop_reason == "completed" + assert result.error is None + assert result.final_content == "I cannot access that private URL. Please share local files." + assert result.tool_events and result.tool_events[0]["detail"].startswith("ssrf_violation:") + tool_messages = [m for m in result.messages if m.get("role") == "tool"] + assert tool_messages + assert "non-bypassable security boundary" in tool_messages[0]["content"] + assert "Do not retry" in tool_messages[0]["content"] + assert "tools.ssrfWhitelist" in tool_messages[0]["content"] + + +@pytest.mark.asyncio +async def test_runner_lets_llm_recover_from_shell_guard_path_outside(): + """Reporter scenario for #3599 / #3605 -- guard hit, agent recovers. + + The shell `_guard_command` heuristic fires on `2>/dev/null`-style + redirects and other shell idioms. Before v2 that abort'd the whole + turn (silent hang on Telegram per #3605); now the LLM gets the soft + error back and can finalize on the next iteration. + """ + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock() + captured_second_call: list[dict] = [] + + async def chat_with_retry(*, messages, **kwargs): + if provider.chat_with_retry.await_count == 1: + return LLMResponse( + content="trying noisy cleanup", + tool_calls=[ToolCallRequest( + id="call_blocked", + name="exec", + arguments={"command": "rm scratch.txt 2>/dev/null"}, + )], + ) + captured_second_call[:] = list(messages) + return LLMResponse(content="recovered final answer", tool_calls=[]) + + provider.chat_with_retry = AsyncMock(side_effect=chat_with_retry) + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock( + return_value="Error: Command blocked by safety guard (path outside working dir)" + ) + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[], + tools=tools, + model="test-model", + max_iterations=3, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + )) + + assert provider.chat_with_retry.await_count == 2, ( + "guard hit must NOT short-circuit the loop -- LLM should get a second turn" + ) + assert result.stop_reason != "tool_error" + assert result.error is None + assert result.final_content == "recovered final answer" + assert result.tool_events and result.tool_events[0]["status"] == "error" + # v2: detail keeps the breadcrumb but the runner did not raise. + assert "workspace_violation" in result.tool_events[0]["detail"] + + +@pytest.mark.asyncio +async def test_runner_throttles_repeated_workspace_bypass_attempts(): + """#3493 motivation: stop the LLM bypass loop without aborting the turn. + + LLM keeps switching tools (read_file -> exec cat -> python -c open(...)) + against the same outside path. After the soft retry budget is exhausted + the runner replaces the tool result with a hard "stop trying" message + so the model finally gives up and surfaces the boundary to the user. + """ + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + bypass_attempts = [ + ToolCallRequest( + id=f"a{i}", name="exec", + arguments={"command": f"cat /Users/x/Downloads/01.md # try {i}"}, + ) + for i in range(4) + ] + responses: list[LLMResponse] = [ + LLMResponse(content=f"try {i}", tool_calls=[bypass_attempts[i]]) + for i in range(4) + ] + responses.append(LLMResponse(content="ok telling user", tool_calls=[])) + + provider = MagicMock() + provider.chat_with_retry = AsyncMock(side_effect=responses) + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock( + return_value="Error: Command blocked by safety guard (path outside working dir)" + ) + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[], + tools=tools, + model="test-model", + max_iterations=10, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + )) + + # All 4 bypass attempts surface to the LLM (no fatal abort), and the + # runner finally completes once the LLM stops asking. + assert result.stop_reason != "tool_error" + assert result.error is None + assert result.final_content == "ok telling user" + # The third+ attempts must have been escalated -- look at the events. + escalated = [ + ev for ev in result.tool_events + if ev["status"] == "error" + and ev["detail"].startswith("workspace_violation_escalated:") + ] + assert escalated, ( + "expected at least one escalated workspace_violation event, got: " + f"{result.tool_events}" + ) @pytest.mark.asyncio @@ -470,7 +643,7 @@ def test_persist_tool_result_logs_cleanup_failures(monkeypatch, tmp_path): lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("busy")), ) monkeypatch.setattr( - "nanobot.utils.helpers.logger.warning", + "nanobot.utils.helpers.logger.exception", lambda message, *args: warnings.append(message.format(*args)), ) @@ -851,6 +1024,7 @@ async def test_runner_batches_read_only_tools_before_exclusive_work(): ToolCallRequest(id="rw1", name="write_a", arguments={}), ], {}, + {}, ) assert shared_events[0:2] == ["start:read_a", "start:read_b"] @@ -895,6 +1069,7 @@ async def test_runner_does_not_batch_exclusive_read_only_tools(): ToolCallRequest(id="ro2", name="read_b", arguments={}), ], {}, + {}, ) assert shared_events[0] == "start:read_a" @@ -1122,6 +1297,51 @@ async def test_streamed_flag_not_set_on_llm_error(tmp_path): "_streamed must not be set when stop_reason is error" +@pytest.mark.asyncio +async def test_ssrf_soft_block_can_finalize_after_streamed_tool_call(tmp_path): + from nanobot.agent.loop import AgentLoop + from nanobot.bus.events import InboundMessage + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + tool_call_resp = LLMResponse( + content="checking metadata", + tool_calls=[ToolCallRequest( + id="call_ssrf", + name="exec", + arguments={"command": "curl http://169.254.169.254/latest/meta-data/"}, + )], + usage={}, + ) + provider.chat_stream_with_retry = AsyncMock(side_effect=[ + tool_call_resp, + LLMResponse( + content="I cannot access private URLs. Please share the local file.", + tool_calls=[], + usage={}, + ), + ]) + + loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.tools.prepare_call = MagicMock(return_value=(None, {}, None)) + loop.tools.execute = AsyncMock(return_value=( + "Error: Command blocked by safety guard (internal/private URL detected)" + )) + + result = await loop._process_message( + InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="hi"), + on_stream=AsyncMock(), + on_stream_end=AsyncMock(), + ) + + assert result is not None + assert result.content == "I cannot access private URLs. Please share the local file." + assert result.metadata.get("_streamed") is True + + @pytest.mark.asyncio async def test_next_turn_after_llm_error_keeps_turn_boundary(tmp_path): from nanobot.agent.loop import AgentLoop diff --git a/tests/agent/test_runner_progress_deltas.py b/tests/agent/test_runner_progress_deltas.py new file mode 100644 index 000000000..13d5ea799 --- /dev/null +++ b/tests/agent/test_runner_progress_deltas.py @@ -0,0 +1,79 @@ +"""Tests for provider progress delta routing in the shared runner.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from nanobot.agent.runner import AgentRunner, AgentRunSpec +from nanobot.config.schema import AgentDefaults +from nanobot.providers.base import LLMResponse + +_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars + + +@pytest.mark.asyncio +async def test_runner_can_disable_provider_progress_delta_streaming(): + """AgentLoop disables token progress streaming for non-streaming channels.""" + provider = MagicMock() + provider.supports_progress_deltas = True + provider.chat_with_retry = AsyncMock( + return_value=LLMResponse(content="done", tool_calls=[], usage={}) + ) + provider.chat_stream_with_retry = AsyncMock() + tools = MagicMock() + tools.get_definitions.return_value = [] + progress_cb = AsyncMock() + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[ + {"role": "system", "content": "system"}, + {"role": "user", "content": "hi"}, + ], + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + progress_callback=progress_cb, + stream_progress_deltas=False, + )) + + assert result.final_content == "done" + provider.chat_with_retry.assert_awaited_once() + provider.chat_stream_with_retry.assert_not_awaited() + progress_cb.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_runner_streams_provider_progress_deltas_by_default(): + """Direct runner users keep the existing opt-in provider progress behavior.""" + provider = MagicMock() + provider.supports_progress_deltas = True + + async def chat_stream_with_retry(*, on_content_delta, **kwargs): + await on_content_delta("he") + await on_content_delta("llo") + return LLMResponse(content="hello", tool_calls=[], usage={}) + + provider.chat_stream_with_retry = chat_stream_with_retry + provider.chat_with_retry = AsyncMock() + tools = MagicMock() + tools.get_definitions.return_value = [] + progress_cb = AsyncMock() + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[ + {"role": "system", "content": "system"}, + {"role": "user", "content": "hi"}, + ], + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + progress_callback=progress_cb, + )) + + assert result.final_content == "hello" + assert [call.args[0] for call in progress_cb.await_args_list] == ["he", "llo"] + provider.chat_with_retry.assert_not_awaited() diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py index b80c774a1..75bc7713d 100644 --- a/tests/agent/test_session_manager_history.py +++ b/tests/agent/test_session_manager_history.py @@ -1,4 +1,4 @@ -from nanobot.session.manager import Session +from nanobot.session.manager import Session, SessionManager def _assert_no_orphans(history: list[dict]) -> None: @@ -31,6 +31,18 @@ def _tool_turn(prefix: str, idx: int) -> list[dict]: ] +def test_list_sessions_includes_metadata_title(tmp_path): + manager = SessionManager(tmp_path) + session = manager.get_or_create("websocket:chat-title") + session.metadata["title"] = "自动生成标题" + manager.save(session) + + rows = manager.list_sessions() + + assert rows[0]["key"] == "websocket:chat-title" + assert rows[0]["title"] == "自动生成标题" + + # --- Original regression test (from PR 2075) --- def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls(): diff --git a/tests/channels/test_email_channel.py b/tests/channels/test_email_channel.py index 98343522c..cb5aed45e 100644 --- a/tests/channels/test_email_channel.py +++ b/tests/channels/test_email_channel.py @@ -1,14 +1,13 @@ -from email.message import EmailMessage -from datetime import date -from pathlib import Path import imaplib +from datetime import date +from email.message import EmailMessage +from pathlib import Path import pytest from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.channels.email import EmailChannel -from nanobot.channels.email import EmailConfig +from nanobot.channels.email import EmailChannel, EmailConfig def _make_config(**overrides) -> EmailConfig: @@ -24,6 +23,7 @@ def _make_config(**overrides) -> EmailConfig: smtp_username="bot@example.com", smtp_password="secret", mark_seen=True, + allow_from=["*"], # Disable auth verification by default so existing tests are unaffected verify_dkim=False, verify_spf=False, @@ -707,8 +707,8 @@ def test_email_content_tagged_with_email_context(monkeypatch) -> None: def test_check_authentication_results_method() -> None: """Unit test for the _check_authentication_results static method.""" - from email.parser import BytesParser from email import policy + from email.parser import BytesParser # No Authentication-Results header msg_no_auth = EmailMessage() @@ -788,6 +788,32 @@ def _make_raw_email_with_attachment( return msg.as_bytes() +def test_fetch_new_messages_ignores_unauthorized_sender_before_attachments(monkeypatch) -> None: + raw = _make_raw_email_with_attachment(from_addr="blocked@example.com") + fake = _make_fake_imap(raw) + monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake) + + called = {"attachments": False} + + def _extract_attachments(*_args, **_kwargs): + called["attachments"] = True + return [] + + monkeypatch.setattr(EmailChannel, "_extract_attachments", _extract_attachments) + + cfg = _make_config( + allow_from=["allowed@example.com"], + allowed_attachment_types=["application/pdf"], + verify_dkim=False, + verify_spf=False, + ) + channel = EmailChannel(cfg, MessageBus()) + + assert channel._fetch_new_messages() == [] + assert called["attachments"] is False + assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")] + + def test_extract_attachments_saves_pdf(tmp_path, monkeypatch) -> None: """PDF attachment is saved to media dir and path returned in media list.""" monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path) diff --git a/tests/channels/test_feishu_reply.py b/tests/channels/test_feishu_reply.py index 430e5abea..cc7e21e5f 100644 --- a/tests/channels/test_feishu_reply.py +++ b/tests/channels/test_feishu_reply.py @@ -445,6 +445,58 @@ async def test_on_message_no_extra_api_call_when_no_parent_id() -> None: assert len(captured) == 1 +# --------------------------------------------------------------------------- +# Inbound media tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_on_message_audio_publishes_downloaded_path_and_transcription() -> None: + channel = _make_feishu_channel() + channel._processed_message_ids.clear() + captured = [] + + async def capture(msg): + captured.append(msg) + + channel.bus.publish_inbound = capture + channel._download_and_save_media = AsyncMock( + return_value=(r"C:\\Users\\dodre\\.nanobot\\media\\feishu\\voice.ogg", "[audio: voice.ogg]") + ) + channel.transcribe_audio = AsyncMock(return_value="hello from voice") + channel._add_reaction = AsyncMock(return_value=None) + + event = _make_feishu_event( + msg_type="audio", + content='{"file_key": "audio_key", "duration": 1000}', + message_id="om_audio", + ) + await channel._on_message(event) + + channel._download_and_save_media.assert_awaited_once_with( + "audio", {"file_key": "audio_key", "duration": 1000}, "om_audio" + ) + channel.transcribe_audio.assert_awaited_once_with(r"C:\\Users\\dodre\\.nanobot\\media\\feishu\\voice.ogg") + assert len(captured) == 1 + assert captured[0].media == [r"C:\\Users\\dodre\\.nanobot\\media\\feishu\\voice.ogg"] + assert captured[0].content == "[transcription: hello from voice]" + + +@pytest.mark.asyncio +async def test_download_and_save_media_returns_absolute_path_in_content(monkeypatch, tmp_path) -> None: + channel = _make_feishu_channel() + monkeypatch.setattr(feishu, "get_media_dir", lambda _channel: tmp_path) + channel._download_file_sync = MagicMock(return_value=(b"voice-bytes", None)) + + file_path, content_text = await channel._download_and_save_media( + "audio", {"file_key": "voice_key"}, "om_audio" + ) + + assert file_path == str(tmp_path / "voice_key.ogg") + assert (tmp_path / "voice_key.ogg").read_bytes() == b"voice-bytes" + assert content_text == f"[audio: {file_path}]" + + # --------------------------------------------------------------------------- # Session key derivation tests # --------------------------------------------------------------------------- @@ -754,3 +806,26 @@ def test_on_background_task_done_removes_from_set() -> None: loop.close() assert task not in channel._background_tasks + + +@pytest.mark.asyncio +async def test_on_message_ignores_unauthorized_sender_before_side_effects() -> None: + channel = _make_feishu_channel(group_policy="open") + channel.config.allow_from = ["ou_allowed"] + channel._add_reaction = AsyncMock() + channel._download_and_save_media = AsyncMock(return_value=("/tmp/audio.ogg", "[audio]")) + channel.transcribe_audio = AsyncMock(return_value="transcript") + channel._handle_message = AsyncMock() + + event = _make_feishu_event( + msg_type="audio", + content='{"file_key": "file_1"}', + sender_open_id="ou_blocked", + ) + + await channel._on_message(event) + + channel._add_reaction.assert_not_awaited() + channel._download_and_save_media.assert_not_awaited() + channel.transcribe_audio.assert_not_awaited() + channel._handle_message.assert_not_awaited() diff --git a/tests/channels/test_qq_media.py b/tests/channels/test_qq_media.py index 80a5ad20e..e2de72f28 100644 --- a/tests/channels/test_qq_media.py +++ b/tests/channels/test_qq_media.py @@ -1,7 +1,7 @@ """Tests for QQ channel media support: helpers, send, inbound, and upload.""" from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest @@ -182,6 +182,35 @@ async def test_send_media_failure_falls_back_to_text() -> None: assert "bad.png" in failure_calls[0]["content"] +@pytest.mark.asyncio +async def test_on_message_ignores_unauthorized_sender_before_attachments_and_ack() -> None: + channel = QQChannel( + QQConfig( + app_id="app", + secret="secret", + allow_from=["allowed-user"], + ack_message="Processing...", + ), + MessageBus(), + ) + channel._client = _FakeClient() + channel._handle_attachments = AsyncMock(return_value=(["/tmp/a.png"], ["file"], [])) + channel._handle_message = AsyncMock() + + data = SimpleNamespace( + id="msg-blocked", + content="hello", + author=SimpleNamespace(user_openid="blocked-user"), + attachments=[SimpleNamespace(filename="a.png")], + ) + + await channel._on_message(data, is_group=False) + + channel._handle_attachments.assert_not_awaited() + channel._handle_message.assert_not_awaited() + assert channel._client.api.c2c_calls == [] + + # ── _on_message() exception handling ──────────────────────────────── diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py index 803415dfd..95865096c 100644 --- a/tests/channels/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -306,17 +306,19 @@ async def test_on_error_logs_network_issues_as_warning(monkeypatch) -> None: recorded: list[tuple[str, str]] = [] monkeypatch.setattr( - "nanobot.channels.telegram.logger.warning", + channel.logger, + "warning", lambda message, error: recorded.append(("warning", message.format(error))), ) monkeypatch.setattr( - "nanobot.channels.telegram.logger.error", + channel.logger, + "error", lambda message, error: recorded.append(("error", message.format(error))), ) await channel._on_error(object(), SimpleNamespace(error=NetworkError("proxy disconnected"))) - assert recorded == [("warning", "Telegram network issue: proxy disconnected")] + assert recorded == [("warning", "network issue: proxy disconnected")] @pytest.mark.asyncio @@ -330,13 +332,14 @@ async def test_on_error_summarizes_empty_network_error(monkeypatch) -> None: recorded: list[tuple[str, str]] = [] monkeypatch.setattr( - "nanobot.channels.telegram.logger.warning", + channel.logger, + "warning", lambda message, error: recorded.append(("warning", message.format(error))), ) await channel._on_error(object(), SimpleNamespace(error=NetworkError(""))) - assert recorded == [("warning", "Telegram network issue: NetworkError")] + assert recorded == [("warning", "network issue: NetworkError")] @pytest.mark.asyncio @@ -348,17 +351,19 @@ async def test_on_error_keeps_non_network_exceptions_as_error(monkeypatch) -> No recorded: list[tuple[str, str]] = [] monkeypatch.setattr( - "nanobot.channels.telegram.logger.warning", + channel.logger, + "warning", lambda message, error: recorded.append(("warning", message.format(error))), ) monkeypatch.setattr( - "nanobot.channels.telegram.logger.error", + channel.logger, + "error", lambda message, error: recorded.append(("error", message.format(error))), ) await channel._on_error(object(), SimpleNamespace(error=RuntimeError("boom"))) - assert recorded == [("error", "Telegram error: boom")] + assert recorded == [("error", "error: boom")] @pytest.mark.asyncio @@ -1309,6 +1314,58 @@ async def test_on_help_includes_restart_command() -> None: assert "/dream-restore" in help_text +@pytest.mark.asyncio +async def test_on_start_ignores_unauthorized_user_silently() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"), + MessageBus(), + ) + update = _make_telegram_update(text="/start", chat_type="private") + update.message.reply_text = AsyncMock() + + await channel._on_start(update, None) + + update.message.reply_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_help_ignores_unauthorized_user_silently() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"), + MessageBus(), + ) + update = _make_telegram_update(text="/help", chat_type="private") + update.message.reply_text = AsyncMock() + + await channel._on_help(update, None) + + update.message.reply_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_message_ignores_unauthorized_user_before_side_effects() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + started_typing: list[str] = [] + handled: list[dict] = [] + channel._start_typing = lambda chat_id: started_typing.append(chat_id) + channel._add_reaction = AsyncMock(return_value=None) + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle + + await channel._on_message(_make_telegram_update(text="hello", chat_type="private"), None) + + assert started_typing == [] + channel._add_reaction.assert_not_awaited() + assert handled == [] + + @pytest.mark.asyncio async def test_on_message_location_content() -> None: """Location messages are forwarded as [location: lat, lon] content.""" @@ -1750,3 +1807,32 @@ async def test_send_uses_native_keyboard_when_flag_on() -> None: sent = channel._app.bot.sent_messages[0] assert isinstance(sent.get("reply_markup"), InlineKeyboardMarkup) assert "[Yes]" not in sent["text"] # native keyboard owns the rendering + + +@pytest.mark.asyncio +async def test_callback_query_ignores_unauthorized_user_before_side_effects() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], inline_keyboards=True), + MessageBus(), + ) + channel._handle_message = AsyncMock() + + query = SimpleNamespace( + id="cb_1", + data="Yes", + answer=AsyncMock(), + message=SimpleNamespace( + chat_id=123, + edit_reply_markup=AsyncMock(), + ), + ) + update = SimpleNamespace( + callback_query=query, + effective_user=SimpleNamespace(id=12345, username="alice", first_name="Alice"), + ) + + await channel._on_callback_query(update, None) + + query.answer.assert_not_awaited() + query.message.edit_reply_markup.assert_not_awaited() + channel._handle_message.assert_not_awaited() diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index b5dc830b4..e757551f2 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -167,6 +167,40 @@ def test_issue_route_secret_matches_empty_secret() -> None: assert _issue_route_secret_matches(Headers([("Authorization", "Bearer anything")]), "") is True +@pytest.mark.asyncio +async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) -> None: + channel = _ch(bus) + conn = MagicMock() + conn.remote_address = ("127.0.0.1", 50123) + + await channel._dispatch_envelope( + conn, + "webui-client", + {"type": "message", "chat_id": "chat-1", "content": "hello", "webui": True}, + ) + + msg = bus.publish_inbound.await_args.args[0] + assert msg.channel == "websocket" + assert msg.chat_id == "chat-1" + assert msg.metadata["webui"] is True + assert msg.metadata["_wants_stream"] is True + + +@pytest.mark.asyncio +async def test_plain_websocket_message_does_not_mark_webui(bus: MagicMock) -> None: + channel = _ch(bus) + conn = MagicMock() + + await channel._dispatch_envelope( + conn, + "custom-client", + {"type": "message", "chat_id": "chat-1", "content": "hello"}, + ) + + msg = bus.publish_inbound.await_args.args[0] + assert "webui" not in msg.metadata + + @pytest.mark.asyncio async def test_send_delivers_json_message_with_media_and_reply() -> None: bus = MagicMock() @@ -287,6 +321,44 @@ async def test_send_delta_emits_delta_and_stream_end() -> None: assert second["stream_id"] == "sid" +@pytest.mark.asyncio +async def test_send_turn_end_emits_turn_end_event() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + await channel.send(OutboundMessage( + channel="websocket", + chat_id="chat-1", + content="", + metadata={"_turn_end": True}, + )) + + mock_ws.send.assert_awaited_once() + body = json.loads(mock_ws.send.await_args.args[0]) + assert body == {"event": "turn_end", "chat_id": "chat-1"} + + +@pytest.mark.asyncio +async def test_send_session_updated_emits_session_updated_event() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + await channel.send(OutboundMessage( + channel="websocket", + chat_id="chat-1", + content="", + metadata={"_session_updated": True}, + )) + + mock_ws.send.assert_awaited_once() + body = json.loads(mock_ws.send.await_args.args[0]) + assert body == {"event": "session_updated", "chat_id": "chat-1"} + + @pytest.mark.asyncio async def test_send_non_connection_closed_exception_is_raised() -> None: bus = MagicMock() @@ -491,6 +563,34 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( await server_task +@pytest.mark.asyncio +async def test_commands_api_returns_slash_command_metadata(bus: MagicMock) -> None: + port = 29892 + channel = _ch(bus, port=port) + channel._api_tokens["tok"] = time.monotonic() + 300 + + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + + try: + denied = await _http_get(f"http://127.0.0.1:{port}/api/commands") + assert denied.status_code == 401 + + response = await _http_get( + f"http://127.0.0.1:{port}/api/commands", + headers={"Authorization": "Bearer tok"}, + ) + assert response.status_code == 200 + body = response.json() + commands = {row["command"]: row for row in body["commands"]} + assert commands["/stop"]["title"] == "Stop current task" + assert commands["/history"]["arg_hint"] == "[n]" + assert all("description" in row for row in body["commands"]) + finally: + await channel.stop() + await server_task + + def test_settings_payload_normalizes_camel_case_provider( bus: MagicMock, monkeypatch, @@ -545,6 +645,16 @@ async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMoc end = json.loads(await client.recv()) assert end["event"] == "stream_end" assert end["stream_id"] == "s1" + + await channel.send(OutboundMessage( + channel="websocket", + chat_id=chat_id, + content="", + metadata={"_turn_end": True}, + )) + + turn_end = json.loads(await client.recv()) + assert turn_end == {"event": "turn_end", "chat_id": chat_id} finally: await channel.stop() await server_task diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 51fd50f4a..40ba19288 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -379,3 +379,111 @@ async def test_api_token_pool_purges_expired(bus: MagicMock, tmp_path: Path) -> headers = {"Authorization": "Bearer live"} assert channel._check_api_token(_LiveReq()) is True + + +class _FakeConn: + """Minimal connection stub with a configurable remote_address.""" + + def __init__(self, remote_address: tuple[str, int]): + self.remote_address = remote_address + + def respond(self, status: int, body: str) -> Any: + from websockets.http11 import Response + + return Response(status=status, body=body.encode()) + + +class _FakeReq: + """Minimal request stub with configurable headers.""" + + def __init__(self, headers: dict[str, str] | None = None): + self.headers = headers or {} + + +_REMOTE = _FakeConn(("192.168.1.5", 12345)) +_LOCAL = _FakeConn(("127.0.0.1", 12345)) +_NO_HEADERS = _FakeReq() + + +def test_wildcard_host_without_auth_raises_on_startup(bus: MagicMock) -> None: + import pytest + from pydantic_core import ValidationError + + with pytest.raises(ValidationError, match="token"): + _ch(bus, host="0.0.0.0") + + +def test_wildcard_host_with_token_is_valid(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0", token="my-token") + assert channel.config.host == "0.0.0.0" + + +def test_wildcard_host_with_secret_is_valid(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret") + assert channel.config.host == "0.0.0.0" + + +def test_wildcard_ipv6_without_auth_raises(bus: MagicMock) -> None: + import pytest + from pydantic_core import ValidationError + + with pytest.raises(ValidationError, match="token"): + _ch(bus, host="::") + + +def test_wildcard_ipv6_with_secret_is_valid(bus: MagicMock) -> None: + channel = _ch(bus, host="::", tokenIssueSecret="s3cret") + resp = channel._handle_webui_bootstrap( + _REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"}) + ) + assert resp.status_code == 200 + + +def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None: + """When only token (not token_issue_secret) is set, bootstrap accepts it.""" + channel = _ch(bus, host="0.0.0.0", token="static-tok") + resp = channel._handle_webui_bootstrap( + _REMOTE, _FakeReq({"Authorization": "Bearer static-tok"}) + ) + assert resp.status_code == 200 + body = json.loads(resp.body) + assert body["token"].startswith("nbwt_") + + +def test_localhost_without_auth_is_valid(bus: MagicMock) -> None: + channel = _ch(bus, host="127.0.0.1") + resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS) + assert resp.status_code == 200 + + +def test_bootstrap_rejects_wrong_secret(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="correct") + resp = channel._handle_webui_bootstrap( + _REMOTE, _FakeReq({"Authorization": "Bearer wrong"}) + ) + assert resp.status_code == 401 + + +def test_bootstrap_accepts_remote_with_valid_secret(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret") + resp = channel._handle_webui_bootstrap( + _REMOTE, _FakeReq({"Authorization": "Bearer s3cret"}) + ) + assert resp.status_code == 200 + body = json.loads(resp.body) + assert body["token"].startswith("nbwt_") + + +def test_bootstrap_accepts_x_nanobot_auth_header(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret") + resp = channel._handle_webui_bootstrap( + _REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"}) + ) + assert resp.status_code == 200 + + +def test_bootstrap_secret_also_enforced_on_localhost(bus: MagicMock) -> None: + """When secret is set, even localhost must provide it (reverse-proxy safety).""" + channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret") + resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS) + assert resp.status_code == 401 diff --git a/tests/channels/test_wecom_channel.py b/tests/channels/test_wecom_channel.py index a8ed3c0e9..7cb61ab82 100644 --- a/tests/channels/test_wecom_channel.py +++ b/tests/channels/test_wecom_channel.py @@ -3,7 +3,6 @@ import os import tempfile from pathlib import Path -from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -451,6 +450,39 @@ async def test_process_text_message() -> None: assert msg.metadata["msg_type"] == "text" +@pytest.mark.asyncio +async def test_enter_chat_ignores_unauthorized_user_before_welcome() -> None: + channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["allowed"]), MessageBus()) + client = _FakeWeComClient() + channel._client = client + channel.config.welcome_message = "hello" + + await channel._on_enter_chat(_FakeFrame(body={"chatid": "blocked"})) + + client.reply_welcome.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_process_message_ignores_unauthorized_sender_before_download() -> None: + channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["allowed"]), MessageBus()) + client = _FakeWeComClient() + channel._client = client + channel._handle_message = AsyncMock() + + frame = _FakeFrame(body={ + "msgid": "msg_blocked", + "chatid": "chat1", + "from": {"userid": "blocked"}, + "image": {"url": "https://example.com/img.png", "aeskey": "key123"}, + }) + + await channel._process_message(frame, "image") + + client.download_file.assert_not_awaited() + channel._handle_message.assert_not_awaited() + assert channel.bus.inbound_size == 0 + + @pytest.mark.asyncio async def test_process_image_message() -> None: """Image message: download success → media_paths non-empty.""" diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 2b455fca6..1ca814561 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -5,8 +5,8 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock -import pytest import httpx +import pytest import nanobot.channels.weixin as weixin_mod from nanobot.bus.queue import MessageBus @@ -15,10 +15,10 @@ from nanobot.channels.weixin import ( ITEM_TEXT, MESSAGE_TYPE_BOT, WEIXIN_CHANNEL_VERSION, - _decrypt_aes_ecb, - _encrypt_aes_ecb, WeixinChannel, WeixinConfig, + _decrypt_aes_ecb, + _encrypt_aes_ecb, ) @@ -128,6 +128,34 @@ async def test_process_message_caches_context_token_and_send_uses_it() -> None: channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-2") +@pytest.mark.asyncio +async def test_process_message_ignores_unauthorized_sender_before_side_effects(tmp_path) -> None: + bus = MessageBus() + channel = WeixinChannel( + WeixinConfig(enabled=True, allow_from=["allowed-user"], state_dir=str(tmp_path)), + bus, + ) + channel._download_media_item = AsyncMock(return_value="/tmp/test.jpg") + channel._start_typing = AsyncMock() + + await channel._process_message( + { + "message_type": 1, + "message_id": "m-unauthorized", + "from_user_id": "blocked-user", + "context_token": "ctx-blocked", + "item_list": [ + {"type": ITEM_IMAGE, "image_item": {"media": {"encrypt_query_param": "x"}}}, + ], + } + ) + + assert channel._context_tokens == {} + channel._download_media_item.assert_not_awaited() + channel._start_typing.assert_not_awaited() + assert bus.inbound_size == 0 + + @pytest.mark.asyncio async def test_process_message_persists_context_token_to_state_file(tmp_path) -> None: bus = MessageBus() @@ -291,21 +319,22 @@ async def test_process_message_does_not_fallback_when_top_level_media_exists_but @pytest.mark.asyncio -async def test_send_without_context_token_does_not_send_text() -> None: +async def test_send_without_context_token_raises() -> None: channel, _bus = _make_channel() channel._client = object() channel._token = "token" channel._send_text = AsyncMock() - await channel.send( - type("Msg", (), {"chat_id": "unknown-user", "content": "pong", "media": [], "metadata": {}})() - ) + with pytest.raises(RuntimeError, match="context_token missing"): + await channel.send( + type("Msg", (), {"chat_id": "unknown-user", "content": "pong", "media": [], "metadata": {}})() + ) channel._send_text.assert_not_awaited() @pytest.mark.asyncio -async def test_send_does_not_send_when_session_is_paused() -> None: +async def test_send_raises_when_session_is_paused() -> None: channel, _bus = _make_channel() channel._client = object() channel._token = "token" @@ -313,9 +342,10 @@ async def test_send_does_not_send_when_session_is_paused() -> None: channel._pause_session(60) channel._send_text = AsyncMock() - await channel.send( - type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})() - ) + with pytest.raises(RuntimeError, match="session paused"): + await channel.send( + type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})() + ) channel._send_text.assert_not_awaited() @@ -1185,3 +1215,38 @@ async def test_send_media_network_error_does_not_double_api_calls() -> None: # _send_media_file called once, _send_text never called channel._send_media_file.assert_awaited_once() channel._send_text.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# Tests for _send_text raising on API errors (previously silently swallowed) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_send_text_raises_on_api_error() -> None: + """_send_text must raise RuntimeError when the API returns a non-zero errcode, + matching _send_media_file behavior. This ensures ChannelManager can retry.""" + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._api_post = AsyncMock( + return_value={"errcode": -14, "errmsg": "session expired"} + ) + + with pytest.raises(RuntimeError, match="WeChat send text error.*-14"): + await channel._send_text("wx-user", "hello", "ctx-expired") + + channel._api_post.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_send_text_succeeds_on_zero_errcode() -> None: + """_send_text must NOT raise when errcode is 0.""" + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._api_post = AsyncMock(return_value={"errcode": 0}) + + await channel._send_text("wx-user", "hello", "ctx-ok") + + channel._api_post.assert_awaited_once() diff --git a/tests/channels/test_whatsapp_channel.py b/tests/channels/test_whatsapp_channel.py index b61033677..6229723a5 100644 --- a/tests/channels/test_whatsapp_channel.py +++ b/tests/channels/test_whatsapp_channel.py @@ -116,7 +116,7 @@ async def test_send_when_disconnected_is_noop(): @pytest.mark.asyncio async def test_group_policy_mention_skips_unmentioned_group_message(): - ch = WhatsAppChannel({"enabled": True, "groupPolicy": "mention"}, MagicMock()) + ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock()) ch._handle_message = AsyncMock() await ch._handle_bridge_message( @@ -139,7 +139,7 @@ async def test_group_policy_mention_skips_unmentioned_group_message(): @pytest.mark.asyncio async def test_group_policy_mention_accepts_mentioned_group_message(): - ch = WhatsAppChannel({"enabled": True, "groupPolicy": "mention"}, MagicMock()) + ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock()) ch._handle_message = AsyncMock() await ch._handle_bridge_message( @@ -166,7 +166,7 @@ async def test_group_policy_mention_accepts_mentioned_group_message(): @pytest.mark.asyncio async def test_sender_id_prefers_phone_jid_over_lid(): """sender_id should resolve to phone number when @s.whatsapp.net JID is present.""" - ch = WhatsAppChannel({"enabled": True}, MagicMock()) + ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock()) ch._handle_message = AsyncMock() await ch._handle_bridge_message( @@ -187,7 +187,7 @@ async def test_sender_id_prefers_phone_jid_over_lid(): @pytest.mark.asyncio async def test_lid_to_phone_cache_resolves_lid_only_messages(): """When only LID is present, a cached LID→phone mapping should be used.""" - ch = WhatsAppChannel({"enabled": True}, MagicMock()) + ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock()) ch._handle_message = AsyncMock() # First message: both phone and LID → builds cache @@ -220,7 +220,7 @@ async def test_lid_to_phone_cache_resolves_lid_only_messages(): @pytest.mark.asyncio async def test_voice_message_transcription_uses_media_path(): """Voice messages are transcribed when media path is available.""" - ch = WhatsAppChannel({"enabled": True}, MagicMock()) + ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock()) ch.transcription_provider = "openai" ch.transcription_api_key = "sk-test" ch._handle_message = AsyncMock() @@ -243,10 +243,32 @@ async def test_voice_message_transcription_uses_media_path(): assert kwargs["content"].startswith("Hello world") +@pytest.mark.asyncio +async def test_unauthorized_voice_message_does_not_transcribe() -> None: + ch = WhatsAppChannel({"enabled": True, "allowFrom": ["allowed"]}, MagicMock()) + ch._handle_message = AsyncMock() + ch.transcribe_audio = AsyncMock(return_value="Hello world") + + await ch._handle_bridge_message( + json.dumps({ + "type": "message", + "id": "v-blocked", + "sender": "blocked@s.whatsapp.net", + "pn": "", + "content": "[Voice Message]", + "timestamp": 1, + "media": ["/tmp/voice.ogg"], + }) + ) + + ch.transcribe_audio.assert_not_awaited() + ch._handle_message.assert_not_awaited() + + @pytest.mark.asyncio async def test_voice_message_no_media_shows_not_available(): """Voice messages without media produce a fallback placeholder.""" - ch = WhatsAppChannel({"enabled": True}, MagicMock()) + ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock()) ch._handle_message = AsyncMock() await ch._handle_bridge_message( diff --git a/tests/cli/test_interactive_retry_wait.py b/tests/cli/test_interactive_retry_wait.py new file mode 100644 index 000000000..5cc217c56 --- /dev/null +++ b/tests/cli/test_interactive_retry_wait.py @@ -0,0 +1,31 @@ +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from nanobot.cli import commands + + +@pytest.mark.asyncio +async def test_interactive_retry_wait_is_rendered_as_progress_even_when_progress_disabled(): + """Provider retry waits should not fall through as assistant responses.""" + calls: list[tuple[str, object | None]] = [] + thinking = None + channels_config = SimpleNamespace(send_progress=False, send_tool_hints=False) + msg = SimpleNamespace( + content="Model request failed, retry in 2s (attempt 1).", + metadata={"_retry_wait": True}, + ) + + async def fake_print(text: str, active_thinking: object | None) -> None: + calls.append((text, active_thinking)) + + with patch("nanobot.cli.commands._print_interactive_progress_line", side_effect=fake_print): + handled = await commands._maybe_print_interactive_progress( + msg, + thinking, + channels_config, + ) + + assert handled is True + assert calls == [("Model request failed, retry in 2s (attempt 1).", thinking)] diff --git a/tests/cron/test_cron_persistence.py b/tests/cron/test_cron_persistence.py new file mode 100644 index 000000000..4732f61e0 --- /dev/null +++ b/tests/cron/test_cron_persistence.py @@ -0,0 +1,166 @@ +"""Persistence tests for ``nanobot.cron.service.CronService``. + +These tests target the specific failure mode where a corrupt or partially +written ``jobs.json`` would silently turn into an empty job list on the next +start, deleting every scheduled job. See ``fix(cron): atomic write for +jobs.json + don't silently overwrite corrupt store``. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from nanobot.cron.service import CronService +from nanobot.cron.types import CronSchedule + + +def _seeded_store(tmp_path: Path) -> tuple[CronService, Path]: + """Build a service with one persisted job on disk and return both the + service and the resolved store path. Adds the job via the action log + (the path used when the service is not running) and then triggers a + merge so ``jobs.json`` is written, mirroring the persisted on-disk + state seen in production.""" + store_path = tmp_path / "cron" / "jobs.json" + service = CronService(store_path) + service.add_job( + name="Daily Loving Message", + schedule=CronSchedule(kind="cron", expr="0 10 * * *", tz="Asia/Kuwait"), + message="hello", + ) + # add_job appended to action.jsonl; flush to jobs.json by toggling + # ``_running`` long enough for ``_merge_action`` to do its rewrite. + service._running = True + try: + service._load_store() + finally: + service._running = False + assert store_path.exists() + return service, store_path + + +def test_save_store_is_atomic(tmp_path: Path) -> None: + """``_save_store`` must use temp-file + rename so an interrupted write + cannot leave the destination truncated or invalid.""" + service, store_path = _seeded_store(tmp_path) + + # Simulate an arbitrary save and confirm the result parses cleanly and + # no orphan ``.tmp`` is left behind. + service._save_store() + data = json.loads(store_path.read_text(encoding="utf-8")) + assert len(data["jobs"]) == 1 + + tmp_files = list(store_path.parent.glob("*.tmp")) + assert tmp_files == [], f"unexpected temp files left behind: {tmp_files}" + + +def test_save_store_failure_does_not_corrupt_existing_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """If writing the temp file blows up partway through, the previous + ``jobs.json`` must remain readable. This is the regression we are + actually fixing: pre-fix, ``write_text`` would truncate the destination + in place and leave it corrupt.""" + service, store_path = _seeded_store(tmp_path) + original = store_path.read_bytes() + + # Inject a failure inside the temp-file write. ``os.replace`` should + # never run; the destination must keep its previous content. + real_open = open + + def boom(path, *args, **kwargs): # type: ignore[no-untyped-def] + if str(path).endswith(".tmp"): + raise OSError("simulated disk full") + return real_open(path, *args, **kwargs) + + monkeypatch.setattr("builtins.open", boom) + + with pytest.raises(OSError, match="simulated disk full"): + service._save_store() + + assert store_path.read_bytes() == original + + +def test_load_jobs_preserves_corrupt_store_and_returns_none( + tmp_path: Path, +) -> None: + """A corrupt ``jobs.json`` must not be silently treated as an empty + list. The loader returns ``None`` and the corrupt file is moved aside + with a ``.corrupt-`` suffix so an operator can recover it.""" + store_path = tmp_path / "cron" / "jobs.json" + store_path.parent.mkdir(parents=True) + store_path.write_text("{not valid json", encoding="utf-8") + + service = CronService(store_path) + assert service._load_jobs() is None + + # Original path is gone; a ``.corrupt-`` backup exists alongside it. + assert not store_path.exists() + backups = list(store_path.parent.glob("jobs.json.corrupt-*")) + assert len(backups) == 1 + assert backups[0].read_text(encoding="utf-8") == "{not valid json" + + +def test_start_refuses_to_overwrite_corrupt_store(tmp_path: Path) -> None: + """``start`` must abort instead of running ``_save_store`` against an + empty in-memory state when the on-disk store is corrupt. Otherwise the + next save would overwrite the (recoverable) corrupt file with an empty + job list and the user's jobs would be unrecoverable.""" + store_path = tmp_path / "cron" / "jobs.json" + store_path.parent.mkdir(parents=True) + store_path.write_text("{still not json", encoding="utf-8") + + service = CronService(store_path) + import asyncio + + with pytest.raises(RuntimeError, match="corrupt"): + asyncio.run(service.start()) + + # Service is left in a stopped state so the operator notices. + assert service._running is False + + # And the corrupt file is still recoverable from the .corrupt- copy. + backups = list(store_path.parent.glob("jobs.json.corrupt-*")) + assert len(backups) == 1 + + +def test_load_store_falls_back_to_in_memory_on_corruption_after_start( + tmp_path: Path, +) -> None: + """If the store file becomes corrupt *after* a successful start (e.g. a + rclone-mounted Drive returns a partial read), the service must keep + using its existing in-memory snapshot instead of dropping every job.""" + service, store_path = _seeded_store(tmp_path) + # Force load so ``self._store`` is populated. + service._load_store() + snapshot = service._store + assert snapshot is not None and len(snapshot.jobs) == 1 + + # Now corrupt the file on disk. + store_path.write_text("\x00garbage\x00", encoding="utf-8") + + # Subsequent reload returns the in-memory snapshot, not None or empty. + result = service._load_store() + assert result is snapshot + assert len(result.jobs) == 1 + assert result.jobs[0].name == "Daily Loving Message" + + +def test_full_round_trip_survives_repeated_save_load(tmp_path: Path) -> None: + """Sanity check: jobs survive add → save → reload across fresh + ``CronService`` instances pointing at the same store.""" + store_path = tmp_path / "cron" / "jobs.json" + + s1 = CronService(store_path) + s1.add_job( + name="Daily Loving Message", + schedule=CronSchedule(kind="cron", expr="0 10 * * *", tz="Asia/Kuwait"), + message="hello", + ) + + s2 = CronService(store_path) + s2._load_store() + assert s2._store is not None + assert [j.name for j in s2._store.jobs] == ["Daily Loving Message"] diff --git a/tests/cron/test_cron_service.py b/tests/cron/test_cron_service.py index 1f000dbd7..fa304e06e 100644 --- a/tests/cron/test_cron_service.py +++ b/tests/cron/test_cron_service.py @@ -228,8 +228,9 @@ async def test_running_service_honors_external_disable(tmp_path) -> None: ) await service.start() try: - # Wait slightly to ensure file mtime is definitively different - await asyncio.sleep(0.05) + # Disable before yielding back to the event loop. On slower Windows CI + # a short sleep here can overrun the 200ms schedule and let the job fire + # before the external update is written. external = CronService(store_path) updated = external.enable_job(job.id, enabled=False) assert updated is not None @@ -552,7 +553,7 @@ def test_update_job_offline_writes_action(tmp_path) -> None: action_path = tmp_path / "cron" / "action.jsonl" assert action_path.exists() - lines = [l for l in action_path.read_text().strip().split("\n") if l] + lines = [line for line in action_path.read_text().strip().split("\n") if line] last = json.loads(lines[-1]) assert last["action"] == "update" assert last["params"]["name"] == "updated-offline" diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index a3b624171..94455fd40 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -620,7 +620,8 @@ def _tool_call(call_id: str) -> dict: } -def test_deepseek_thinking_drops_tool_history_missing_reasoning_content() -> None: +def test_deepseek_thinking_backfills_missing_reasoning_content_on_tool_history() -> None: + """Backfill reasoning_content="" instead of dropping the turn (#3554, #3584).""" kwargs = _deepseek_kwargs([ {"role": "system", "content": "system"}, {"role": "user", "content": "can we use wechat?"}, @@ -629,10 +630,12 @@ def test_deepseek_thinking_drops_tool_history_missing_reasoning_content() -> Non {"role": "user", "content": "continue"}, ]) - assert kwargs["messages"] == [ - {"role": "system", "content": "system"}, - {"role": "user", "content": "continue"}, + assert [m["role"] for m in kwargs["messages"]] == [ + "system", "user", "assistant", "tool", "user", ] + assistant = kwargs["messages"][2] + assert assistant["reasoning_content"] == "" + assert assistant["tool_calls"][0]["function"]["name"] == "my" def test_deepseek_thinking_keeps_tool_history_with_reasoning_content() -> None: @@ -654,20 +657,6 @@ def test_deepseek_thinking_keeps_tool_history_with_reasoning_content() -> None: assert kwargs["messages"][2]["role"] == "tool" -def test_deepseek_thinking_drops_current_bad_tool_turn_without_followup_user() -> None: - kwargs = _deepseek_kwargs([ - {"role": "system", "content": "system"}, - {"role": "user", "content": "can we use wechat?"}, - {"role": "assistant", "content": "", "tool_calls": [_tool_call("call_bad")]}, - {"role": "tool", "tool_call_id": "call_bad", "name": "my", "content": "channels"}, - ]) - - assert kwargs["messages"] == [ - {"role": "system", "content": "system"}, - {"role": "user", "content": "can we use wechat?"}, - ] - - def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -> None: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): provider = OpenAICompatProvider() @@ -937,8 +926,8 @@ def test_backfill_does_not_touch_messages_when_thinking_explicitly_off() -> None assert "reasoning_content" not in msg -def test_deepseek_v4_drops_incomplete_reasoning_history_when_effort_implicit() -> None: - """DeepSeek-V4 may default to thinking, so incomplete legacy history is trimmed.""" +def test_deepseek_v4_backfills_incomplete_reasoning_history_when_effort_implicit() -> None: + """DeepSeek-V4 reasons natively: backfill even without explicit reasoning_effort.""" spec = find_by_name("deepseek") with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): p = OpenAICompatProvider(api_key="k", default_model="deepseek-v4-pro", spec=spec) @@ -958,12 +947,16 @@ def test_deepseek_v4_drops_incomplete_reasoning_history_when_effort_implicit() - reasoning_effort=None, tool_choice=None, ) - assert [msg["role"] for msg in kw["messages"]] == ["system", "user"] + assert [msg["role"] for msg in kw["messages"]] == [ + "system", "user", "assistant", "tool", "user", + ] + assert kw["messages"][2]["reasoning_content"] == "" assert kw["messages"][-1]["content"] == "thanks" def test_deepseek_chat_keeps_tool_history_when_effort_implicit() -> None: - """Implicit cleanup must not trim non-thinking DeepSeek chat models.""" + """Non-thinking deepseek-chat must keep history untouched and must NOT + receive backfilled reasoning_content (#3554, #3584).""" spec = find_by_name("deepseek") with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): p = OpenAICompatProvider(api_key="k", default_model="deepseek-chat", spec=spec) @@ -985,6 +978,7 @@ def test_deepseek_chat_keeps_tool_history_when_effort_implicit() -> None: roles = [msg["role"] for msg in kw["messages"]] assert roles == ["user", "assistant", "tool", "user"] assert kw["messages"][1]["tool_calls"] + assert "reasoning_content" not in kw["messages"][1] def test_deepseek_coerces_list_content_to_string() -> None: diff --git a/tests/test_msteams.py b/tests/test_msteams.py index 0671f9f58..fd71018b1 100644 --- a/tests/test_msteams.py +++ b/tests/test_msteams.py @@ -835,7 +835,7 @@ async def test_start_logs_install_hint_when_pyjwt_missing(make_channel, monkeypa ch = make_channel() errors = [] monkeypatch.setattr(msteams_module, "MSTEAMS_AVAILABLE", False) - monkeypatch.setattr(msteams_module.logger, "error", lambda message, *args: errors.append(message.format(*args))) + monkeypatch.setattr(ch.logger, "error", lambda message, *args: errors.append(message.format(*args))) await ch.start() diff --git a/tests/tools/test_exec_platform.py b/tests/tools/test_exec_platform.py index b3d7f4c18..6e5292e7f 100644 --- a/tests/tools/test_exec_platform.py +++ b/tests/tools/test_exec_platform.py @@ -112,33 +112,31 @@ class TestSpawnUnix: class TestSpawnWindows: @pytest.mark.asyncio - async def test_uses_comspec_from_env(self): + async def test_uses_create_subprocess_shell(self): env = {"COMSPEC": r"C:\Windows\system32\cmd.exe", "PATH": ""} with ( patch("nanobot.agent.tools.shell._IS_WINDOWS", True), - patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec, + patch("asyncio.create_subprocess_shell", new_callable=AsyncMock) as mock_shell, ): - mock_exec.return_value = AsyncMock() - await ExecTool._spawn("dir", r"C:\Users", env) + mock_shell.return_value = AsyncMock() + await ExecTool._spawn("dir", r"C:\work", env) - args = mock_exec.call_args[0] - assert "cmd.exe" in args[0] - assert "/c" in args + args = mock_shell.call_args[0] assert "dir" in args @pytest.mark.asyncio - async def test_falls_back_to_default_comspec(self): - env = {"PATH": ""} + async def test_passes_cwd_and_env(self): + env = {"PATH": "/usr/bin"} with ( patch("nanobot.agent.tools.shell._IS_WINDOWS", True), - patch.dict("os.environ", {}, clear=True), - patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec, + patch("asyncio.create_subprocess_shell", new_callable=AsyncMock) as mock_shell, ): - mock_exec.return_value = AsyncMock() - await ExecTool._spawn("dir", r"C:\Users", env) + mock_shell.return_value = AsyncMock() + await ExecTool._spawn("echo hi", r"C:\work", env) - args = mock_exec.call_args[0] - assert args[0] == "cmd.exe" + kwargs = mock_shell.call_args[1] + assert kwargs["cwd"] == r"C:\work" + assert kwargs["env"] == env # --------------------------------------------------------------------------- diff --git a/tests/tools/test_exec_security.py b/tests/tools/test_exec_security.py index 64dc49563..844d535c0 100644 --- a/tests/tools/test_exec_security.py +++ b/tests/tools/test_exec_security.py @@ -3,6 +3,7 @@ from __future__ import annotations import socket +import sys from unittest.mock import patch import pytest @@ -182,3 +183,63 @@ async def test_exec_ignores_workspace_check_when_not_restricted(tmp_path): result = await tool.execute(command="echo ok", working_dir=str(other)) assert "ok" in result assert "outside the configured workspace" not in result + + +# --- #3599: stdio redirects to /dev/null must not trip the workspace guard ---- + + +@pytest.mark.parametrize( + "command", + [ + # The exact command from the #3599 reporter. + 'rm test_print.txt 2>/dev/null; echo "done"', + # Plain redirect of stdout / stderr. + "find . -type f >/dev/null", + "noisy_cmd 2>/dev/null", + "noisy_cmd >/dev/null 2>&1", + # Read from /dev/urandom is also a benign device read. + "head -c 16 /dev/urandom | xxd", + "echo done >/dev/stderr", + "echo line 2>/dev/null`` must succeed against the workspace guard.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + target = workspace / "test_print.txt" + target.write_text("scratch") + tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True, timeout=5) + result = await tool.execute( + command=f'rm {target} 2>/dev/null; echo "done"', + working_dir=str(workspace), + ) + assert "done" in result + assert "path outside working dir" not in result + assert not target.exists() + + +def test_exec_still_blocks_real_outside_path_via_redirect(tmp_path): + """Redirect *targets* outside the workspace (not /dev/...) must still be blocked. + + We only whitelist kernel device files; arbitrary outside redirects such as + ``> /etc/issue`` should remain caught by the workspace guard so a buggy + LLM cannot exfiltrate data outside the workspace via stderr redirection. + """ + workspace = tmp_path / "workspace" + workspace.mkdir() + tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True) + blocked = tool._guard_command("echo pwn > /etc/issue", str(workspace)) + assert blocked is not None + assert "path outside working dir" in blocked diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 66f7b19a8..de39d1a67 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -467,7 +467,7 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint( yield # pragma: no cover monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _broken_stdio_client) - monkeypatch.setattr("nanobot.agent.tools.mcp.logger.error", _error) + monkeypatch.setattr("nanobot.agent.tools.mcp.logger.exception", _error) registry = ToolRegistry() stacks = await connect_mcp_servers({"gh": MCPServerConfig(command="github-mcp")}, registry) diff --git a/tests/tools/test_tool_validation.py b/tests/tools/test_tool_validation.py index 73e3b4f2a..42620dcc6 100644 --- a/tests/tools/test_tool_validation.py +++ b/tests/tools/test_tool_validation.py @@ -242,13 +242,21 @@ def test_exec_extract_absolute_paths_captures_quoted_paths() -> None: def test_exec_guard_blocks_home_path_outside_workspace(tmp_path) -> None: tool = ExecTool(restrict_to_workspace=True) error = tool._guard_command("cat ~/.nanobot/config.json", str(tmp_path)) - assert error == "Error: Command blocked by safety guard (path outside working dir)" + assert error is not None + assert error.startswith( + "Error: Command blocked by safety guard (path outside working dir)" + ) + assert "hard policy boundary" in error def test_exec_guard_blocks_quoted_home_path_outside_workspace(tmp_path) -> None: tool = ExecTool(restrict_to_workspace=True) error = tool._guard_command('cat "~/.nanobot/config.json"', str(tmp_path)) - assert error == "Error: Command blocked by safety guard (path outside working dir)" + assert error is not None + assert error.startswith( + "Error: Command blocked by safety guard (path outside working dir)" + ) + assert "hard policy boundary" in error def test_exec_guard_allows_media_path_outside_workspace(tmp_path, monkeypatch) -> None: @@ -300,7 +308,39 @@ def test_exec_guard_blocks_windows_drive_root_outside_workspace(monkeypatch) -> tool = ExecTool(restrict_to_workspace=True) error = tool._guard_command("dir E:\\", "E:\\workspace") - assert error == "Error: Command blocked by safety guard (path outside working dir)" + assert error is not None + assert error.startswith( + "Error: Command blocked by safety guard (path outside working dir)" + ) + assert "hard policy boundary" in error + + +def test_exec_guard_allows_dev_null_redirect(tmp_path) -> None: + tool = ExecTool(restrict_to_workspace=True) + ws = tmp_path / "workspace" + ws.mkdir() + (ws / "file.txt").write_text("ok", encoding="utf-8") + error = tool._guard_command(f'rm "{ws / "file.txt"}" 2>/dev/null', str(ws)) + assert error is None + + +def test_exec_guard_allows_dev_urandom(tmp_path) -> None: + tool = ExecTool(restrict_to_workspace=True) + error = tool._guard_command("cat /dev/urandom | head -c 16 > random.bin", str(tmp_path)) + assert error is None + + +def test_exec_guard_blocks_non_benign_dev_path(tmp_path) -> None: + tool = ExecTool(restrict_to_workspace=True) + error = tool._guard_command("cat /dev/sda", str(tmp_path)) + assert error is not None + assert "path outside working dir" in error + + +def test_exec_extract_absolute_paths_ignores_pipe_tilde() -> None: + cmd = "python query.py --query '{job=\"app\"} |~ \"error\"'" + paths = ExecTool._extract_absolute_paths(cmd) + assert not any(p.startswith("~") for p in paths) # --- cast_params tests --- diff --git a/tests/utils/test_workspace_violation_throttle.py b/tests/utils/test_workspace_violation_throttle.py new file mode 100644 index 000000000..a0fb059e1 --- /dev/null +++ b/tests/utils/test_workspace_violation_throttle.py @@ -0,0 +1,120 @@ +"""Tests for repeated_workspace_violation throttle and signature.""" + +from __future__ import annotations + +from nanobot.utils.runtime import ( + repeated_workspace_violation_error, + workspace_violation_signature, +) + + +def test_signature_for_filesystem_tools_uses_path_argument(): + sig_a = workspace_violation_signature( + "read_file", {"path": "/Users/x/Downloads/01.md"} + ) + sig_b = workspace_violation_signature( + "write_file", {"path": "/Users/x/Downloads/01.md"} + ) + sig_c = workspace_violation_signature( + "edit_file", {"file_path": "/Users/x/Downloads/01.md"} + ) + + assert sig_a is not None + assert sig_a == sig_b == sig_c, ( + "the throttle must collapse equivalent paths across different tools " + "so the LLM cannot bypass it by switching tool" + ) + assert "/users/x/downloads/01.md" in sig_a + + +def test_signature_for_exec_extracts_first_absolute_path_in_command(): + sig = workspace_violation_signature( + "exec", + {"command": "cat /Users/x/Downloads/01.md && echo done"}, + ) + assert sig is not None + assert "/users/x/downloads/01.md" in sig + + +def test_signature_collides_across_filesystem_and_exec_for_same_target(): + """LLM bypass loops jump tools (read_file -> exec cat). Throttle must + treat both attempts as targeting the same outside resource.""" + fs_sig = workspace_violation_signature( + "read_file", {"path": "/Users/x/Downloads/01.md"} + ) + exec_sig = workspace_violation_signature( + "exec", {"command": "cat /Users/x/Downloads/01.md"} + ) + assert fs_sig == exec_sig + + +def test_signature_falls_back_to_working_dir_when_no_absolute_in_command(): + sig = workspace_violation_signature( + "exec", + {"command": "ls -la", "working_dir": "/etc"}, + ) + assert sig is not None + assert "/etc" in sig + + +def test_signature_is_none_for_unknown_tool_with_no_path(): + assert workspace_violation_signature("web_search", {"query": "anything"}) is None + assert workspace_violation_signature("exec", {"command": "echo hello"}) is None + + +def test_repeated_workspace_violation_returns_none_within_budget(): + counts: dict[str, int] = {} + arguments = {"path": "/Users/x/Downloads/01.md"} + + assert repeated_workspace_violation_error("read_file", arguments, counts) is None + assert repeated_workspace_violation_error("read_file", arguments, counts) is None + + +def test_repeated_workspace_violation_escalates_after_third_attempt(): + counts: dict[str, int] = {} + arguments = {"path": "/Users/x/Downloads/01.md"} + + repeated_workspace_violation_error("read_file", arguments, counts) + repeated_workspace_violation_error("read_file", arguments, counts) + third = repeated_workspace_violation_error("read_file", arguments, counts) + + assert third is not None + assert "refusing repeated workspace-bypass" in third + assert "/users/x/downloads/01.md" in third + assert "ask how they want to proceed" in third + + +def test_repeated_workspace_violation_independent_per_target(): + """Different outside paths must each get their own retry budget.""" + counts: dict[str, int] = {} + + repeated_workspace_violation_error( + "read_file", {"path": "/Users/x/Downloads/01.md"}, counts, + ) + repeated_workspace_violation_error( + "read_file", {"path": "/Users/x/Downloads/01.md"}, counts, + ) + # Different target, fresh budget. + assert repeated_workspace_violation_error( + "read_file", {"path": "/Users/x/Documents/notes.md"}, counts, + ) is None + + +def test_repeated_workspace_violation_collapses_tool_switching(): + """LLM switches from read_file to exec cat then to python -c open(...) + against the same path; the throttle must escalate on the third attempt.""" + counts: dict[str, int] = {} + + repeated_workspace_violation_error( + "read_file", {"path": "/Users/x/Downloads/01.md"}, counts, + ) + repeated_workspace_violation_error( + "exec", {"command": "cat /Users/x/Downloads/01.md"}, counts, + ) + third = repeated_workspace_violation_error( + "exec", + {"command": "python3 -c \"open('/Users/x/Downloads/01.md').read()\""}, + counts, + ) + assert third is not None + assert "refusing repeated workspace-bypass" in third diff --git a/webui/README.md b/webui/README.md index 602b179e7..b99874ba0 100644 --- a/webui/README.md +++ b/webui/README.md @@ -72,6 +72,27 @@ If your gateway listens on a non-default port, point the dev server at it: NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev ``` +### Access from another device (LAN) + +To use the webui from another device on the same network, set `host` to `"0.0.0.0"` and configure a `token` or `tokenIssueSecret` in `~/.nanobot/config.json`: + +```json +{ + "channels": { + "websocket": { + "enabled": true, + "host": "0.0.0.0", + "port": 8765, + "tokenIssueSecret": "your-secret-here" + } + } +} +``` + +The gateway will refuse to start if `host` is `"0.0.0.0"` and neither `token` nor `tokenIssueSecret` is set. + +Then open `http://:8765` on the other device. The webui will show an authentication form where you enter the secret. It is saved in your browser so you only need to enter it once. + ## Build for packaged runtime ```bash diff --git a/webui/src/App.tsx b/webui/src/App.tsx index c6ad6f067..9eca02688 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -9,14 +9,23 @@ import { preloadMarkdownText } from "@/components/MarkdownText"; import { useSessions } from "@/hooks/useSessions"; import { useTheme } from "@/hooks/useTheme"; import { cn } from "@/lib/utils"; -import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap"; +import { + clearSavedSecret, + deriveWsUrl, + fetchBootstrap, + loadSavedSecret, + saveSecret, +} from "@/lib/bootstrap"; import { NanobotClient } from "@/lib/nanobot-client"; import { ClientProvider } from "@/providers/ClientProvider"; import type { ChatSummary } from "@/lib/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; type BootState = | { status: "loading" } | { status: "error"; message: string } + | { status: "auth"; failed?: boolean } | { status: "ready"; client: NanobotClient; @@ -25,9 +34,63 @@ type BootState = }; const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; -const SIDEBAR_WIDTH = 279; +const SIDEBAR_WIDTH = 272; type ShellView = "chat" | "settings"; +function AuthForm({ + failed, + onSecret, +}: { + failed: boolean; + onSecret: (secret: string) => void; +}) { + const { t } = useTranslation(); + const [value, setValue] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const secret = value.trim(); + if (!secret) return; + setSubmitting(true); + onSecret(secret); + }; + + return ( +
+
+
+

{t("app.auth.title")}

+

{t("app.auth.hint")}

+
+ {failed && ( +

+ {t("app.auth.invalid")} +

+ )} + setValue(e.target.value)} + disabled={submitting} + autoFocus + /> + +
+
+ ); +} + function readSidebarOpen(): boolean { if (typeof window === "undefined") return true; try { @@ -43,40 +106,55 @@ export default function App() { const { t } = useTranslation(); const [state, setState] = useState({ status: "loading" }); + const bootstrapWithSecret = useCallback( + (secret: string) => { + let cancelled = false; + (async () => { + setState({ status: "loading" }); + try { + const boot = await fetchBootstrap("", secret); + if (cancelled) return; + if (secret) saveSecret(secret); + const url = deriveWsUrl(boot.ws_path, boot.token); + const client = new NanobotClient({ + url, + onReauth: async () => { + try { + const refreshed = await fetchBootstrap("", secret); + return deriveWsUrl(refreshed.ws_path, refreshed.token); + } catch { + return null; + } + }, + }); + client.connect(); + setState({ + status: "ready", + client, + token: boot.token, + modelName: boot.model_name ?? null, + }); + } catch (e) { + if (cancelled) return; + const msg = (e as Error).message; + if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) { + setState({ status: "auth", failed: true }); + } else { + setState({ status: "error", message: msg }); + } + } + })(); + return () => { + cancelled = true; + }; + }, + [], + ); + useEffect(() => { - let cancelled = false; - (async () => { - try { - const boot = await fetchBootstrap(); - if (cancelled) return; - const url = deriveWsUrl(boot.ws_path, boot.token); - const client = new NanobotClient({ - url, - onReauth: async () => { - try { - const refreshed = await fetchBootstrap(); - return deriveWsUrl(refreshed.ws_path, refreshed.token); - } catch { - return null; - } - }, - }); - client.connect(); - setState({ - status: "ready", - client, - token: boot.token, - modelName: boot.model_name ?? null, - }); - } catch (e) { - if (cancelled) return; - setState({ status: "error", message: (e as Error).message }); - } - })(); - return () => { - cancelled = true; - }; - }, []); + const saved = loadSavedSecret(); + return bootstrapWithSecret(saved); + }, [bootstrapWithSecret]); useEffect(() => { const warm = () => preloadMarkdownText(); @@ -99,13 +177,6 @@ export default function App() { return (
-
@@ -117,17 +188,18 @@ export default function App() {
); } + if (state.status === "auth") { + return ( + bootstrapWithSecret(s)} + /> + ); + } if (state.status === "error") { return (
-

{t("app.error.title")}

{state.message}

@@ -144,18 +216,26 @@ export default function App() { ); }; + const handleLogout = () => { + if (state.status === "ready") { + state.client.close(); + } + clearSavedSecret(); + setState({ status: "auth" }); + }; + return ( - + ); } -function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | null) => void }) { +function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) { const { t, i18n } = useTranslation(); const { theme, toggle } = useTheme(); const { sessions, loading, refresh, createChat, deleteChat } = useSessions(); @@ -213,7 +293,7 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | } }, []); - const onNewChat = useCallback(async () => { + const onCreateChat = useCallback(async () => { try { const chatId = await createChat(); setActiveKey(`websocket:${chatId}`); @@ -226,6 +306,12 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | } }, [createChat]); + const onNewChat = useCallback(() => { + setActiveKey(null); + setView("chat"); + setMobileSidebarOpen(false); + }, []); + const onSelectChat = useCallback( (key: string) => { setActiveKey(key); @@ -235,6 +321,15 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | [], ); + const onOpenSettings = useCallback(() => { + setView("settings"); + setMobileSidebarOpen(false); + }, []); + + const onTurnEnd = useCallback(() => { + void refresh(); + }, [refresh]); + const onConfirmDelete = useCallback(async () => { if (!pendingDelete) return; const key = pendingDelete.key; @@ -254,7 +349,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | }, [pendingDelete, deleteChat, activeKey, sessions]); const headerTitle = activeSession - ? activeSession.preview || + ? activeSession.title || + activeSession.preview || t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) }) : t("app.brand"); @@ -268,20 +364,10 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | sessions, activeKey, loading, - theme, - onToggleTheme: toggle, - onNewChat: () => { - void onNewChat(); - }, + onNewChat, onSelect: onSelectChat, - onRefresh: () => void refresh(), onRequestDelete: (key: string, label: string) => setPendingDelete({ key, label }), - activeView: view, - onOpenSettings: () => { - setView("settings" as const); - setMobileSidebarOpen(false); - }, }; return ( @@ -296,10 +382,11 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | >

@@ -312,7 +399,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | @@ -325,14 +413,19 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | onToggleTheme={toggle} onBackToChat={() => setView("chat")} onModelNameChange={onModelNameChange} + onLogout={onLogout} /> ) : ( setActiveKey(null)} onNewChat={onNewChat} + onCreateChat={onCreateChat} + onTurnEnd={onTurnEnd} + theme={theme} + onToggleTheme={toggle} + onOpenSettings={onOpenSettings} hideSidebarToggleOnDesktop={desktopSidebarOpen} /> )} diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index f77f7c1b2..ce7bb17e0 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -8,7 +8,6 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { relativeTime } from "@/lib/format"; import { cn } from "@/lib/utils"; import type { ChatSummary } from "@/lib/types"; @@ -18,10 +17,11 @@ interface ChatListProps { onSelect: (key: string) => void; onRequestDelete: (key: string, label: string) => void; loading?: boolean; + emptyLabel?: string; } function titleFor(s: ChatSummary, fallbackTitle: string): string { - const p = s.preview?.trim(); + const p = (s.title || s.preview)?.trim(); if (p) return p.length > 48 ? `${p.slice(0, 45)}…` : p; return fallbackTitle; } @@ -32,6 +32,7 @@ export function ChatList({ onSelect, onRequestDelete, loading, + emptyLabel, }: ChatListProps) { const { t } = useTranslation(); if (loading && sessions.length === 0) { @@ -44,73 +45,111 @@ export function ChatList({ if (sessions.length === 0) { return ( -
- {t("chat.noSessions")} +
+ {emptyLabel ?? t("chat.noSessions")}
); } + const groups = groupSessions(sessions, { + today: t("chat.groups.today"), + yesterday: t("chat.groups.yesterday"), + earlier: t("chat.groups.earlier"), + }); + return ( -
    - {sessions.map((s) => { - const active = s.key === activeKey; - const title = titleFor( - s, - t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }), - ); - return ( -
  • -
    - - - - - - event.preventDefault()} - > - { - window.setTimeout(() => onRequestDelete(s.key, title), 0); - }} - className="text-destructive focus:text-destructive" +
    + {groups.map((group) => ( +
    +
    + {group.label} +
    +
      + {group.sessions.map((s) => { + const active = s.key === activeKey; + const title = titleFor( + s, + t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }), + ); + return ( +
    • +
      - - {t("chat.delete")} - - - -
      -
    • - ); - })} -
    + + + + + + event.preventDefault()} + > + { + window.setTimeout(() => onRequestDelete(s.key, title), 0); + }} + className="text-destructive focus:text-destructive" + > + + {t("chat.delete")} + + + +
    +
  • + ); + })} +
+ + ))} +
); } + +function groupSessions( + sessions: ChatSummary[], + labels: { today: string; yesterday: string; earlier: string }, +): Array<{ label: string; sessions: ChatSummary[] }> { + const now = new Date(); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000; + const buckets = new Map(); + + for (const session of sessions) { + const timestamp = Date.parse(session.updatedAt ?? session.createdAt ?? ""); + const label = Number.isFinite(timestamp) && timestamp >= startOfToday + ? labels.today + : Number.isFinite(timestamp) && timestamp >= startOfYesterday + ? labels.yesterday + : labels.earlier; + const bucket = buckets.get(label) ?? []; + bucket.push(session); + buckets.set(label, bucket); + } + + return [labels.today, labels.yesterday, labels.earlier] + .map((label) => ({ label, sessions: buckets.get(label) ?? [] })) + .filter((group) => group.sessions.length > 0); +} diff --git a/webui/src/components/ChatPane.tsx b/webui/src/components/ChatPane.tsx index 29d0df49f..43fe64914 100644 --- a/webui/src/components/ChatPane.tsx +++ b/webui/src/components/ChatPane.tsx @@ -22,7 +22,7 @@ interface ChatPaneProps { export function ChatPane({ session, onNewChat }: ChatPaneProps) { const chatId = session?.chatId ?? null; const historyKey = session?.key ?? null; - const { messages: historical, loading } = useSessionHistory(historyKey); + const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey); const { client } = useClient(); const [booting, setBooting] = useState(false); const pendingFirstRef = useRef(null); @@ -31,6 +31,7 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) { const { messages, isStreaming, send, setMessages } = useNanobotStream( chatId, initial, + hasPendingToolCalls, ); useEffect(() => { @@ -78,20 +79,8 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
- - - nanobot -

- What's on your mind? + What can I do for you?

Your conversations are persisted locally under the nanobot @@ -104,7 +93,7 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) { disabled={booting} onSend={handleWelcomeSend} placeholder={ - booting ? "Opening a new chat…" : "Type your message…" + booting ? "Opening a new chat…" : "Ask anything..." } />

diff --git a/webui/src/components/ConnectionBadge.tsx b/webui/src/components/ConnectionBadge.tsx index 354be976f..7616ddbe5 100644 --- a/webui/src/components/ConnectionBadge.tsx +++ b/webui/src/components/ConnectionBadge.tsx @@ -6,21 +6,21 @@ import { useClient } from "@/providers/ClientProvider"; import type { ConnectionStatus } from "@/lib/types"; const COPY: Record = { - idle: { color: "bg-card/40 text-muted-foreground" }, + idle: { color: "text-muted-foreground" }, connecting: { - color: "bg-amber-500/10 text-amber-700 dark:text-amber-300", + color: "text-amber-700 dark:text-amber-300", }, open: { - color: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400", + color: "text-emerald-700 dark:text-emerald-400", }, reconnecting: { - color: "bg-amber-500/10 text-amber-700 dark:text-amber-300", + color: "text-amber-700 dark:text-amber-300", }, closed: { - color: "bg-card/40 text-muted-foreground", + color: "text-muted-foreground", }, error: { - color: "bg-destructive/10 text-destructive", + color: "text-destructive", }, }; @@ -39,7 +39,7 @@ export function ConnectionBadge() { return ( (null); const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300"; + useEffect(() => { + return () => { + if (copyResetRef.current !== null) { + window.clearTimeout(copyResetRef.current); + } + }; + }, []); + + const onCopyAssistantReply = useCallback(() => { + if (!navigator.clipboard) return; + void navigator.clipboard.writeText(message.content).then(() => { + setCopied(true); + if (copyResetRef.current !== null) { + window.clearTimeout(copyResetRef.current); + } + copyResetRef.current = window.setTimeout(() => { + setCopied(false); + copyResetRef.current = null; + }, 1_500); + }); + }, [message.content]); + if (message.kind === "trace") { return ; } @@ -60,6 +85,7 @@ export function MessageBubble({ message }: MessageBubbleProps) { const empty = message.content.trim().length === 0; const media = message.media ?? []; + const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty; return (
{empty && message.isStreaming ? ( @@ -69,6 +95,27 @@ export function MessageBubble({ message }: MessageBubbleProps) { {message.content} {message.isStreaming && } {media.length > 0 ? : null} + {showAssistantActions ? ( +
+ +
+ ) : null} )}
diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index b544fd0ba..52c8de47c 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -1,109 +1,121 @@ -import { Moon, PanelLeftClose, RefreshCcw, Settings, SquarePen, Sun } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + PanelLeftClose, + Search, + SquarePen, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { ChatList } from "@/components/ChatList"; import { ConnectionBadge } from "@/components/ConnectionBadge"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; import type { ChatSummary } from "@/lib/types"; interface SidebarProps { sessions: ChatSummary[]; activeKey: string | null; loading: boolean; - theme: "light" | "dark"; - onToggleTheme: () => void; onNewChat: () => void; onSelect: (key: string) => void; - onRefresh: () => void; onRequestDelete: (key: string, label: string) => void; onCollapse: () => void; - activeView?: "chat" | "settings"; - onOpenSettings: () => void; } export function Sidebar(props: SidebarProps) { const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const normalizedQuery = query.trim().toLowerCase(); + const filteredSessions = useMemo(() => { + if (!normalizedQuery) return props.sessions; + return props.sessions.filter((session) => { + const haystack = [ + session.preview, + session.chatId, + session.channel, + session.key, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return haystack.includes(normalizedQuery); + }); + }, [normalizedQuery, props.sessions]); + return ( - + ); } diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index c24ff97da..0f3b5b77d 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronLeft, Loader2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { Button } from "@/components/ui/button"; @@ -14,11 +15,13 @@ interface SettingsViewProps { onToggleTheme: () => void; onBackToChat: () => void; onModelNameChange: (modelName: string | null) => void; + onLogout?: () => void; } export function SettingsView({ onBackToChat, onModelNameChange, + onLogout, }: SettingsViewProps) { const { token } = useClient(); const [settings, setSettings] = useState(null); @@ -115,6 +118,7 @@ export function SettingsView({ dirty={dirty} saving={saving} onSave={save} + onLogout={onLogout} /> ) : null} @@ -129,6 +133,7 @@ function SettingsSection({ dirty, saving, onSave, + onLogout, }: { form: { model: string; @@ -142,7 +147,9 @@ function SettingsSection({ dirty: boolean; saving: boolean; onSave: () => void; + onLogout?: () => void; }) { + const { t } = useTranslation(); return (
@@ -192,6 +199,19 @@ function SettingsSection({
+ + {onLogout && ( +
+

{t("app.account.section")}

+ + + + + +
+ )}
); } diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 105bb6c77..ac994f89e 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -7,11 +7,21 @@ import { type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { + Activity, ArrowUp, + BookOpen, + CircleHelp, + History, ImageIcon, Loader2, - Paperclip, + Plus, + RotateCw, + Sparkles, + Square, + SquarePen, + Undo2, X, + type LucideIcon, } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -24,6 +34,7 @@ import { } from "@/hooks/useAttachedImages"; import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop"; import type { SendImage } from "@/hooks/useNanobotStream"; +import type { SlashCommand } from "@/lib/types"; import { cn } from "@/lib/utils"; /** ````: aligned with the server's MIME whitelist. SVG is @@ -40,26 +51,49 @@ interface ThreadComposerProps { onSend: (content: string, images?: SendImage[]) => void; disabled?: boolean; placeholder?: string; + isStreaming?: boolean; modelLabel?: string | null; variant?: "thread" | "hero"; + slashCommands?: SlashCommand[]; +} + +const COMMAND_ICONS: Record = { + activity: Activity, + "book-open": BookOpen, + "circle-help": CircleHelp, + history: History, + "rotate-cw": RotateCw, + sparkles: Sparkles, + square: Square, + "square-pen": SquarePen, + "undo-2": Undo2, +}; + +function slashCommandI18nKey(command: string): string { + return command.replace(/^\//, "").replace(/-/g, "_"); } export function ThreadComposer({ onSend, disabled, placeholder, + isStreaming = false, modelLabel = null, variant = "thread", + slashCommands = [], }: ThreadComposerProps) { const { t } = useTranslation(); const [value, setValue] = useState(""); const [inlineError, setInlineError] = useState(null); + const [slashMenuDismissed, setSlashMenuDismissed] = useState(false); + const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); const textareaRef = useRef(null); const fileInputRef = useRef(null); const chipRefs = useRef(new Map()); const isHero = variant === "hero"; - const resolvedPlaceholder = - placeholder ?? t("thread.composer.placeholderThread"); + const resolvedPlaceholder = isStreaming + ? t("thread.composer.placeholderStreaming") + : placeholder ?? t("thread.composer.placeholderThread"); const { images, enqueue, remove, clear, encoding, full } = useAttachedImages(); @@ -116,6 +150,66 @@ export function ThreadComposer({ && !hasErrors && (value.trim().length > 0 || readyImages.length > 0); + const slashQuery = useMemo(() => { + if (disabled || slashMenuDismissed || !value.startsWith("/")) return null; + const commandToken = value.slice(1); + if (/\s/.test(commandToken)) return null; + return commandToken.toLowerCase(); + }, [disabled, slashMenuDismissed, value]); + + const filteredSlashCommands = useMemo(() => { + if (slashQuery === null) return []; + return slashCommands + .filter((command) => { + const haystack = [ + command.command, + command.title, + command.description, + command.argHint ?? "", + t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.title`, { + defaultValue: "", + }), + t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.description`, { + defaultValue: "", + }), + ].join(" ").toLowerCase(); + return haystack.includes(slashQuery); + }) + .slice(0, 8); + }, [slashCommands, slashQuery, t]); + + const showSlashMenu = filteredSlashCommands.length > 0; + + useEffect(() => { + setSelectedCommandIndex(0); + }, [slashQuery]); + + useEffect(() => { + if (selectedCommandIndex >= filteredSlashCommands.length) { + setSelectedCommandIndex(0); + } + }, [filteredSlashCommands.length, selectedCommandIndex]); + + const resizeTextarea = useCallback(() => { + requestAnimationFrame(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 260)}px`; + el.focus(); + }); + }, []); + + const chooseSlashCommand = useCallback( + (command: SlashCommand) => { + setValue(command.argHint ? `${command.command} ` : command.command); + setSlashMenuDismissed(true); + setInlineError(null); + resizeTextarea(); + }, + [resizeTextarea], + ); + const submit = useCallback(() => { if (!canSend) return; const trimmed = value.trim(); @@ -139,16 +233,35 @@ export function ThreadComposer({ // Bubble owns the data URL copy; safe to revoke every staged blob // preview here without affecting the rendered message. clear(); - requestAnimationFrame(() => { - const el = textareaRef.current; - if (el) { - el.style.height = "auto"; - el.focus(); - } - }); - }, [canSend, clear, onSend, readyImages, value]); + setSlashMenuDismissed(false); + resizeTextarea(); + }, [canSend, clear, onSend, readyImages, resizeTextarea, value]); const onKeyDown = (e: ReactKeyboardEvent) => { + if (showSlashMenu) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedCommandIndex((idx) => (idx + 1) % filteredSlashCommands.length); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedCommandIndex( + (idx) => (idx - 1 + filteredSlashCommands.length) % filteredSlashCommands.length, + ); + return; + } + if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) { + e.preventDefault(); + chooseSlashCommand(filteredSlashCommands[selectedCommandIndex]); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setSlashMenuDismissed(true); + return; + } + } if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); submit(); @@ -210,14 +323,23 @@ export function ThreadComposer({ onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop} - className={cn("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 ? ( + + ) : null}
setValue(e.target.value)} + onChange={(e) => { + setValue(e.target.value); + setSlashMenuDismissed(false); + }} onInput={onInput} onKeyDown={onKeyDown} onPaste={onPaste} @@ -265,9 +390,9 @@ export function ThreadComposer({ className={cn( "w-full resize-none bg-transparent", isHero - ? "min-h-[96px] px-4 pb-2 pt-4 text-[15px] leading-6" + ? "min-h-[78px] px-5 pb-2 pt-5 text-[16px] leading-6" : "min-h-[50px] px-4 pb-1.5 pt-3 text-sm", - "placeholder:text-muted-foreground", + "placeholder:text-muted-foreground/70", "focus:outline-none focus-visible:outline-none", "disabled:cursor-not-allowed", )} @@ -286,7 +411,7 @@ export function ThreadComposer({
@@ -307,10 +432,12 @@ export function ThreadComposer({ onClick={() => fileInputRef.current?.click()} className={cn( "rounded-full text-muted-foreground hover:text-foreground", - isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5", + isHero + ? "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card" + : "h-7.5 w-7.5 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card", )} > - + {modelLabel ? ( {modelLabel} ) : null} - - {t("thread.composer.sendHint")} - + {!isHero ? ( + + {t("thread.composer.sendHint")} + + ) : null}
- +
@@ -352,6 +489,106 @@ export function ThreadComposer({ ); } +interface SlashCommandPaletteProps { + commands: SlashCommand[]; + selectedIndex: number; + isHero: boolean; + onHover: (index: number) => void; + onChoose: (command: SlashCommand) => void; +} + +function SlashCommandPalette({ + commands, + selectedIndex, + isHero, + onHover, + onChoose, +}: SlashCommandPaletteProps) { + const { t } = useTranslation(); + return ( +
+
+ {t("thread.composer.slash.label")} +
+
+ {commands.map((command, index) => { + const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp; + const selected = index === selectedIndex; + const commandKey = slashCommandI18nKey(command.command); + const title = t(`thread.composer.slash.commands.${commandKey}.title`, { + defaultValue: command.title, + }); + const description = t(`thread.composer.slash.commands.${commandKey}.description`, { + defaultValue: command.description, + }); + return ( + + ); + })} +
+
+ {t("thread.composer.slash.navigateHint")} + {t("thread.composer.slash.selectHint")} + {t("thread.composer.slash.closeHint")} +
+
+ ); +} + interface AttachmentChipProps { image: AttachedImage; labelRemove: string; diff --git a/webui/src/components/thread/ThreadHeader.tsx b/webui/src/components/thread/ThreadHeader.tsx index bdc00ac2c..9c23d4bc2 100644 --- a/webui/src/components/thread/ThreadHeader.tsx +++ b/webui/src/components/thread/ThreadHeader.tsx @@ -1,4 +1,4 @@ -import { PanelLeftOpen } from "lucide-react"; +import { Menu, Moon, PanelLeftOpen, Settings, Sun } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -7,17 +7,66 @@ import { cn } from "@/lib/utils"; interface ThreadHeaderProps { title: string; onToggleSidebar: () => void; - onGoHome: () => void; + theme: "light" | "dark"; + onToggleTheme: () => void; + onOpenSettings: () => void; hideSidebarToggleOnDesktop?: boolean; + minimal?: boolean; } export function ThreadHeader({ title, onToggleSidebar, - onGoHome, + theme, + onToggleTheme, + onOpenSettings, hideSidebarToggleOnDesktop = false, + minimal = false, }: ThreadHeaderProps) { const { t } = useTranslation(); + if (minimal) { + return ( +
+ +
+ + +
+
+ ); + } + return (
@@ -33,19 +82,34 @@ export function ThreadHeader({ > - +
+
+ +
+ +
diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 801080bbf..f15551ce5 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -1,4 +1,13 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + BarChart3, + BookOpen, + ChevronRight, + Code2, + LayoutGrid, + Lightbulb, + MoreHorizontal, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { AskUserPrompt } from "@/components/thread/AskUserPrompt"; @@ -8,15 +17,21 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice"; import { ThreadViewport } from "@/components/thread/ThreadViewport"; import { useNanobotStream } from "@/hooks/useNanobotStream"; import { useSessionHistory } from "@/hooks/useSessions"; -import type { ChatSummary, UIMessage } from "@/lib/types"; +import { listSlashCommands } from "@/lib/api"; +import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types"; import { useClient } from "@/providers/ClientProvider"; interface ThreadShellProps { session: ChatSummary | null; title: string; onToggleSidebar: () => void; - onGoHome: () => void; - onNewChat: () => Promise; + onGoHome?: () => void; + onNewChat?: () => void; + onCreateChat?: () => Promise; + onTurnEnd?: () => void; + theme?: "light" | "dark"; + onToggleTheme?: () => void; + onOpenSettings?: () => void; hideSidebarToggleOnDesktop?: boolean; } @@ -28,22 +43,36 @@ function toModelBadgeLabel(modelName: string | null): string | null { return leaf || trimmed; } +const QUICK_ACTION_KEYS = [ + { key: "plan", icon: LayoutGrid, tone: "text-[#f25b8f]" }, + { key: "analyze", icon: BarChart3, tone: "text-[#4f9de8]" }, + { key: "brainstorm", icon: Lightbulb, tone: "text-[#53c59d]" }, + { key: "code", icon: Code2, tone: "text-[#eba45d]" }, + { key: "summarize", icon: BookOpen, tone: "text-[#a877e7]" }, + { key: "more", icon: MoreHorizontal, tone: "text-muted-foreground/65" }, +] as const; + export function ThreadShell({ session, title, onToggleSidebar, - onGoHome, - onNewChat, + onCreateChat, + onTurnEnd, + theme = "light", + onToggleTheme = () => {}, + onOpenSettings = () => {}, hideSidebarToggleOnDesktop = false, }: ThreadShellProps) { const { t } = useTranslation(); const chatId = session?.chatId ?? null; const historyKey = session?.key ?? null; - const { messages: historical, loading } = useSessionHistory(historyKey); - const { client, modelName } = useClient(); + const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey); + const { client, modelName, token } = useClient(); const [booting, setBooting] = useState(false); + const [slashCommands, setSlashCommands] = useState([]); const pendingFirstRef = useRef(null); const messageCacheRef = useRef>(new Map()); + const lastCachedChatIdRef = useRef(null); const initial = useMemo(() => { if (!chatId) return historical; @@ -56,7 +85,7 @@ export function ThreadShell({ setMessages, streamError, dismissStreamError, - } = useNanobotStream(chatId, initial); + } = useNanobotStream(chatId, initial, hasPendingToolCalls, onTurnEnd); const showHeroComposer = messages.length === 0 && !loading; const pendingAsk = useMemo(() => { for (let index = messages.length - 1; index >= 0; index -= 1) { @@ -89,10 +118,24 @@ export function ThreadShell({ setMessages(historical); }, [chatId, historical, setMessages]); - useEffect(() => { - if (!chatId) return; + useLayoutEffect(() => { + if (!chatId) { + lastCachedChatIdRef.current = null; + return; + } + if (loading) return; + // Skip the first cache write after a chat switch. During that render, + // `messages` can still belong to the previous chat until the stream hook + // resets its local state for the new session. + if (lastCachedChatIdRef.current !== chatId) { + lastCachedChatIdRef.current = chatId; + if (messages.length > 0) { + messageCacheRef.current.set(chatId, messages); + } + return; + } messageCacheRef.current.set(chatId, messages); - }, [chatId, messages]); + }, [chatId, loading, messages]); useEffect(() => { if (!chatId) return; @@ -112,18 +155,115 @@ export function ThreadShell({ setBooting(false); }, [chatId, client, setMessages]); + useEffect(() => { + let cancelled = false; + (async () => { + try { + const commands = await listSlashCommands(token); + if (!cancelled) setSlashCommands(commands); + } catch { + if (!cancelled) setSlashCommands([]); + } + })(); + return () => { + cancelled = true; + }; + }, [token]); + const handleWelcomeSend = useCallback( async (content: string) => { if (booting) return; setBooting(true); pendingFirstRef.current = content; - const newId = await onNewChat(); + const newId = await onCreateChat?.(); if (!newId) { pendingFirstRef.current = null; setBooting(false); } }, - [booting, onNewChat], + [booting, onCreateChat], + ); + + const handleQuickAction = useCallback( + (prompt: string) => { + if (session) { + send(prompt); + return; + } + void handleWelcomeSend(prompt); + }, + [handleWelcomeSend, send, session], + ); + + const quickActions = ( +
+ {QUICK_ACTION_KEYS.map(({ key, icon: Icon, tone }) => { + const title = t(`thread.empty.quickActions.${key}.title`); + const prompt = t(`thread.empty.quickActions.${key}.prompt`); + return ( + + ); + })} +
+ ); + + const composer = ( + <> + {streamError ? ( + + ) : null} + {pendingAsk ? ( + + ) : null} + {session ? ( + + ) : ( + + )} + {showHeroComposer ? quickActions : null} + ); const emptyState = loading ? ( @@ -131,20 +271,10 @@ export function ThreadShell({ {t("thread.loadingConversation")}
) : ( -
-
- - nanobot -
-

- {t("thread.empty.description")} -

+
+

+ {t("thread.empty.greeting")} +

); @@ -153,55 +283,17 @@ export function ThreadShell({ - {streamError ? ( - - ) : null} - {pendingAsk ? ( - - ) : null} - {session ? ( - - ) : ( - - )} - - } + composer={composer} />
); diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 5f4b8d01a..7d4a80f06 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -82,9 +82,9 @@ export function ThreadViewport({
) : ( -
-
-
+
+
+
{emptyState}
{composer}
diff --git a/webui/src/globals.css b/webui/src/globals.css index 1c677432c..802009ee7 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -25,9 +25,9 @@ --input: 0 0% 89.8%; --ring: 0 0% 3.9%; --radius: 0.4375rem; - --sidebar: 0 0% 98%; + --sidebar: 0 0% 98.5%; --sidebar-foreground: 0 0% 3.9%; - --sidebar-accent: 0 0% 96.1%; + --sidebar-accent: 0 0% 95.8%; --sidebar-accent-foreground: 0 0% 9%; --sidebar-border: 0 0% 89.8%; } @@ -52,9 +52,9 @@ --border: 0 0% 18%; --input: 0 0% 18%; --ring: 0 0% 83.1%; - --sidebar: 0 0% 12%; + --sidebar: 0 0% 11.5%; --sidebar-foreground: 0 0% 98%; - --sidebar-accent: 0 0% 16%; + --sidebar-accent: 0 0% 15.5%; --sidebar-accent-foreground: 0 0% 98%; --sidebar-border: 0 0% 18%; } diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index 7730c6812..b25f5981a 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -37,6 +37,8 @@ export interface SendImage { export function useNanobotStream( chatId: string | null, initialMessages: UIMessage[] = [], + hasPendingToolCalls = false, + onTurnEnd?: () => void, ): { messages: UIMessage[]; isStreaming: boolean; @@ -51,9 +53,23 @@ export function useNanobotStream( } { const { client } = useClient(); const [messages, setMessages] = useState(initialMessages); - const [isStreaming, setIsStreaming] = useState(false); + /** If the last loaded message is a trace row (e.g. "Using 2 tools"), + * the model was still processing when the page loaded — keep the + * loading spinner alive so the user sees the model is active. */ + const initialStreaming = initialMessages.length > 0 + ? initialMessages[initialMessages.length - 1].kind === "trace" + : false; + const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls); const [streamError, setStreamError] = useState(null); const buffer = useRef(null); + /** Timer that defers ``isStreaming = false`` after ``stream_end``. + * + * When the model finishes a text segment and calls a tool, the server + * sends ``stream_end`` but the agent is still "thinking" while the tool + * executes. By deferring the flag reset by a short window (1 s) we keep + * the loading spinner alive across tool-call boundaries without needing + * backend changes. */ + const streamEndTimerRef = useRef | null>(null); useEffect(() => { return client.onError((err) => setStreamError(err)); @@ -62,21 +78,43 @@ export function useNanobotStream( const dismissStreamError = useCallback(() => setStreamError(null), []); // Reset local state when switching chats. ``streamError`` is scoped to the - // send that triggered it, so a chat swap should wipe it out: a stale - // "Message too large" banner on a freshly-opened chat-B would confuse the - // user about which send actually failed (and in which chat). - useEffect(() => { - setMessages(initialMessages); - setIsStreaming(false); - setStreamError(null); - buffer.current = null; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chatId]); + // send that triggered it, so a chat swap should wipe it out: a stale + // "Message too large" banner on a freshly-opened chat-B would confuse the + // user about which send actually failed (and in which chat). + useEffect(() => { + setMessages(initialMessages); + // Check if the new chat's last message is a trace row — if so, the + // model may still be processing. + setIsStreaming( + initialMessages.length > 0 + ? initialMessages[initialMessages.length - 1].kind === "trace" + : false, + ); + // Also consider hasPendingToolCalls from session history. + if (hasPendingToolCalls) { + setIsStreaming(true); + } + setStreamError(null); + buffer.current = null; + if (streamEndTimerRef.current !== null) { + clearTimeout(streamEndTimerRef.current); + streamEndTimerRef.current = null; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatId, initialMessages, hasPendingToolCalls]); useEffect(() => { if (!chatId) return; const handle = (ev: InboundEvent) => { + // Any incoming event while the debounce timer is alive means the model + // is still working (e.g. tool result arrived, more text to stream). + // Cancel the pending "stream ended" timer so we don't hide the spinner. + if (streamEndTimerRef.current !== null) { + clearTimeout(streamEndTimerRef.current); + streamEndTimerRef.current = null; + } + if (ev.event === "delta") { const id = buffer.current?.messageId ?? crypto.randomUUID(); if (!buffer.current) { @@ -103,18 +141,31 @@ export function useNanobotStream( } if (ev.event === "stream_end") { - if (!buffer.current) { - setIsStreaming(false); - return; - } - const finalId = buffer.current.messageId; + // stream_end only means the text segment finished — the model may + // still be executing tools. Do NOT reset isStreaming here; the + // definitive "turn is complete" signal is ``turn_end``. + if (!buffer.current) return; buffer.current = null; + return; + } + + if (ev.event === "turn_end") { + // Definitive signal that the turn is fully complete. Cancel any + // pending debounce timer and stop the loading indicator immediately. + if (streamEndTimerRef.current !== null) { + clearTimeout(streamEndTimerRef.current); + streamEndTimerRef.current = null; + } setIsStreaming(false); setMessages((prev) => - prev.map((m) => - m.id === finalId ? { ...m, isStreaming: false } : m, - ), + prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)), ); + onTurnEnd?.(); + return; + } + + if (ev.event === "session_updated") { + onTurnEnd?.(); return; } @@ -157,7 +208,8 @@ export function useNanobotStream( // flight, drop the placeholder so we don't render the text twice. const activeId = buffer.current?.messageId; buffer.current = null; - setIsStreaming(false); + // Do NOT reset isStreaming here — only ``turn_end`` signals that + // the full turn (all tool calls + final text) is complete. setMessages((prev) => { const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev; const content = ev.buttons?.length ? (ev.button_prompt ?? ev.text) : ev.text; @@ -183,8 +235,12 @@ export function useNanobotStream( return () => { unsub(); buffer.current = null; + if (streamEndTimerRef.current !== null) { + clearTimeout(streamEndTimerRef.current); + streamEndTimerRef.current = null; + } }; - }, [chatId, client]); + }, [chatId, client, onTurnEnd]); const send = useCallback( (content: string, images?: SendImage[]) => { @@ -205,6 +261,9 @@ export function useNanobotStream( ...(previews ? { images: previews } : {}), }, ]); + // Mark streaming immediately so the UI shows the loading indicator + // right away, before the first delta arrives from the server. + setIsStreaming(true); const wireMedia = hasImages ? images!.map((i) => i.media) : undefined; client.sendMessage(chatId, content, wireMedia); }, diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index 719d4ce16..e05e16a20 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -61,6 +61,7 @@ export function useSessions(): { chatId, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + title: "", preview: "", }, ...prev.filter((s) => s.key !== key), @@ -84,6 +85,9 @@ export function useSessionHistory(key: string | null): { messages: UIMessage[]; loading: boolean; error: string | null; + /** ``true`` when the last persisted assistant turn has ``tool_calls`` but no + * final text yet — the model was still processing when the page loaded. */ + hasPendingToolCalls: boolean; } { const { token } = useClient(); const [state, setState] = useState<{ @@ -91,11 +95,13 @@ export function useSessionHistory(key: string | null): { messages: UIMessage[]; loading: boolean; error: string | null; + hasPendingToolCalls: boolean; }>({ key: null, messages: [], loading: false, error: null, + hasPendingToolCalls: false, }); useEffect(() => { @@ -105,6 +111,7 @@ export function useSessionHistory(key: string | null): { messages: [], loading: false, error: null, + hasPendingToolCalls: false, }); return; } @@ -116,6 +123,7 @@ export function useSessionHistory(key: string | null): { messages: [], loading: true, error: null, + hasPendingToolCalls: false, }); (async () => { try { @@ -146,11 +154,21 @@ export function useSessionHistory(key: string | null): { }, ]; }); + // Tool result rows can trail the assistant tool-call row while the turn + // is still running, so check the last conversational row. + const lastRaw = [...body.messages] + .reverse() + .find((m) => m.role === "user" || m.role === "assistant"); + const hasPending = + lastRaw?.role === "assistant" && + Array.isArray(lastRaw.tool_calls) && + lastRaw.tool_calls.length > 0; setState({ key, messages: ui, loading: false, error: null, + hasPendingToolCalls: hasPending, }); } catch (e) { if (cancelled) return; @@ -162,6 +180,7 @@ export function useSessionHistory(key: string | null): { messages: [], loading: false, error: null, + hasPendingToolCalls: false, }); } else { setState({ @@ -169,6 +188,7 @@ export function useSessionHistory(key: string | null): { messages: [], loading: false, error: (e as Error).message, + hasPendingToolCalls: false, }); } } @@ -179,19 +199,20 @@ export function useSessionHistory(key: string | null): { }, [key, token]); if (!key) { - return { messages: EMPTY_MESSAGES, loading: false, error: null }; + return { messages: EMPTY_MESSAGES, loading: false, error: null, hasPendingToolCalls: false }; } // Even before the effect above commits its loading state, never surface the // previous session's payload for a brand-new key. if (state.key !== key) { - return { messages: EMPTY_MESSAGES, loading: true, error: null }; + return { messages: EMPTY_MESSAGES, loading: true, error: null, hasPendingToolCalls: false }; } return { messages: state.messages, loading: state.loading, error: state.error, + hasPendingToolCalls: state.hasPendingToolCalls, }; } @@ -201,7 +222,7 @@ export function sessionTitle( firstUserMessage?: string, ): string { return deriveTitle( - firstUserMessage || session.preview, + session.title || firstUserMessage || session.preview, i18n.t("chat.newChat"), ); } diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index aa6b3165b..8368d7ee7 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -9,6 +9,18 @@ "title": "Couldn't reach nanobot", "gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine." }, + "auth": { + "title": "Authentication required", + "hint": "Enter the secret configured as tokenIssueSecret in your gateway config.", + "placeholder": "Password", + "submit": "Connect", + "invalid": "Invalid password. Try again." + }, + "account": { + "section": "Account", + "logoutHint": "Disconnect this browser from the gateway.", + "logout": "Sign out" + }, "documentTitle": { "base": "nanobot", "chat": "{{title}} · nanobot" @@ -18,11 +30,19 @@ } }, "sidebar": { + "navigation": "Sidebar navigation", + "globalActions": "Global actions", "collapse": "Collapse sidebar", "toggleTheme": "Toggle theme", + "home": "Home", "newChat": "New chat", + "searchAria": "Search chats", + "searchPlaceholder": "Search chats", + "searchResults": "Results", + "noSearchResults": "No matching chats.", "recent": "Recent", "refreshSessions": "Refresh sessions", + "settings": "Settings", "language": { "label": "Language", "ariaLabel": "Change language" @@ -34,7 +54,12 @@ "noSessions": "No sessions yet.", "actions": "Chat actions for {{title}}", "delete": "Delete", - "newChat": "New chat" + "newChat": "New chat", + "groups": { + "today": "Today", + "yesterday": "Yesterday", + "earlier": "Earlier" + } }, "deleteConfirm": { "title": "Delete “{{title}}”?", @@ -53,19 +78,100 @@ "thread": { "loadingConversation": "Loading conversation…", "empty": { - "description": "Ask questions, continue local work, or start a new thread." + "greeting": "What can I do for you?", + "quickActions": { + "plan": { + "title": "Create a project plan", + "prompt": "Create a concise project plan for what I should build next." + }, + "analyze": { + "title": "Analyze this data", + "prompt": "Help me analyze this data and call out the most important patterns." + }, + "brainstorm": { + "title": "Brainstorm ideas", + "prompt": "Brainstorm a few practical ideas and tradeoffs for this problem." + }, + "code": { + "title": "Write code", + "prompt": "Help me write the code for this task, starting with the smallest useful change." + }, + "summarize": { + "title": "Summarize this document", + "prompt": "Summarize this document and list the key takeaways." + }, + "more": { + "title": "More", + "prompt": "Show me a few useful ways you can help in this workspace." + } + } }, "header": { - "toggleSidebar": "Toggle sidebar" + "toggleSidebar": "Toggle sidebar", + "newChat": "Start a new chat", + "toggleTheme": "Toggle theme from header", + "settings": "Open settings" }, "composer": { "placeholderThread": "Type your message…", - "placeholderHero": "What's on your mind?", + "placeholderHero": "Ask anything...", "placeholderOpening": "Opening a new chat…", + "placeholderStreaming": "Model is responding…", "inputAria": "Message input", "sendHint": "Enter to send · Shift+Enter for newline", "send": "Send message", "attachImage": "Attach image", + "tools": { + "search": "Search", + "reason": "Reason", + "deepResearch": "Deep research", + "voice": "Voice input" + }, + "slash": { + "ariaLabel": "Slash commands", + "label": "commands", + "navigateHint": "↑↓ Navigate", + "selectHint": "Enter/Tab Select", + "closeHint": "Esc Close", + "commands": { + "new": { + "title": "New chat", + "description": "Stop the current task and start a fresh conversation." + }, + "stop": { + "title": "Stop current task", + "description": "Cancel the active agent turn for this chat." + }, + "restart": { + "title": "Restart nanobot", + "description": "Restart the bot process in place." + }, + "status": { + "title": "Show status", + "description": "Display runtime, provider, and channel status." + }, + "history": { + "title": "Show conversation history", + "description": "Print the last N persisted conversation messages." + }, + "dream": { + "title": "Run Dream", + "description": "Manually trigger memory consolidation." + }, + "dream_log": { + "title": "Show Dream log", + "description": "Show what the last Dream consolidation changed." + }, + "dream_restore": { + "title": "Restore memory", + "description": "Revert memory to a previous Dream snapshot." + }, + "help": { + "title": "Show help", + "description": "List available slash commands." + } + } + }, "encoding": "Encoding…", "remove": "Remove attachment", "normalizedSizeHint": "{{orig}} → {{current}} (auto)", @@ -85,7 +191,9 @@ "assistantTyping": "Assistant is typing", "toolSingle": "Using a tool", "toolMany": "Used {{count}} tools", - "imageAttachment": "Image attachment" + "imageAttachment": "Image attachment", + "copyReply": "Copy reply", + "copiedReply": "Copied reply" }, "lightbox": { "title": "Image preview", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 93bef843e..80f809ae5 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -53,7 +53,34 @@ "thread": { "loadingConversation": "Cargando conversación…", "empty": { - "description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo." + "description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo.", + "greeting": "¿Qué puedo hacer por ti?", + "quickActions": { + "plan": { + "title": "Crear un plan de proyecto", + "prompt": "Crea un plan de proyecto conciso para lo que debería construir después." + }, + "analyze": { + "title": "Analizar estos datos", + "prompt": "Ayúdame a analizar estos datos y destaca los patrones más importantes." + }, + "brainstorm": { + "title": "Lluvia de ideas", + "prompt": "Propón algunas ideas prácticas y sus compensaciones para este problema." + }, + "code": { + "title": "Escribir código", + "prompt": "Ayúdame a escribir el código para esta tarea, empezando por el cambio útil más pequeño." + }, + "summarize": { + "title": "Resumir este documento", + "prompt": "Resume este documento y enumera las conclusiones clave." + }, + "more": { + "title": "Más", + "prompt": "Muéstrame algunas formas útiles en las que puedes ayudar en este workspace." + } + } }, "header": { "toggleSidebar": "Mostrar u ocultar la barra lateral" @@ -62,6 +89,7 @@ "placeholderThread": "Escribe tu mensaje…", "placeholderHero": "¿Qué tienes en mente?", "placeholderOpening": "Abriendo un nuevo chat…", + "placeholderStreaming": "El modelo está respondiendo…", "inputAria": "Entrada de mensaje", "sendHint": "Enter para enviar · Shift+Enter para nueva línea", "send": "Enviar mensaje", @@ -76,6 +104,51 @@ "decode_failed": "No se pudo decodificar esta imagen", "too_large": "Imagen demasiado grande — prueba una más pequeña", "io": "No se pudo leer este archivo" + }, + "slash": { + "ariaLabel": "Comandos slash", + "label": "comandos", + "navigateHint": "↑↓ Navegar", + "selectHint": "Enter/Tab Insertar", + "closeHint": "Esc Cerrar", + "commands": { + "new": { + "title": "Nuevo chat", + "description": "Detiene la tarea actual e inicia una conversación nueva." + }, + "stop": { + "title": "Detener tarea actual", + "description": "Cancela el turno activo del agent en este chat." + }, + "restart": { + "title": "Reiniciar nanobot", + "description": "Reinicia el proceso del bot en el mismo lugar." + }, + "status": { + "title": "Mostrar estado", + "description": "Muestra el estado del runtime, provider y channels." + }, + "history": { + "title": "Mostrar historial", + "description": "Imprime los últimos N mensajes persistidos de la conversación." + }, + "dream": { + "title": "Ejecutar Dream", + "description": "Activa manualmente la consolidación de memoria." + }, + "dream_log": { + "title": "Mostrar registro de Dream", + "description": "Muestra qué cambió la última consolidación Dream." + }, + "dream_restore": { + "title": "Restaurar memoria", + "description": "Revierte la memoria a una instantánea Dream anterior." + }, + "help": { + "title": "Mostrar ayuda", + "description": "Lista los comandos slash disponibles." + } + } } }, "scrollToBottom": "Desplazarse al final" diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index ba9e759b3..d5a37c9b0 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -53,7 +53,34 @@ "thread": { "loadingConversation": "Chargement de la conversation…", "empty": { - "description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil." + "description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil.", + "greeting": "Que puis-je faire pour vous ?", + "quickActions": { + "plan": { + "title": "Créer un plan de projet", + "prompt": "Créez un plan de projet concis pour ce que je devrais construire ensuite." + }, + "analyze": { + "title": "Analyser ces données", + "prompt": "Aidez-moi à analyser ces données et à faire ressortir les tendances les plus importantes." + }, + "brainstorm": { + "title": "Trouver des idées", + "prompt": "Proposez quelques idées pratiques et leurs compromis pour ce problème." + }, + "code": { + "title": "Écrire du code", + "prompt": "Aidez-moi à écrire le code pour cette tâche, en commençant par le plus petit changement utile." + }, + "summarize": { + "title": "Résumer ce document", + "prompt": "Résumez ce document et listez les points clés à retenir." + }, + "more": { + "title": "Plus", + "prompt": "Montrez-moi quelques façons utiles dont vous pouvez m’aider dans cet espace de travail." + } + } }, "header": { "toggleSidebar": "Afficher ou masquer la barre latérale" @@ -62,6 +89,7 @@ "placeholderThread": "Saisissez votre message…", "placeholderHero": "Qu’avez-vous en tête ?", "placeholderOpening": "Ouverture d’une nouvelle discussion…", + "placeholderStreaming": "Le modèle est en train de répondre…", "inputAria": "Champ de message", "sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne", "send": "Envoyer le message", @@ -76,6 +104,51 @@ "decode_failed": "Impossible de décoder cette image", "too_large": "Image trop grande — essayez-en une plus petite", "io": "Impossible de lire ce fichier" + }, + "slash": { + "ariaLabel": "Commandes slash", + "label": "commandes", + "navigateHint": "↑↓ Naviguer", + "selectHint": "Entrée/Tab Insérer", + "closeHint": "Échap Fermer", + "commands": { + "new": { + "title": "Nouvelle discussion", + "description": "Arrêter la tâche en cours et démarrer une nouvelle conversation." + }, + "stop": { + "title": "Arrêter la tâche en cours", + "description": "Annuler le tour agent actif pour cette discussion." + }, + "restart": { + "title": "Redémarrer nanobot", + "description": "Redémarrer le processus du bot sur place." + }, + "status": { + "title": "Afficher l’état", + "description": "Afficher l’état du runtime, du provider et des channels." + }, + "history": { + "title": "Afficher l’historique", + "description": "Afficher les N derniers messages persistés de la conversation." + }, + "dream": { + "title": "Lancer Dream", + "description": "Déclencher manuellement la consolidation de la mémoire." + }, + "dream_log": { + "title": "Afficher le journal Dream", + "description": "Afficher ce que la dernière consolidation Dream a changé." + }, + "dream_restore": { + "title": "Restaurer la mémoire", + "description": "Revenir à un instantané Dream précédent." + }, + "help": { + "title": "Afficher l’aide", + "description": "Lister les commandes slash disponibles." + } + } } }, "scrollToBottom": "Faire défiler vers le bas" diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 9775372cc..fdc5febfe 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -53,7 +53,34 @@ "thread": { "loadingConversation": "Memuat percakapan…", "empty": { - "description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru." + "description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru.", + "greeting": "Apa yang bisa saya bantu?", + "quickActions": { + "plan": { + "title": "Buat rencana proyek", + "prompt": "Buat rencana proyek ringkas untuk apa yang sebaiknya saya bangun berikutnya." + }, + "analyze": { + "title": "Analisis data ini", + "prompt": "Bantu saya menganalisis data ini dan soroti pola yang paling penting." + }, + "brainstorm": { + "title": "Brainstorm ide", + "prompt": "Brainstorm beberapa ide praktis dan tradeoff untuk masalah ini." + }, + "code": { + "title": "Tulis kode", + "prompt": "Bantu saya menulis kode untuk tugas ini, mulai dari perubahan berguna yang paling kecil." + }, + "summarize": { + "title": "Ringkas dokumen ini", + "prompt": "Ringkas dokumen ini dan daftar poin-poin utamanya." + }, + "more": { + "title": "Lainnya", + "prompt": "Tunjukkan beberapa cara berguna Anda dapat membantu di workspace ini." + } + } }, "header": { "toggleSidebar": "Tampilkan atau sembunyikan sidebar" @@ -62,6 +89,7 @@ "placeholderThread": "Ketik pesan Anda…", "placeholderHero": "Apa yang sedang Anda pikirkan?", "placeholderOpening": "Membuka obrolan baru…", + "placeholderStreaming": "Model sedang merespons…", "inputAria": "Input pesan", "sendHint": "Enter untuk kirim · Shift+Enter untuk baris baru", "send": "Kirim pesan", @@ -76,6 +104,51 @@ "decode_failed": "Tidak dapat mendekode gambar ini", "too_large": "Gambar terlalu besar — coba yang lebih kecil", "io": "Tidak dapat membaca file ini" + }, + "slash": { + "ariaLabel": "Perintah slash", + "label": "perintah", + "navigateHint": "↑↓ Pilih", + "selectHint": "Enter/Tab Sisipkan", + "closeHint": "Esc Tutup", + "commands": { + "new": { + "title": "Obrolan baru", + "description": "Hentikan tugas saat ini dan mulai percakapan baru." + }, + "stop": { + "title": "Hentikan tugas saat ini", + "description": "Batalkan giliran agent yang sedang aktif di chat ini." + }, + "restart": { + "title": "Mulai ulang nanobot", + "description": "Mulai ulang proses bot di tempat." + }, + "status": { + "title": "Tampilkan status", + "description": "Tampilkan status runtime, provider, dan channel." + }, + "history": { + "title": "Tampilkan riwayat", + "description": "Cetak N pesan percakapan tersimpan terbaru." + }, + "dream": { + "title": "Jalankan Dream", + "description": "Picu konsolidasi memori secara manual." + }, + "dream_log": { + "title": "Tampilkan log Dream", + "description": "Tampilkan perubahan dari konsolidasi Dream terakhir." + }, + "dream_restore": { + "title": "Pulihkan memori", + "description": "Kembalikan memori ke snapshot Dream sebelumnya." + }, + "help": { + "title": "Tampilkan bantuan", + "description": "Daftar perintah slash yang tersedia." + } + } } }, "scrollToBottom": "Gulir ke bawah" diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 6868dec5c..0fb012146 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -53,7 +53,34 @@ "thread": { "loadingConversation": "会話を読み込み中…", "empty": { - "description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。" + "description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。", + "greeting": "何をお手伝いしましょうか?", + "quickActions": { + "plan": { + "title": "プロジェクト計画を作成", + "prompt": "次に作るものについて、簡潔なプロジェクト計画を作成してください。" + }, + "analyze": { + "title": "このデータを分析", + "prompt": "このデータを分析し、最も重要なパターンを指摘してください。" + }, + "brainstorm": { + "title": "アイデアを出す", + "prompt": "この問題について、実用的なアイデアとトレードオフをいくつか出してください。" + }, + "code": { + "title": "コードを書く", + "prompt": "このタスクのコードを書くのを手伝ってください。まず最小限の有用な変更から始めてください。" + }, + "summarize": { + "title": "この文書を要約", + "prompt": "この文書を要約し、重要なポイントを列挙してください。" + }, + "more": { + "title": "その他", + "prompt": "このワークスペースであなたが手伝える便利な方法をいくつか見せてください。" + } + } }, "header": { "toggleSidebar": "サイドバーを切り替える" @@ -62,6 +89,7 @@ "placeholderThread": "メッセージを入力…", "placeholderHero": "何を考えていますか?", "placeholderOpening": "新しいチャットを開いています…", + "placeholderStreaming": "モデルが応答しています…", "inputAria": "メッセージ入力欄", "sendHint": "Enter で送信 · Shift+Enter で改行", "send": "メッセージを送信", @@ -76,6 +104,51 @@ "decode_failed": "この画像をデコードできません", "too_large": "画像が大きすぎます。小さいものを選んでください", "io": "このファイルを読み込めません" + }, + "slash": { + "ariaLabel": "スラッシュコマンド", + "label": "コマンド", + "navigateHint": "↑↓ 選択", + "selectHint": "Enter/Tab 入力", + "closeHint": "Esc 閉じる", + "commands": { + "new": { + "title": "新しいチャット", + "description": "現在のタスクを停止して、新しい会話を開始します。" + }, + "stop": { + "title": "現在のタスクを停止", + "description": "このチャットで実行中の agent ターンをキャンセルします。" + }, + "restart": { + "title": "nanobot を再起動", + "description": "bot プロセスをその場で再起動します。" + }, + "status": { + "title": "ステータスを表示", + "description": "ランタイム、provider、channel の状態を表示します。" + }, + "history": { + "title": "会話履歴を表示", + "description": "保存済みの直近 N 件の会話メッセージを表示します。" + }, + "dream": { + "title": "Dream を実行", + "description": "メモリ統合を手動で開始します。" + }, + "dream_log": { + "title": "Dream ログを表示", + "description": "直近の Dream 統合で変更された内容を表示します。" + }, + "dream_restore": { + "title": "メモリを復元", + "description": "以前の Dream スナップショットへメモリを戻します。" + }, + "help": { + "title": "ヘルプを表示", + "description": "利用可能なスラッシュコマンドを一覧表示します。" + } + } } }, "scrollToBottom": "一番下へスクロール" diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index bb89af259..75ecaf147 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -53,7 +53,34 @@ "thread": { "loadingConversation": "대화 불러오는 중…", "empty": { - "description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다." + "description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다.", + "greeting": "무엇을 도와드릴까요?", + "quickActions": { + "plan": { + "title": "프로젝트 계획 만들기", + "prompt": "다음에 만들 것에 대한 간결한 프로젝트 계획을 작성해 주세요." + }, + "analyze": { + "title": "이 데이터 분석하기", + "prompt": "이 데이터를 분석하고 가장 중요한 패턴을 짚어 주세요." + }, + "brainstorm": { + "title": "아이디어 브레인스토밍", + "prompt": "이 문제에 대한 실용적인 아이디어와 트레이드오프를 몇 가지 제안해 주세요." + }, + "code": { + "title": "코드 작성하기", + "prompt": "이 작업을 위한 코드를 작성해 주세요. 가장 작은 유용한 변경부터 시작해 주세요." + }, + "summarize": { + "title": "문서 요약하기", + "prompt": "이 문서를 요약하고 핵심 내용을 정리해 주세요." + }, + "more": { + "title": "더 보기", + "prompt": "이 워크스페이스에서 도와줄 수 있는 유용한 방법을 몇 가지 보여 주세요." + } + } }, "header": { "toggleSidebar": "사이드바 전환" @@ -62,6 +89,7 @@ "placeholderThread": "메시지를 입력하세요…", "placeholderHero": "무슨 생각을 하고 있나요?", "placeholderOpening": "새 채팅을 여는 중…", + "placeholderStreaming": "모델이 응답 중입니다…", "inputAria": "메시지 입력", "sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈", "send": "메시지 보내기", @@ -76,6 +104,51 @@ "decode_failed": "이 이미지를 디코딩할 수 없습니다", "too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요", "io": "이 파일을 읽을 수 없습니다" + }, + "slash": { + "ariaLabel": "슬래시 명령", + "label": "명령", + "navigateHint": "↑↓ 선택", + "selectHint": "Enter/Tab 입력", + "closeHint": "Esc 닫기", + "commands": { + "new": { + "title": "새 채팅", + "description": "현재 작업을 중지하고 새 대화를 시작합니다." + }, + "stop": { + "title": "현재 작업 중지", + "description": "이 채팅에서 실행 중인 agent 턴을 취소합니다." + }, + "restart": { + "title": "nanobot 재시작", + "description": "bot 프로세스를 제자리에서 재시작합니다." + }, + "status": { + "title": "상태 보기", + "description": "런타임, provider, channel 상태를 표시합니다." + }, + "history": { + "title": "대화 기록 보기", + "description": "저장된 최근 N개의 대화 메시지를 출력합니다." + }, + "dream": { + "title": "Dream 실행", + "description": "메모리 정리를 수동으로 트리거합니다." + }, + "dream_log": { + "title": "Dream 로그 보기", + "description": "마지막 Dream 정리에서 변경된 내용을 표시합니다." + }, + "dream_restore": { + "title": "메모리 복원", + "description": "이전 Dream 스냅샷으로 메모리를 되돌립니다." + }, + "help": { + "title": "도움말 보기", + "description": "사용 가능한 슬래시 명령을 나열합니다." + } + } } }, "scrollToBottom": "맨 아래로 스크롤" diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index f2b64e33b..5e2f713a4 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -53,7 +53,34 @@ "thread": { "loadingConversation": "Đang tải cuộc trò chuyện…", "empty": { - "description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới." + "description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới.", + "greeting": "Tôi có thể giúp gì cho bạn?", + "quickActions": { + "plan": { + "title": "Tạo kế hoạch dự án", + "prompt": "Tạo một kế hoạch dự án ngắn gọn cho việc tôi nên xây dựng tiếp theo." + }, + "analyze": { + "title": "Phân tích dữ liệu này", + "prompt": "Giúp tôi phân tích dữ liệu này và chỉ ra các mẫu quan trọng nhất." + }, + "brainstorm": { + "title": "Động não ý tưởng", + "prompt": "Động não vài ý tưởng thực tế và các đánh đổi cho vấn đề này." + }, + "code": { + "title": "Viết mã", + "prompt": "Giúp tôi viết mã cho nhiệm vụ này, bắt đầu từ thay đổi hữu ích nhỏ nhất." + }, + "summarize": { + "title": "Tóm tắt tài liệu này", + "prompt": "Tóm tắt tài liệu này và liệt kê các ý chính." + }, + "more": { + "title": "Thêm", + "prompt": "Cho tôi xem vài cách hữu ích mà bạn có thể giúp trong workspace này." + } + } }, "header": { "toggleSidebar": "Bật/tắt thanh bên" @@ -62,6 +89,7 @@ "placeholderThread": "Nhập tin nhắn…", "placeholderHero": "Bạn đang nghĩ gì?", "placeholderOpening": "Đang mở cuộc trò chuyện mới…", + "placeholderStreaming": "Mô hình đang trả lời…", "inputAria": "Ô nhập tin nhắn", "sendHint": "Enter để gửi · Shift+Enter để xuống dòng", "send": "Gửi tin nhắn", @@ -76,6 +104,51 @@ "decode_failed": "Không thể giải mã ảnh này", "too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn", "io": "Không thể đọc tệp này" + }, + "slash": { + "ariaLabel": "Lệnh slash", + "label": "lệnh", + "navigateHint": "↑↓ Chọn", + "selectHint": "Enter/Tab Chèn", + "closeHint": "Esc Đóng", + "commands": { + "new": { + "title": "Cuộc trò chuyện mới", + "description": "Dừng tác vụ hiện tại và bắt đầu một cuộc trò chuyện mới." + }, + "stop": { + "title": "Dừng tác vụ hiện tại", + "description": "Hủy lượt agent đang chạy trong cuộc trò chuyện này." + }, + "restart": { + "title": "Khởi động lại nanobot", + "description": "Khởi động lại tiến trình bot tại chỗ." + }, + "status": { + "title": "Hiển thị trạng thái", + "description": "Hiển thị trạng thái runtime, provider và channel." + }, + "history": { + "title": "Hiển thị lịch sử", + "description": "In N tin nhắn hội thoại đã lưu gần nhất." + }, + "dream": { + "title": "Chạy Dream", + "description": "Kích hoạt thủ công quá trình hợp nhất bộ nhớ." + }, + "dream_log": { + "title": "Hiển thị nhật ký Dream", + "description": "Hiển thị những gì lần hợp nhất Dream gần nhất đã thay đổi." + }, + "dream_restore": { + "title": "Khôi phục bộ nhớ", + "description": "Đưa bộ nhớ về một snapshot Dream trước đó." + }, + "help": { + "title": "Hiển thị trợ giúp", + "description": "Liệt kê các lệnh slash có sẵn." + } + } } }, "scrollToBottom": "Cuộn xuống cuối" diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 349e2625c..88334f358 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -18,11 +18,19 @@ } }, "sidebar": { + "navigation": "侧边栏导航", + "globalActions": "全局操作", "collapse": "收起侧边栏", "toggleTheme": "切换主题", + "home": "首页", "newChat": "新建对话", + "searchAria": "搜索会话", + "searchPlaceholder": "搜索会话", + "searchResults": "搜索结果", + "noSearchResults": "没有匹配的会话。", "recent": "最近对话", "refreshSessions": "刷新会话", + "settings": "设置", "language": { "label": "语言", "ariaLabel": "切换语言" @@ -34,7 +42,12 @@ "noSessions": "还没有会话。", "actions": "“{{title}}” 的会话操作", "delete": "删除", - "newChat": "新建对话" + "newChat": "新建对话", + "groups": { + "today": "今天", + "yesterday": "昨天", + "earlier": "更早" + } }, "deleteConfirm": { "title": "删除“{{title}}”?", @@ -53,19 +66,100 @@ "thread": { "loadingConversation": "正在加载对话…", "empty": { - "description": "可以提问、继续本地工作,或者开启一个新线程。" + "greeting": "我可以帮你做什么?", + "quickActions": { + "plan": { + "title": "创建项目计划", + "prompt": "帮我为接下来要做的事情写一份简洁的项目计划。" + }, + "analyze": { + "title": "分析这些数据", + "prompt": "帮我分析这些数据,并指出最重要的模式。" + }, + "brainstorm": { + "title": "头脑风暴想法", + "prompt": "围绕这个问题头脑风暴几个实用方案,并说明取舍。" + }, + "code": { + "title": "编写代码", + "prompt": "帮我为这个任务写代码,先从最小可用改动开始。" + }, + "summarize": { + "title": "总结这份文档", + "prompt": "帮我总结这份文档,并列出关键要点。" + }, + "more": { + "title": "更多", + "prompt": "展示几个你在这个工作区里可以帮我的实用方式。" + } + } }, "header": { - "toggleSidebar": "切换侧边栏" + "toggleSidebar": "切换侧边栏", + "newChat": "从顶部新建对话", + "toggleTheme": "从顶部切换主题", + "settings": "打开设置" }, "composer": { "placeholderThread": "输入消息…", - "placeholderHero": "你在想什么?", + "placeholderHero": "问任何问题...", "placeholderOpening": "正在打开新对话…", + "placeholderStreaming": "模型正在回复…", "inputAria": "消息输入框", "sendHint": "Enter 发送 · Shift+Enter 换行", "send": "发送消息", "attachImage": "添加图片", + "tools": { + "search": "搜索", + "reason": "推理", + "deepResearch": "深度研究", + "voice": "语音输入" + }, + "slash": { + "ariaLabel": "斜杠命令", + "label": "命令", + "navigateHint": "↑↓ 选择", + "selectHint": "Enter/Tab 填入", + "closeHint": "Esc 关闭", + "commands": { + "new": { + "title": "新建对话", + "description": "停止当前任务,并开始一个新的对话。" + }, + "stop": { + "title": "停止当前任务", + "description": "取消这个对话中正在运行的 agent 回合。" + }, + "restart": { + "title": "重启 nanobot", + "description": "原地重启 bot 进程。" + }, + "status": { + "title": "查看状态", + "description": "显示运行时、provider 和 channel 状态。" + }, + "history": { + "title": "查看对话历史", + "description": "打印最近 N 条已持久化的对话消息。" + }, + "dream": { + "title": "运行 Dream", + "description": "手动触发记忆整理。" + }, + "dream_log": { + "title": "查看 Dream 日志", + "description": "查看上一次 Dream 整理改变了什么。" + }, + "dream_restore": { + "title": "恢复记忆", + "description": "将记忆恢复到之前的 Dream 快照。" + }, + "help": { + "title": "查看帮助", + "description": "列出可用的斜杠命令。" + } + } + }, "encoding": "处理中…", "remove": "移除附件", "normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)", @@ -85,7 +179,9 @@ "assistantTyping": "助手正在输入", "toolSingle": "正在使用工具", "toolMany": "已使用 {{count}} 个工具", - "imageAttachment": "图片附件" + "imageAttachment": "图片附件", + "copyReply": "复制回复", + "copiedReply": "已复制回复" }, "lightbox": { "title": "图片预览", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index b8a1e83da..5a3b7f1d6 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -53,7 +53,34 @@ "thread": { "loadingConversation": "正在載入對話…", "empty": { - "description": "你可以提問、延續本地工作,或是開始新的執行緒。" + "description": "你可以提問、延續本地工作,或是開始新的執行緒。", + "greeting": "我可以幫你做什麼?", + "quickActions": { + "plan": { + "title": "建立專案計畫", + "prompt": "幫我為接下來要做的事情寫一份簡潔的專案計畫。" + }, + "analyze": { + "title": "分析這些資料", + "prompt": "幫我分析這些資料,並指出最重要的模式。" + }, + "brainstorm": { + "title": "腦力激盪想法", + "prompt": "圍繞這個問題腦力激盪幾個實用方案,並說明取捨。" + }, + "code": { + "title": "撰寫程式碼", + "prompt": "幫我為這個任務撰寫程式碼,先從最小可用改動開始。" + }, + "summarize": { + "title": "總結這份文件", + "prompt": "幫我總結這份文件,並列出關鍵重點。" + }, + "more": { + "title": "更多", + "prompt": "展示幾個你在這個工作區裡可以幫我的實用方式。" + } + } }, "header": { "toggleSidebar": "切換側邊欄" @@ -62,6 +89,7 @@ "placeholderThread": "輸入訊息…", "placeholderHero": "你在想什麼?", "placeholderOpening": "正在開啟新對話…", + "placeholderStreaming": "模型正在回覆…", "inputAria": "訊息輸入框", "sendHint": "Enter 送出 · Shift+Enter 換行", "send": "送出訊息", @@ -76,6 +104,51 @@ "decode_failed": "無法解碼這張圖片", "too_large": "圖片太大,請換一張小一點的", "io": "無法讀取這個檔案" + }, + "slash": { + "ariaLabel": "斜線命令", + "label": "命令", + "navigateHint": "↑↓ 選擇", + "selectHint": "Enter/Tab 填入", + "closeHint": "Esc 關閉", + "commands": { + "new": { + "title": "新增對話", + "description": "停止目前任務,並開始新的對話。" + }, + "stop": { + "title": "停止目前任務", + "description": "取消這個對話中正在執行的 agent 回合。" + }, + "restart": { + "title": "重新啟動 nanobot", + "description": "原地重新啟動 bot 進程。" + }, + "status": { + "title": "查看狀態", + "description": "顯示執行環境、provider 和 channel 狀態。" + }, + "history": { + "title": "查看對話歷史", + "description": "列印最近 N 則已持久化的對話訊息。" + }, + "dream": { + "title": "執行 Dream", + "description": "手動觸發記憶整理。" + }, + "dream_log": { + "title": "查看 Dream 日誌", + "description": "查看上一次 Dream 整理變更了什麼。" + }, + "dream_restore": { + "title": "恢復記憶", + "description": "將記憶恢復到之前的 Dream 快照。" + }, + "help": { + "title": "查看說明", + "description": "列出可用的斜線命令。" + } + } } }, "scrollToBottom": "捲動到底部" diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 56fed32c7..453297862 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { ChatSummary, SettingsPayload, SettingsUpdate } from "./types"; +import type { ChatSummary, SettingsPayload, SettingsUpdate, SlashCommand } from "./types"; export class ApiError extends Error { status: number; @@ -42,6 +42,7 @@ export async function listSessions( key: string; created_at: string | null; updated_at: string | null; + title?: string; preview?: string; }; const body = await request<{ sessions: Row[] }>( @@ -53,6 +54,7 @@ export async function listSessions( ...splitKey(s.key), createdAt: s.created_at, updatedAt: s.updated_at, + title: s.title ?? "", preview: s.preview ?? "", })); } @@ -112,6 +114,27 @@ export async function fetchSettings( return request(`${base}/api/settings`, token); } +export async function listSlashCommands( + token: string, + base: string = "", +): Promise { + type Row = { + command: string; + title: string; + description: string; + icon: string; + arg_hint?: string; + }; + const body = await request<{ commands: Row[] }>(`${base}/api/commands`, token); + return body.commands.map((command) => ({ + command: command.command, + title: command.title, + description: command.description, + icon: command.icon, + argHint: command.arg_hint ?? "", + })); +} + export async function updateSettings( token: string, update: SettingsUpdate, diff --git a/webui/src/lib/bootstrap.ts b/webui/src/lib/bootstrap.ts index 66d2b5958..931484a87 100644 --- a/webui/src/lib/bootstrap.ts +++ b/webui/src/lib/bootstrap.ts @@ -1,15 +1,51 @@ import type { BootstrapResponse } from "./types"; +const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret"; + +/** Read a previously saved bootstrap secret from localStorage. */ +export function loadSavedSecret(): string { + if (typeof window === "undefined") return ""; + try { + return window.localStorage.getItem(SECRET_STORAGE_KEY) ?? ""; + } catch { + return ""; + } +} + +/** Persist the bootstrap secret so page reloads don't re-prompt. */ +export function saveSecret(secret: string): void { + try { + window.localStorage.setItem(SECRET_STORAGE_KEY, secret); + } catch { + // ignore storage errors (private mode, etc.) + } +} + +/** Clear the saved bootstrap secret (sign out). */ +export function clearSavedSecret(): void { + try { + window.localStorage.removeItem(SECRET_STORAGE_KEY); + } catch { + // ignore + } +} + /** * Fetch a short-lived token + the WebSocket path from the gateway's - * ``/webui/bootstrap`` endpoint. Localhost-only on the server side. + * ``/webui/bootstrap`` endpoint. */ export async function fetchBootstrap( baseUrl: string = "", + secret: string = "", ): Promise { + const headers: Record = {}; + if (secret) { + headers["X-Nanobot-Auth"] = secret; + } const res = await fetch(`${baseUrl}/webui/bootstrap`, { method: "GET", credentials: "same-origin", + headers, }); if (!res.ok) { throw new Error(`bootstrap failed: HTTP ${res.status}`); diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts index f5039f93f..2162cf439 100644 --- a/webui/src/lib/nanobot-client.ts +++ b/webui/src/lib/nanobot-client.ts @@ -185,8 +185,8 @@ export class NanobotClient { this.knownChats.add(chatId); const frame: Outbound = media && media.length > 0 - ? { type: "message", chat_id: chatId, content, media } - : { type: "message", chat_id: chatId, content }; + ? { type: "message", chat_id: chatId, content, media, webui: true } + : { type: "message", chat_id: chatId, content, webui: true }; this.queueSend(frame); } diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 1b857a171..cc5e7ae29 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -56,6 +56,7 @@ export interface ChatSummary { chatId: string; createdAt: string | null; updatedAt: string | null; + title?: string; preview: string; } @@ -88,6 +89,14 @@ export interface SettingsUpdate { provider?: string; } +export interface SlashCommand { + command: string; + title: string; + description: string; + icon: string; + argHint?: string; +} + export type ConnectionStatus = | "idle" | "connecting" @@ -124,6 +133,8 @@ export type InboundEvent = chat_id: string; stream_id?: string; } + | { event: "turn_end"; chat_id: string } + | { event: "session_updated"; chat_id: string } | { event: "error"; chat_id?: string; detail?: string }; /** Base64-encoded image attached to an outbound ``message`` envelope. @@ -147,4 +158,7 @@ export type Outbound = chat_id: string; content: string; media?: OutboundMedia[]; + /** Marks messages sent by the embedded WebUI, without changing the + * generic websocket protocol for other clients. */ + webui?: true; }; diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index aab940d5c..aa44651f5 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -1,6 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { deleteSession, fetchSessionMessages, updateSettings } from "@/lib/api"; +import { + deleteSession, + fetchSessionMessages, + listSessions, + listSlashCommands, + updateSettings, +} from "@/lib/api"; describe("webui API helpers", () => { beforeEach(() => { @@ -48,4 +54,61 @@ describe("webui API helpers", () => { }), ); }); + + it("maps generated session titles from the sessions list", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + sessions: [ + { + key: "websocket:chat-1", + created_at: "2026-05-01T10:00:00", + updated_at: "2026-05-01T10:01:00", + title: "优化 WebUI 标题", + }, + ], + }), + } as Response); + + await expect(listSessions("tok")).resolves.toMatchObject([ + { + key: "websocket:chat-1", + title: "优化 WebUI 标题", + preview: "", + }, + ]); + }); + + it("maps slash command metadata from the commands endpoint", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + commands: [ + { + command: "/history", + title: "Show conversation history", + description: "Print the last N messages.", + icon: "history", + arg_hint: "[n]", + }, + ], + }), + } as Response); + + await expect(listSlashCommands("tok")).resolves.toEqual([ + { + command: "/history", + title: "Show conversation history", + description: "Print the last N messages.", + icon: "history", + argHint: "[n]", + }, + ]); + expect(fetch).toHaveBeenCalledWith( + "/api/commands", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); }); diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 77b9420dd..25248230e 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ChatSummary } from "@/lib/types"; @@ -7,6 +7,7 @@ const connectSpy = vi.fn(); const refreshSpy = vi.fn(); const createChatSpy = vi.fn().mockResolvedValue("chat-1"); const deleteChatSpy = vi.fn(); +const toggleThemeSpy = vi.fn(); let mockSessions: ChatSummary[] = []; vi.mock("@/hooks/useSessions", async (importOriginal) => { @@ -34,7 +35,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => { vi.mock("@/hooks/useTheme", () => ({ useTheme: () => ({ theme: "light" as const, - toggle: vi.fn(), + toggle: toggleThemeSpy, }), })); @@ -45,6 +46,9 @@ vi.mock("@/lib/bootstrap", () => ({ expires_in: 300, }), deriveWsUrl: vi.fn(() => "ws://test"), + loadSavedSecret: vi.fn(() => ""), + saveSecret: vi.fn(), + clearSavedSecret: vi.fn(), })); vi.mock("@/lib/nanobot-client", () => { @@ -74,6 +78,7 @@ describe("App layout", () => { refreshSpy.mockReset(); createChatSpy.mockClear(); deleteChatSpy.mockReset(); + toggleThemeSpy.mockReset(); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ @@ -121,8 +126,11 @@ describe("App layout", () => { render(); await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); await waitFor(() => - expect(screen.getByRole("button", { name: /^First chat$/ })).toBeInTheDocument(), + expect( + within(sidebar).getByRole("button", { name: /^First chat$/ }), + ).toBeInTheDocument(), ); fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), { @@ -140,14 +148,24 @@ describe("App layout", () => { ); await waitFor(() => expect( - screen.getByRole("button", { name: /^Second chat$/ }), + within(sidebar).getByRole("button", { name: /^Second chat$/ }), ).toBeInTheDocument(), ); expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument(); expect(document.body.style.pointerEvents).not.toBe("none"); }, 15_000); - it("opens the Cursor-style settings view from the sidebar", async () => { + it("opens the Cursor-style settings view from the header", async () => { + mockSessions = [ + { + key: "websocket:chat-a", + channel: "websocket", + chatId: "chat-a", + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:00:00Z", + preview: "Existing chat", + }, + ]; vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { @@ -180,10 +198,95 @@ describe("App layout", () => { render(); await waitFor(() => expect(connectSpy).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Settings" })); + fireEvent.click(screen.getByRole("button", { name: "Open settings" })); expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument(); expect(screen.getByText("AI")).toBeInTheDocument(); expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument(); }); + + it("filters sidebar sessions through the lightweight search row", async () => { + mockSessions = [ + { + key: "websocket:chat-alpha", + channel: "websocket", + chatId: "chat-alpha", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + preview: "Project planning notes", + }, + { + key: "websocket:chat-beta", + channel: "websocket", + chatId: "chat-beta", + createdAt: "2026-04-15T10:00:00Z", + updatedAt: "2026-04-15T10:00:00Z", + preview: "Travel ideas", + }, + ]; + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + expect(within(sidebar).getByText("Project planning notes")).toBeInTheDocument(); + expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument(); + + fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), { + target: { value: "travel" }, + }); + + expect(within(sidebar).queryByText("Project planning notes")).not.toBeInTheDocument(); + expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument(); + }); + + it("opens a blank start page without creating an empty chat", async () => { + mockSessions = [ + { + key: "websocket:chat-a", + channel: "websocket", + chatId: "chat-a", + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:00:00Z", + preview: "Existing chat", + }, + ]; + + const matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("1024px"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + vi.stubGlobal("matchMedia", matchMedia); + + const { container } = render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + + fireEvent.click(screen.getByRole("button", { name: "Toggle theme from header" })); + expect(toggleThemeSpy).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" })); + const desktopAside = container.querySelector("aside.lg\\:block") as HTMLElement; + await waitFor(() => expect(desktopAside.style.width).toBe("0px")); + + expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" })); + await waitFor(() => expect(desktopAside.style.width).toBe("272px")); + + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + fireEvent.click(within(sidebar).getByRole("button", { name: "New chat" })); + expect(createChatSpy).not.toHaveBeenCalled(); + expect(screen.getByText("What can I do for you?")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Toggle theme from header" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open settings" })).toBeInTheDocument(); + + expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument(); + }); }); diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx index 66b029577..fb4496f71 100644 --- a/webui/src/tests/i18n.test.tsx +++ b/webui/src/tests/i18n.test.tsx @@ -4,6 +4,9 @@ import { describe, expect, it, vi } from "vitest"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { ThreadComposer } from "@/components/thread/ThreadComposer"; +import { resources } from "@/i18n"; + +const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"]; describe("webui i18n", () => { it("switches UI copy and document locale through the language switcher", async () => { @@ -41,4 +44,16 @@ describe("webui i18n", () => { expect(screen.getByLabelText("メッセージ入力欄")).toBeInTheDocument(); }); + + it("keeps welcome quick actions localized for every registered locale", () => { + for (const resource of Object.values(resources)) { + const empty = resource.common.thread.empty; + expect(empty.greeting).toBeTruthy(); + for (const key of QUICK_ACTION_KEYS) { + const action = empty.quickActions[key as keyof typeof empty.quickActions]; + expect(action.title).toBeTruthy(); + expect(action.prompt).toBeTruthy(); + } + } + }); }); diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx index e8dec29ab..773c143c7 100644 --- a/webui/src/tests/message-bubble.test.tsx +++ b/webui/src/tests/message-bubble.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; import { MessageBubble } from "@/components/MessageBubble"; import type { UIMessage } from "@/lib/types"; @@ -19,6 +19,44 @@ describe("MessageBubble", () => { expect(row).toHaveClass("ml-auto", "flex"); expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]"); + expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument(); + }); + + it("copies completed assistant replies from the action row", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + const message: UIMessage = { + id: "a-copy", + role: "assistant", + content: "I can help with the next step.", + createdAt: Date.now(), + }; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Copy reply" })); + + expect(writeText).toHaveBeenCalledWith("I can help with the next step."); + await waitFor(() => + expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(), + ); + }); + + it("does not show copy actions for streaming placeholders", () => { + const message: UIMessage = { + id: "a-streaming", + role: "assistant", + content: "", + isStreaming: true, + createdAt: Date.now(), + }; + + render(); + + expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument(); }); it("renders trace messages as collapsible tool groups", () => { diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts index b95ef6804..4c7923999 100644 --- a/webui/src/tests/nanobot-client.test.ts +++ b/webui/src/tests/nanobot-client.test.ts @@ -116,7 +116,7 @@ describe("NanobotClient", () => { // Attach is sent first because sendMessage adds to knownChats, which // handleOpen re-attaches; then the queued message follows. expect(lastSocket().sent).toContain( - JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello" }), + JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello", webui: true }), ); }); @@ -196,6 +196,7 @@ describe("NanobotClient", () => { chat_id: "chat-x", content: "look", media: [{ data_url: "data:image/png;base64,AAAA", name: "shot.png" }], + webui: true, }); }); @@ -214,6 +215,7 @@ describe("NanobotClient", () => { type: "message", chat_id: "chat-x", content: "hello", + webui: true, }); }); diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 17205fb67..9e776291a 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -1,7 +1,24 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ThreadComposer } from "@/components/thread/ThreadComposer"; +import type { SlashCommand } from "@/lib/types"; + +const COMMANDS: SlashCommand[] = [ + { + command: "/stop", + title: "Stop current task", + description: "Cancel the active agent turn.", + icon: "square", + }, + { + command: "/history", + title: "Show conversation history", + description: "Print the last N persisted messages.", + icon: "history", + argHint: "[n]", + }, +]; describe("ThreadComposer", () => { it("renders a readonly hero model composer when provided", () => { @@ -9,15 +26,69 @@ describe("ThreadComposer", () => { , ); expect(screen.getByText("claude-opus-4-5")).toBeInTheDocument(); - const input = screen.getByPlaceholderText("What's on your mind?"); + expect(screen.queryByRole("button", { name: "Search" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Reason" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Deep research" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Voice input" })).not.toBeInTheDocument(); + const input = screen.getByPlaceholderText("Ask anything..."); expect(input).toBeInTheDocument(); - expect(input.className).toContain("min-h-[96px]"); - expect(input.parentElement?.className).toContain("max-w-[40rem]"); + expect(input.className).toContain("min-h-[78px]"); + expect(input.parentElement?.className).toContain("max-w-[58rem]"); + }); + + it("keeps the thread composer compact while matching the hero style", () => { + render( + , + ); + + expect(screen.getByText("gpt-4o")).toBeInTheDocument(); + const input = screen.getByPlaceholderText("Type your message..."); + expect(input.className).toContain("min-h-[50px]"); + expect(input.parentElement?.className).toContain("max-w-[49.5rem]"); + expect(input.parentElement?.className).toContain("rounded-[22px]"); + expect(input.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]"); + expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card"); + expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground"); + }); + + it("opens a slash command palette and inserts the selected command", () => { + const onSend = vi.fn(); + render( + , + ); + + const input = screen.getByLabelText("Message input"); + fireEvent.change(input, { target: { value: "/" } }); + + expect(screen.getByRole("listbox", { name: "Slash commands" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: /\/stop/i })).toHaveAttribute( + "aria-selected", + "true", + ); + + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(screen.getByRole("option", { name: /\/history/i })).toHaveAttribute( + "aria-selected", + "true", + ); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(input).toHaveValue("/history "); + expect(onSend).not.toHaveBeenCalled(); + expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument(); }); }); diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index d134fcce2..3dd47f6b8 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -86,6 +86,26 @@ describe("ThreadShell", () => { ); }); + it("does not navigate away when clicking the chat title", async () => { + const client = makeClient(); + const onGoHome = vi.fn(); + render(wrap( + client, + {}} + onGoHome={onGoHome} + onNewChat={() => {}} + />, + )); + + await waitFor(() => expect(screen.getByText("Important conversation")).toBeInTheDocument()); + fireEvent.click(screen.getByText("Important conversation")); + + expect(onGoHome).not.toHaveBeenCalled(); + }); + it("restores in-memory messages when switching away and back to a session", async () => { const client = makeClient(); const onNewChat = vi.fn().mockResolvedValue("chat-a"); @@ -199,7 +219,67 @@ describe("ThreadShell", () => { await waitFor(() => { expect(screen.queryByText("delete me cleanly")).not.toBeInTheDocument(); }); - expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument(); + }); + + it("creates a chat only when the blank landing sends a first message", async () => { + const client = makeClient(); + const onNewChat = vi.fn(); + const onCreateChat = vi.fn().mockResolvedValue("chat-new"); + + render( + wrap( + client, + {}} + onGoHome={() => {}} + onNewChat={onNewChat} + onCreateChat={onCreateChat} + />, + ), + ); + + fireEvent.change(screen.getByLabelText("Message input"), { + target: { value: "start for real" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + + await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1)); + expect(onNewChat).not.toHaveBeenCalled(); + }); + + it("sends quick action prompts from the empty thread landing", async () => { + const client = makeClient(); + const onNewChat = vi.fn().mockResolvedValue("chat-a"); + + render( + wrap( + client, + {}} + onGoHome={() => {}} + onNewChat={onNewChat} + />, + ), + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Write code" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Write code" })); + + await waitFor(() => + expect(client.sendMessage).toHaveBeenCalledWith( + "chat-a", + "Help me write the code for this task, starting with the smallest useful change.", + undefined, + ), + ); }); it("does not leak the previous thread when opening a brand-new chat", async () => { @@ -260,13 +340,232 @@ describe("ThreadShell", () => { expect(screen.queryByText("old answer")).not.toBeInTheDocument(); await waitFor(() => - expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument(), + expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument(), ); - const input = screen.getByPlaceholderText("What's on your mind?"); - expect(input.className).toContain("min-h-[96px]"); + const input = screen.getByPlaceholderText("Ask anything..."); + expect(input.className).toContain("min-h-[78px]"); expect(screen.queryByText("old answer")).not.toBeInTheDocument(); }); + it("does not cache optimistic messages under the next chat during a session switch", async () => { + const client = makeClient(); + const onNewChat = vi.fn().mockResolvedValue("chat-b"); + + const { rerender } = render( + wrap( + client, + {}} + onGoHome={() => {}} + onNewChat={onNewChat} + />, + ), + ); + + fireEvent.change(screen.getByLabelText("Message input"), { + target: { value: "only in chat a" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + + await waitFor(() => + expect(client.sendMessage).toHaveBeenCalledWith( + "chat-a", + "only in chat a", + undefined, + ), + ); + expect(screen.getByText("only in chat a")).toBeInTheDocument(); + + await act(async () => { + rerender( + wrap( + client, + {}} + onGoHome={() => {}} + onNewChat={onNewChat} + />, + ), + ); + }); + + await waitFor(() => { + expect(screen.queryByText("only in chat a")).not.toBeInTheDocument(); + }); + + await act(async () => { + rerender( + wrap( + client, + {}} + onGoHome={() => {}} + onNewChat={onNewChat} + />, + ), + ); + }); + + expect(screen.getByText("only in chat a")).toBeInTheDocument(); + + await act(async () => { + rerender( + wrap( + client, + {}} + onGoHome={() => {}} + onNewChat={onNewChat} + />, + ), + ); + }); + + await waitFor(() => { + expect(screen.queryByText("only in chat a")).not.toBeInTheDocument(); + }); + }); + + it("keeps live assistant replies after visiting the blank new-chat page", async () => { + const client = makeClient(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Achat-a/messages")) { + return httpJson({ + key: "websocket:chat-a", + created_at: null, + updated_at: null, + // Simulate a stale history response that has not persisted the + // just-received assistant reply yet. + messages: [{ role: "user", content: "hello" }], + }); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + const { rerender } = render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(screen.getByText("hello")).toBeInTheDocument()); + await act(async () => { + client._emitChat("chat-a", { + event: "message", + chat_id: "chat-a", + text: "live assistant reply", + }); + }); + expect(screen.getByText("live assistant reply")).toBeInTheDocument(); + + await act(async () => { + rerender( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + }); + + expect(screen.queryByText("live assistant reply")).not.toBeInTheDocument(); + expect(screen.getByText("What can I do for you?")).toBeInTheDocument(); + + await act(async () => { + rerender( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + }); + + await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument()); + }); + + it("does not open slash commands on the blank welcome page", async () => { + const client = makeClient(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/commands")) { + return httpJson({ + commands: [ + { + command: "/stop", + title: "Stop current task", + description: "Cancel the active agent turn.", + icon: "square", + }, + ], + }); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(fetch).toHaveBeenCalledWith( + "/api/commands", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + )); + + fireEvent.change(screen.getByLabelText("Message input"), { + target: { value: "/" }, + }); + + expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument(); + }); + it("surfaces a dismissible banner when the stream reports message_too_big", async () => { const client = makeClient(); const onNewChat = vi.fn().mockResolvedValue("chat-a"); @@ -287,6 +586,7 @@ describe("ThreadShell", () => { // No banner yet: only appears once the client emits a matching error. expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + await act(async () => {}); await act(async () => { client._emitError({ kind: "message_too_big" }); }); @@ -318,6 +618,7 @@ describe("ThreadShell", () => { ), ); + await act(async () => {}); await act(async () => { client._emitError({ kind: "message_too_big" }); }); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index f5adcf176..155ec118e 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -6,6 +6,8 @@ import { useNanobotStream } from "@/hooks/useNanobotStream"; import type { InboundEvent } from "@/lib/types"; import { ClientProvider } from "@/providers/ClientProvider"; +const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = []; + function fakeClient() { const handlers = new Map void>>(); return { @@ -51,9 +53,27 @@ function wrap(client: ReturnType["client"]) { } describe("useNanobotStream", () => { + it("starts in streaming mode when history shows pending tool calls", () => { + const fake = fakeClient(); + const initialMessages = [{ + id: "m1", + role: "assistant" as const, + content: "Using tools", + createdAt: Date.now(), + }]; + const { result } = renderHook( + () => useNanobotStream("chat-p", initialMessages, true), + { + wrapper: wrap(fake.client), + }, + ); + + expect(result.current.isStreaming).toBe(true); + }); + it("collapses consecutive tool_hint frames into one trace row", () => { const fake = fakeClient(); - const { result } = renderHook(() => useNanobotStream("chat-t", []), { + const { result } = renderHook(() => useNanobotStream("chat-t", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); @@ -95,7 +115,7 @@ describe("useNanobotStream", () => { it("attaches assistant media_urls to complete messages", () => { const fake = fakeClient(); - const { result } = renderHook(() => useNanobotStream("chat-m", []), { + const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); @@ -116,7 +136,7 @@ describe("useNanobotStream", () => { it("keeps assistant buttons on complete messages", () => { const fake = fakeClient(); - const { result } = renderHook(() => useNanobotStream("chat-q", []), { + const { result } = renderHook(() => useNanobotStream("chat-q", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); @@ -136,4 +156,79 @@ describe("useNanobotStream", () => { ["Short answer", "Detailed answer"], ]); }); + + it("keeps streaming alive across stream_end and completes on turn_end", () => { + const fake = fakeClient(); + const onTurnEnd = vi.fn(); + const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-s", { + event: "delta", + chat_id: "chat-s", + text: "Hello", + }); + }); + + expect(result.current.isStreaming).toBe(true); + expect(result.current.messages[0]).toMatchObject({ + role: "assistant", + content: "Hello", + isStreaming: true, + }); + + act(() => { + fake.emit("chat-s", { + event: "stream_end", + chat_id: "chat-s", + }); + }); + + expect(result.current.isStreaming).toBe(true); + expect(result.current.messages[0].isStreaming).toBe(true); + + act(() => { + fake.emit("chat-s", { + event: "message", + chat_id: "chat-s", + text: "Hello world", + }); + }); + + expect(result.current.isStreaming).toBe(true); + expect(result.current.messages.at(-1)).toMatchObject({ + role: "assistant", + content: "Hello world", + }); + + act(() => { + fake.emit("chat-s", { + event: "turn_end", + chat_id: "chat-s", + }); + }); + + expect(result.current.isStreaming).toBe(false); + expect(result.current.messages.every((message) => !message.isStreaming)).toBe(true); + expect(onTurnEnd).toHaveBeenCalledTimes(1); + }); + + it("refreshes session metadata when the server reports a session update", () => { + const fake = fakeClient(); + const onTurnEnd = vi.fn(); + renderHook(() => useNanobotStream("chat-title", EMPTY_MESSAGES, false, onTurnEnd), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-title", { + event: "session_updated", + chat_id: "chat-title", + }); + }); + + expect(onTurnEnd).toHaveBeenCalledTimes(1); + }); }); diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx index ad4f1c1af..4805c6567 100644 --- a/webui/src/tests/useSessions.test.tsx +++ b/webui/src/tests/useSessions.test.tsx @@ -170,6 +170,83 @@ describe("useSessions", () => { ]); }); + it("flags history with trailing assistant tool calls as still pending", async () => { + vi.mocked(api.fetchSessionMessages).mockResolvedValue({ + key: "websocket:chat-pending", + created_at: "2026-04-20T10:00:00Z", + updated_at: "2026-04-20T10:05:00Z", + messages: [ + { + role: "assistant", + content: "Using 2 tools", + timestamp: "2026-04-20T10:00:01Z", + tool_calls: [{ id: "call-1" }], + }, + ], + }); + + const { result } = renderHook(() => useSessionHistory("websocket:chat-pending"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.hasPendingToolCalls).toBe(true); + }); + + it("keeps pending when tool result rows trail assistant tool calls", async () => { + vi.mocked(api.fetchSessionMessages).mockResolvedValue({ + key: "websocket:chat-pending-tool-result", + created_at: "2026-04-20T10:00:00Z", + updated_at: "2026-04-20T10:05:00Z", + messages: [ + { + role: "assistant", + content: "Using 1 tool", + timestamp: "2026-04-20T10:00:01Z", + tool_calls: [{ id: "call-1" }], + }, + { + role: "tool", + content: "tool output", + timestamp: "2026-04-20T10:00:02Z", + tool_call_id: "call-1", + }, + ], + }); + + const { result } = renderHook(() => useSessionHistory("websocket:chat-pending-tool-result"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.hasPendingToolCalls).toBe(true); + }); + + it("does not flag history as pending once the assistant turn has no tool calls", async () => { + vi.mocked(api.fetchSessionMessages).mockResolvedValue({ + key: "websocket:chat-done", + created_at: "2026-04-20T10:00:00Z", + updated_at: "2026-04-20T10:05:00Z", + messages: [ + { + role: "assistant", + content: "All done", + timestamp: "2026-04-20T10:00:01Z", + }, + ], + }); + + const { result } = renderHook(() => useSessionHistory("websocket:chat-done"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.hasPendingToolCalls).toBe(false); + }); + it("keeps the session in the list when delete fails", async () => { vi.mocked(api.listSessions).mockResolvedValue([ {