Compare commits

...
Author SHA1 Message Date
Xubin Ren da8ffcd883 fix(apps): preserve legacy CLI app skills 2026-08-08 08:38:06 +08:00
Xubin Ren 93fd48d327 feat(apps): package CLI apps as Agent Plugins 2026-08-08 01:14:24 +08:00
Xubin Ren 2663bae186 feat(skills): load Agent Plugins v1 skills 2026-08-07 20:23:07 +08:00
chengyongruandchengyongru bd8d3ad5b6 fix(channels): preserve global progress defaults 2026-08-07 17:19:14 +08:00
chengyongruandGitHub 332c159b93 fix(weixin): harden protocol delivery, streaming, and login (#5263) 2026-08-07 16:53:31 +08:00
chengyongruandchengyongru edb3b7e446 fix(webui): preserve newly created topic route 2026-08-07 16:10:01 +08:00
chengyongruandchengyongru cdb2a474f9 refactor(webui): remove legacy session messages route 2026-08-07 15:16:57 +08:00
chengyongruandGitHub ff6deda178 fix: modernize dependency recovery guidance (#5282) 2026-08-07 13:58:13 +08:00
chengyongruandchengyongru 02a002a0e6 fix(webui): preserve activity text rendering 2026-08-07 13:04:42 +08:00
Xubin Ren 3836c32874 fix(webui): scope preset editor to one row 2026-08-07 12:42:37 +08:00
Xubin Ren 3fc69b2922 style(webui): inset expanded preset editor 2026-08-07 12:42:37 +08:00
Xubin Ren eb5d7e1a32 style(webui): distinguish expanded preset editor 2026-08-07 12:42:37 +08:00
Xubin Ren b77e1133cb fix(webui): preserve preset deletion workflow 2026-08-07 12:42:37 +08:00
Xubin Ren 1b12fbae39 fix(webui): explain disabled preset deletion 2026-08-07 12:42:37 +08:00
Xubin Ren 6f2512ce9a style(webui): retain model preset colors 2026-08-07 12:42:37 +08:00
Xubin Ren c8bc4d8510 fix(webui): make active model presets deletable 2026-08-07 12:42:37 +08:00
Xubin Ren e971e81b6c refactor(webui): expand model preset editor inline 2026-08-07 12:42:37 +08:00
Xubin Ren ada07aa799 feat(webui): add responsive model preset detail pane 2026-08-07 12:42:37 +08:00
chengyongruandchengyongru 2c7943a133 fix(memory): archive short idle sessions for Dream 2026-08-07 11:45:49 +08:00
chengyongruandchengyongru 8dfce4c162 fix(session): require user anchor for delivery retention 2026-08-07 10:53:55 +08:00
ziuusandchengyongru 60282d1588 fix(session): preserve proactive channel delivery during session retention trimming 2026-08-07 10:53:55 +08:00
Xubin Ren c2fd41b44d fix(webui): persist large sidebar ordering state 2026-08-06 19:11:05 +08:00
Xubin Ren 1d290614c9 fix(webui): align composer mention metrics 2026-08-06 19:11:05 +08:00
Xubin Ren 9af6bb91c7 fix(webui): preserve session drag contracts 2026-08-06 19:11:05 +08:00
Xubin Ren f44a766f98 feat(webui): preview dragged session mentions 2026-08-06 19:11:05 +08:00
Xubin Ren 9cf6cf0639 feat(webui): persist manual session ordering 2026-08-06 19:11:05 +08:00
Xubin Ren 2c8e63446f feat(webui): drag sessions into composer mentions 2026-08-06 19:11:05 +08:00
Orrin WittandGitHub 5c4c2cb819 fix(matrix): send non-empty POST body on room join for Continuwuity compatibility (#5248) 2026-08-06 18:29:57 +08:00
chengyongruandchengyongru 223b911e7e fix(webui): tighten interactive motion 2026-08-06 18:28:45 +08:00
94 changed files with 5366 additions and 985 deletions
+1 -1
View File
@@ -202,7 +202,7 @@ When changing tools, channels, file access, WebUI workspace behavior, or network
| Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
| MCP | Add `tools.mcpServers` config |
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
| Skill | Add workspace skills under `<workspace>/skills/`, Agent Plugins v1 under `<workspace>/plugins/`, or built-in skills under `nanobot/skills/` |
Prefer existing registry/discovery patterns over ad hoc wiring.
+30
View File
@@ -2306,6 +2306,36 @@ Disabled skills are excluded from the main agent's skill summary, from always-on
|--------|---------|-------------|
| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
### Agent Plugins v1 skills
nanobot also discovers portable [Agent Plugins](https://agent-plugins.org/) placed under
`<workspace>/plugins/<plugin>/`. A supported package has a root `plugin.json` that targets
Agent Plugins v1 and one or more direct-child skills:
```text
plugins/
└── release-tools/
├── plugin.json
└── skills/
└── release-notes/
└── SKILL.md
```
Plugin skills use the same progressive loading and `$skill-name` invocation as workspace
skills. A workspace skill wins when it has the same name as a plugin skill; plugin skills win
over built-in skills. Invalid manifests, invalid Agent Skills, nested skill directories, and
paths that resolve outside the plugin root are ignored.
This initial compatibility layer loads the portable `skills/` component only. Agent Plugins
`mcp.json` is not started automatically: local MCP servers execute third-party processes and
need an explicit trust and approval flow. Configure a reviewed MCP server through **Apps** or
`tools.mcpServers` for now.
CLI Apps installed from the WebUI use the same package layout. nanobot installs the CLI through
its catalog adapter, then writes a skills-only Agent Plugin under `<workspace>/plugins/`; updates
refresh that package and uninstall removes it. The external executable remains managed by the
CLI Apps installer rather than by the Agent Plugins manifest.
## Tool Hint Max Length
Tool hints are the short progress messages shown when the agent calls tools (e.g. `$ cd …/project && npm test`). By default, these are truncated at 40 characters, which can make long commands hard to read.
@@ -27,7 +27,7 @@ nanobot agent -m "Hello!"
Install Langfuse:
```bash
python -m pip install langfuse
nanobot plugins enable langfuse
```
## Minimal working example
+1 -1
View File
@@ -549,7 +549,7 @@ This recipe applies after the agent works and you want observability for OpenAI-
Install the optional package in the same Python environment that runs nanobot:
```bash
python -m pip install langfuse
nanobot plugins enable langfuse
```
Set the environment variables before starting nanobot:
+210
View File
@@ -0,0 +1,210 @@
"""Discover portable Agent Plugins from the agent workspace."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
import yaml
from loguru import logger
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
_PLUGIN_NAME = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
_SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
_SKILL_FRONTMATTER = re.compile(r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", re.DOTALL)
_MANIFEST_FIELDS = {
"$schema",
"name",
"version",
"description",
"author",
"homepage",
"repository",
"license",
"keywords",
"extensions",
}
_STRING_FIELDS = {"version", "description", "homepage", "repository", "license"}
_AUTHOR_FIELDS = {"name", "email", "url"}
@dataclass(frozen=True)
class AgentPluginSkill:
"""One skill supplied by a valid Agent Plugins v1 package."""
name: str
path: Path
plugin: str
def discover_agent_plugin_skills(workspace: Path) -> list[AgentPluginSkill]:
"""Discover direct-child skills under ``<workspace>/plugins/*``.
Agent Plugins does not prescribe an install location. nanobot uses the
workspace ``plugins`` directory so packages stay explicit and portable
with the rest of the agent workspace.
"""
workspace = workspace.expanduser().resolve()
plugins_root = workspace / "plugins"
if not plugins_root.is_dir():
return []
try:
resolved_plugins_root = plugins_root.resolve(strict=True)
except OSError:
return []
if not resolved_plugins_root.is_relative_to(workspace):
logger.warning("Ignoring Agent Plugins directory outside the workspace")
return []
try:
candidates = sorted(plugins_root.iterdir(), key=lambda path: path.name)
except OSError as exc:
logger.warning("Could not inspect Agent Plugins directory: {}", exc)
return []
skills: list[AgentPluginSkill] = []
for candidate in candidates:
plugin_root = _contained_directory(candidate, resolved_plugins_root)
if plugin_root is None:
continue
plugin_name = _load_manifest_name(plugin_root)
if plugin_name is None:
continue
skills.extend(_discover_plugin_skills(plugin_name, plugin_root))
return skills
def _load_manifest_name(plugin_root: Path) -> str | None:
manifest = _contained_file(plugin_root / "plugin.json", plugin_root)
if manifest is None:
return None
try:
value = cast(object, json.loads(manifest.read_text(encoding="utf-8")))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
logger.warning("Ignoring invalid Agent Plugin manifest '{}': {}", manifest, exc)
return None
if not isinstance(value, dict):
logger.warning("Ignoring Agent Plugin manifest '{}': expected a JSON object", manifest)
return None
payload = cast(dict[str, Any], value)
if payload.get("$schema") != AGENT_PLUGIN_SCHEMA:
return None
name = payload.get("name")
if (
not isinstance(name, str)
or len(name) > 64
or _PLUGIN_NAME.fullmatch(name) is None
):
logger.warning("Ignoring Agent Plugin manifest '{}': invalid name", manifest)
return None
if not _valid_optional_fields(payload):
logger.warning("Ignoring Agent Plugin manifest '{}': invalid metadata", manifest)
return None
for field in payload.keys() - _MANIFEST_FIELDS:
logger.warning("Ignoring unknown Agent Plugin manifest field '{}' in '{}'", field, manifest)
if "extensions" in payload and not isinstance(payload["extensions"], dict):
logger.warning("Ignoring non-object Agent Plugin extensions in '{}'", manifest)
return name
def _valid_optional_fields(payload: dict[str, Any]) -> bool:
if any(field in payload and not isinstance(payload[field], str) for field in _STRING_FIELDS):
return False
keywords = payload.get("keywords")
if "keywords" in payload and (
not isinstance(keywords, list)
or not all(isinstance(keyword, str) for keyword in cast(list[object], keywords))
):
return False
author = payload.get("author")
if "author" not in payload:
return True
if not isinstance(author, dict):
return False
author_payload = cast(dict[str, object], author)
return not (author_payload.keys() - _AUTHOR_FIELDS) and all(
isinstance(value, str) for value in author_payload.values()
)
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[AgentPluginSkill]:
skills_root = plugin_root / "skills"
if not skills_root.exists():
return []
resolved_skills_root = _contained_directory(skills_root, plugin_root)
if resolved_skills_root is None:
logger.warning("Ignoring invalid skills component in Agent Plugin '{}'", plugin_name)
return []
try:
candidates = sorted(skills_root.iterdir(), key=lambda path: path.name)
except OSError as exc:
logger.warning("Could not inspect Agent Plugin '{}' skills: {}", plugin_name, exc)
return []
skills: list[AgentPluginSkill] = []
for candidate in candidates:
skill_root = _contained_directory(candidate, resolved_skills_root)
if skill_root is None:
continue
skill_file = _contained_file(skill_root / "SKILL.md", plugin_root)
if skill_file is None or not _valid_skill(skill_file, candidate.name, plugin_name):
continue
skills.append(
AgentPluginSkill(name=candidate.name, path=skill_file, plugin=plugin_name)
)
return skills
def _valid_skill(path: Path, directory_name: str, plugin_name: str) -> bool:
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeError):
return False
match = _SKILL_FRONTMATTER.match(content)
if match is None:
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid frontmatter", plugin_name, directory_name)
return False
try:
metadata = cast(object, yaml.safe_load(match.group(1)))
except yaml.YAMLError:
metadata = None
if not isinstance(metadata, dict):
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid frontmatter", plugin_name, directory_name)
return False
payload = cast(dict[object, object], metadata)
name = payload.get("name")
description = payload.get("description")
valid = (
name == directory_name
and isinstance(name, str)
and len(name) <= 64
and _SKILL_NAME.fullmatch(name) is not None
and isinstance(description, str)
and 1 <= len(description.strip()) <= 1024
)
if not valid:
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, directory_name)
return valid
def _contained_directory(path: Path, root: Path) -> Path | None:
try:
resolved = path.resolve(strict=True)
except OSError:
return None
return resolved if resolved.is_dir() and resolved.is_relative_to(root) else None
def _contained_file(path: Path, root: Path) -> Path | None:
try:
resolved = path.resolve(strict=True)
except OSError:
return None
return resolved if resolved.is_file() and resolved.is_relative_to(root) else None
+5 -21
View File
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, Callable, Coroutine, cast
from loguru import logger
from nanobot.session.manager import Session, SessionManager
from nanobot.session.manager import MIN_COMPACTED_REPLAY_MESSAGES, Session, SessionManager
if TYPE_CHECKING:
from nanobot.agent.memory import Consolidator
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
class AutoCompact:
_RECENT_SUFFIX_MESSAGES = 8
_RECENT_SUFFIX_MESSAGES = MIN_COMPACTED_REPLAY_MESSAGES
_INTERNAL_SESSION_PREFIXES = ("dream:",)
def __init__(self, sessions: SessionManager, consolidator: Consolidator,
@@ -45,25 +45,9 @@ class AutoCompact:
return False
return idle_seconds >= self._ttl * 60
def _has_compactable_idle_tail(self, key: str) -> bool:
def _has_unarchived_messages(self, key: str) -> bool:
session = self.sessions.get_or_create(key)
tail = list(session.messages[session.last_consolidated:])
if not tail:
return False
probe = Session(
key=session.key,
messages=tail,
created_at=session.created_at,
updated_at=session.updated_at,
metadata={},
last_consolidated=0,
)
result = probe.retain_recent_legal_suffix(
self._RECENT_SUFFIX_MESSAGES,
extend_to_user=True,
)
messages_to_remove = result.dropped[result.already_consolidated_count:]
return bool(messages_to_remove)
return session.last_consolidated < len(session.messages)
@staticmethod
def _format_summary(text: str, last_active: datetime) -> str:
@@ -88,7 +72,7 @@ class AutoCompact:
if key in active_session_keys:
continue
updated_at = info.get("updated_at")
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
if self._is_expired(updated_at, now) and self._has_unarchived_messages(key):
session = self.sessions.get_or_create(key)
try:
runtime = resolve_runtime(session)
+36 -36
View File
@@ -21,7 +21,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator, cast
from loguru import logger
from nanobot.runtime_context import public_history_messages
from nanobot.session.manager import Session, SessionManager
from nanobot.session.manager import MIN_COMPACTED_REPLAY_MESSAGES, Session, SessionManager
from nanobot.utils.gitstore import GitStore
from nanobot.utils.helpers import (
content_with_media_breadcrumbs,
@@ -858,14 +858,13 @@ class Consolidator:
return last_boundary
@staticmethod
def _full_unconsolidated_history(
def _full_replay_history(
session: Session,
) -> list[dict[str, Any]]:
"""Return the whole unconsolidated tail for consolidation decisions."""
unconsolidated_count = len(session.messages) - session.last_consolidated
if unconsolidated_count <= 0:
"""Return all messages that can reach the next model prompt."""
if not session.messages:
return []
return session.get_history(max_messages=unconsolidated_count)
return session.get_history(max_messages=len(session.messages))
@staticmethod
def _replay_overflow_boundary(
@@ -948,8 +947,8 @@ class Consolidator:
*,
runtime: LLMRuntime,
) -> tuple[int, str]:
"""Estimate prompt size from the full unconsolidated session tail."""
history = self._full_unconsolidated_history(session)
"""Estimate prompt size from the full replayable session history."""
history = self._full_replay_history(session)
channel = session.key.split(":", 1)[0] if ":" in session.key else None
# Include archived summary in estimation so the budget accounts for it.
meta = session.metadata.get("_last_summary")
@@ -1160,42 +1159,37 @@ class Consolidator:
session_key: str,
*,
runtime: LLMRuntime,
max_suffix: int = 8,
max_suffix: int = MIN_COMPACTED_REPLAY_MESSAGES,
) -> str | None:
"""Archive an idle prefix and hide it from replay without deleting it."""
"""Archive the full idle tail while keeping recent messages replayable.
``max_suffix`` remains accepted for SDK compatibility. Replay retention
is now derived independently from archive progress using the project-wide
compacted-session window.
"""
if max_suffix != MIN_COMPACTED_REPLAY_MESSAGES:
logger.debug(
"Idle-session compact for {} uses the fixed replay window ({}, requested {})",
session_key,
MIN_COMPACTED_REPLAY_MESSAGES,
max_suffix,
)
lock = self.get_lock(session_key)
async with lock:
self.sessions.invalidate(session_key)
session = self.sessions.get_or_create(session_key)
messages_to_summarize = list(session.messages[session.last_consolidated:])
if not messages_to_summarize:
self.sessions.save(session)
return ""
probe = Session(
key=session.key,
messages=messages_to_summarize.copy(),
created_at=session.created_at,
updated_at=session.updated_at,
metadata={},
last_consolidated=0,
)
result = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
visible_suffix = probe.messages
messages_to_remove = result.dropped
if not messages_to_remove:
self.sessions.save(session)
archive_start = session.last_consolidated
messages_to_archive = list(session.messages[archive_start:])
if not messages_to_archive:
return ""
last_active = session.updated_at
# The visible suffix informs the summary but stays out of raw fallback.
archive_end = archive_start + len(messages_to_archive)
summary = await self.archive(
messages_to_remove,
messages_to_archive,
runtime=runtime,
session_key=session_key,
summary_messages=messages_to_summarize,
)
if summary and summary != "(nothing)":
@@ -1204,16 +1198,22 @@ class Consolidator:
"last_active": last_active.isoformat(),
}
# Preserve history and advance only the replay boundary.
session.last_consolidated = len(session.messages) - len(visible_suffix)
# A turn can append while the provider call is in flight. Advance only
# through the captured batch so new messages remain eligible next time.
session.last_consolidated = archive_end
session.provider_state = None
self.sessions.save(session)
visible = session.get_history(
max_messages=MIN_COMPACTED_REPLAY_MESSAGES,
extend_to_user=True,
)
logger.info(
"Idle-session compact for {}: archived={}, visible={}, retained={}, summary={}",
session_key,
len(messages_to_remove),
len(visible_suffix),
len(messages_to_archive),
len(visible),
len(session.messages),
bool(summary),
)
+28 -8
View File
@@ -9,6 +9,8 @@ from typing import Any, cast
import yaml
from nanobot.agent.agent_plugins import discover_agent_plugin_skills
# Default builtin skills directory (relative to this file)
BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills"
@@ -33,6 +35,7 @@ class SkillsLoader:
self.workspace_skills = workspace / "skills"
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
self.disabled_skills = disabled_skills or set()
self.plugin_skills = discover_agent_plugin_skills(workspace)
def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]:
if not base.exists():
@@ -60,11 +63,24 @@ class SkillsLoader:
Returns:
List of skill info dicts with 'name', 'path', 'source'.
"""
self.plugin_skills = discover_agent_plugin_skills(self.workspace)
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
workspace_names = {entry["name"] for entry in skills}
seen_names = {entry["name"] for entry in skills}
for plugin_skill in self.plugin_skills:
if plugin_skill.name in seen_names:
continue
skills.append(
{
"name": plugin_skill.name,
"path": str(plugin_skill.path),
"source": "plugin",
"plugin": plugin_skill.plugin,
}
)
seen_names.add(plugin_skill.name)
if self.builtin_skills and self.builtin_skills.exists():
skills.extend(
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names)
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names)
)
if self.disabled_skills:
@@ -84,13 +100,16 @@ class SkillsLoader:
Returns:
Skill content or None if not found.
"""
roots = [self.workspace_skills]
workspace_path = self.workspace_skills / name / "SKILL.md"
if workspace_path.exists():
return workspace_path.read_text(encoding="utf-8")
for plugin_skill in self.plugin_skills:
if plugin_skill.name == name:
return plugin_skill.path.read_text(encoding="utf-8")
if self.builtin_skills:
roots.append(self.builtin_skills)
for root in roots:
path = root / name / "SKILL.md"
if path.exists():
return path.read_text(encoding="utf-8")
builtin_path = self.builtin_skills / name / "SKILL.md"
if builtin_path.exists():
return builtin_path.read_text(encoding="utf-8")
return None
def load_skills_for_context(self, skill_names: list[str]) -> str:
@@ -145,6 +164,7 @@ class SkillsLoader:
sections: list[str] = []
groups = (
("Workspace skills", "workspace", self.workspace_skills),
("Agent Plugin skills", "plugin", self.workspace / "plugins"),
("Built-in skills", "builtin", self.builtin_skills),
)
for label, source, root in groups:
+4 -1
View File
@@ -458,7 +458,10 @@ class WebSearchTool(Tool):
Olostep_BaseError, # pyright: ignore[reportUnknownVariableType]
)
except ImportError:
return ToolResult.error("Error: olostep package not installed. Run: pip install olostep")
return ToolResult.error(
"Error: Olostep support is not installed. "
"Run `nanobot plugins enable olostep`."
)
async_olostep = cast(Any, AsyncOlostep)
olostep_base_error = cast(type[Exception], Olostep_BaseError)
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
+75 -12
View File
@@ -18,6 +18,7 @@ from typing import Any, cast
from urllib.parse import urlparse
import httpx
import yaml
from loguru import logger
from nanobot.apps.protocol import app_manifest, compact_dict
@@ -27,6 +28,7 @@ from nanobot.security.workspace_policy import is_path_within
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
NANOBOT_EXTENSION_REGISTRY_URL = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/registry.json"
NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
_CATALOG_SOURCES = (
@@ -41,6 +43,8 @@ _MAX_ARTIFACT_REPORT = 12
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
_SAFE_NPM_DIR_RE = re.compile(r"^[a-z0-9._-]+$", re.IGNORECASE)
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE)
_SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", re.DOTALL)
_SKILL_NAME_LINE_RE = re.compile(r"^name\s*:.*$", re.MULTILINE)
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
_ENDORSEMENT_WORD_RE = re.compile(r"\bofficial\s+", re.IGNORECASE)
_ARTIFACT_EXTENSIONS = frozenset({
@@ -211,10 +215,21 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
def _safe_skill_name(name: str) -> str:
clean = _SAFE_NAME_RE.sub("-", name.lower()).replace("_", "-").strip("-")
return f"cli-app-{clean or 'app'}"
def _legacy_skill_name(name: str) -> str:
"""Return the workspace skill name emitted before Agent Plugins support."""
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
return f"cli-app-{clean or 'app'}"
def _plugin_skill_relative_path(name: str) -> str:
skill_name = _safe_skill_name(name)
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
def _has_shell_meta(command: str) -> bool:
return any(char in command for char in _SHELL_META_CHARS)
@@ -613,7 +628,7 @@ class CliAppManager:
"name": installed_name,
"entry_point": entry_point,
"source": str(data.get("source") or ""),
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
"skill": self.skill_relative_path(installed_name),
"tool": "run_cli_app",
}
)
@@ -640,7 +655,20 @@ class CliAppManager:
return not _has_shell_meta(install_cmd)
def _skill_path(self, name: str) -> Path:
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
skill_name = _safe_skill_name(name)
return self.workspace / "plugins" / skill_name / "skills" / skill_name / "SKILL.md"
def _legacy_skill_path(self, name: str) -> Path:
return self.workspace / "skills" / _legacy_skill_name(name) / "SKILL.md"
def _installed_skill_path(self, name: str) -> Path:
path = self._skill_path(name)
legacy_path = self._legacy_skill_path(name)
return legacy_path if not path.is_file() and legacy_path.is_file() else path
def skill_relative_path(self, name: str) -> str:
"""Return the existing skill path, falling back to the canonical plugin path."""
return self._installed_skill_path(name).relative_to(self.workspace).as_posix()
def _app_payload(
self,
@@ -677,7 +705,7 @@ class CliAppManager:
"status": status,
"logo_url": logo_url,
"brand_color": brand_color,
"skill_installed": self._skill_path(name).is_file(),
"skill_installed": self._installed_skill_path(name).is_file(),
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
}
@@ -713,7 +741,8 @@ class CliAppManager:
name = str(app["name"])
entry_point = str(app.get("entry_point") or "")
strategy = self._strategy(app)
skill_path = f"skills/{_safe_skill_name(name)}/SKILL.md"
skill_path = _plugin_skill_relative_path(name)
plugin_path = f"plugins/{_safe_skill_name(name)}"
capabilities = [
compact_dict({
"type": "cli",
@@ -726,13 +755,13 @@ class CliAppManager:
install = compact_dict({
"supported": install_supported,
"strategy": strategy,
"managed_paths": [skill_path],
"managed_paths": [plugin_path],
"verification": ["entry_point_available"] if entry_point else [],
})
remove = compact_dict({
"supported": strategy != "unsupported",
"strategy": strategy,
"managed_paths": [skill_path],
"managed_paths": [plugin_path],
"verification": (
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
if strategy not in {"bundled", "unsupported"}
@@ -1032,11 +1061,10 @@ class CliAppManager:
name = str(app.get("name") or "unknown")
display = str(app.get("display_name") or name)
entry = str(app.get("entry_point") or f"cli-anything-{name}")
description = _catalog_description(app) or f"Use {display} from nanobot."
description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
return f"""---
name: {_safe_skill_name(name)}
description: >-
{description}
description: {json.dumps(description, ensure_ascii=False)}
---
# {display}
@@ -1072,18 +1100,53 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
return "".join(lines[: index + 1]) + "\n" + note + "\n" + "".join(lines[index + 1 :])
return note + "\n" + content
def _normalise_skill(self, content: str, app: dict[str, Any]) -> str:
"""Give a catalog skill the identity required by its plugin directory."""
match = _SKILL_FRONTMATTER_RE.match(content)
if match is None:
return self._fallback_skill(app)
try:
metadata = _as_object_dict(cast(object, yaml.safe_load(match.group(1))))
except yaml.YAMLError:
return self._fallback_skill(app)
description = metadata.get("description") if metadata is not None else None
if not isinstance(description, str) or not 1 <= len(description.strip()) <= 1024:
return self._fallback_skill(app)
name = _safe_skill_name(str(app["name"]))
frontmatter, replaced = _SKILL_NAME_LINE_RE.subn(f"name: {name}", match.group(1), count=1)
if not replaced:
frontmatter = f"name: {name}\n{frontmatter}"
body = content[match.end():].lstrip()
return f"---\n{frontmatter.strip()}\n---\n\n{body}"
def install_skill(self, app: dict[str, Any]) -> Path:
path = self._skill_path(str(app["name"]))
path.parent.mkdir(parents=True, exist_ok=True)
content = self._fetch_skill_content(app) or self._fallback_skill(app)
content = self._normalise_skill(content, app)
content = self._with_nanobot_skill_note(content, app)
path.write_text(content, encoding="utf-8")
plugin_root = path.parents[2]
manifest = compact_dict({
"$schema": AGENT_PLUGIN_SCHEMA,
"name": _safe_skill_name(str(app["name"])),
"version": str(app.get("version") or ""),
"description": _catalog_description(app),
})
_write_json(plugin_root / "plugin.json", manifest)
legacy_dir = self._legacy_skill_path(str(app["name"])).parent
if legacy_dir.is_dir():
shutil.rmtree(legacy_dir)
return path
def remove_skill(self, name: str) -> None:
skill_dir = self._skill_path(name).parent
if skill_dir.is_dir():
shutil.rmtree(skill_dir)
plugin_root = self._skill_path(name).parents[2]
if plugin_root.is_dir():
shutil.rmtree(plugin_root)
legacy_dir = self._legacy_skill_path(name).parent
if legacy_dir.is_dir():
shutil.rmtree(legacy_dir)
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
installed = self._load_installed()
+4 -1
View File
@@ -29,6 +29,9 @@ def runtime_lines_for_request(
"""Return CLI App annotations from an immutable request snapshot."""
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
if isinstance(structured, list):
from nanobot.apps.cli import CliAppManager
manager = CliAppManager(workspace=workspace)
structured_items = cast(list[Any], structured)
mentions = [
cast(Mapping[str, Any], item) for item in structured_items
@@ -41,7 +44,7 @@ def runtime_lines_for_request(
f"@{str(item['name']).strip().lower()} "
f"(installed; tool=run_cli_app; "
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
f"skill={manager.skill_relative_path(str(item['name']))}). "
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
for item in mentions
if str(item.get("name") or "").strip()
+25
View File
@@ -101,6 +101,31 @@ class BaseChannel(ABC):
"""
pass
def progress_transport_defaults(self) -> tuple[bool, bool] | None:
"""Return channel-owned defaults for progress and tool-hint messages.
``None`` keeps the global channel policy. Channels should override this
only when their transport requires different defaults.
"""
return None
def should_retry_send_error(self, error: Exception) -> bool:
"""Return whether the channel manager may retry a failed delivery.
Channels with protocol-level business errors can override this hook to
prevent retries that cannot succeed until external state changes.
Transport and unexpected errors remain retryable by default.
"""
return True
def start_error_message(self, error: Exception) -> str | None:
"""Return an actionable public message for a channel startup failure.
Channel-specific exception handling stays in the owning channel. Returning
``None`` keeps the manager's generic fallback.
"""
return None
async def send_delta(
self,
chat_id: str,
+20 -4
View File
@@ -187,11 +187,15 @@ class ChannelManager:
channel = cls(section, self.bus, **kwargs)
if runtime_name and runtime_name != channel.name:
channel.name = runtime_name
progress_default, tool_hints_default = channel.progress_transport_defaults() or (
self.config.channels.send_progress,
self.config.channels.send_tool_hints,
)
channel.send_progress = self._resolve_bool_override(
section, "send_progress", self.config.channels.send_progress,
section, "send_progress", progress_default,
)
channel.send_tool_hints = self._resolve_bool_override(
section, "send_tool_hints", self.config.channels.send_tool_hints,
section, "send_tool_hints", tool_hints_default,
)
channel.show_reasoning = self._resolve_bool_override(
section, "show_reasoning", self.config.channels.show_reasoning,
@@ -347,8 +351,12 @@ class ChannelManager:
await channel.start()
except asyncio.CancelledError:
raise
except Exception:
errors[name] = "Channel failed to start. Check gateway logs."
except Exception as exc:
public_error = channel.start_error_message(exc)
errors[name] = public_error or "Channel failed to start. Check gateway logs."
if public_error:
logger.error("Failed to start channel {}: {}", name, public_error)
else:
logger.exception("Failed to start channel {}", name)
def _start_channel_task(self, name: str, channel: BaseChannel) -> asyncio.Task[None]:
@@ -912,6 +920,14 @@ class ChannelManager:
except asyncio.CancelledError:
raise # Propagate cancellation for graceful shutdown
except Exception as e:
if not channel.should_retry_send_error(e):
logger.error(
"Send to {} failed with a non-retryable {}: {}",
msg.channel,
type(e).__name__,
e,
)
return
loop = asyncio.get_running_loop()
exhausted = (
attempt >= max_attempts
+48 -2
View File
@@ -24,10 +24,12 @@ try:
import nh3
from mistune import HTMLRenderer, create_markdown
from nio import (
Api,
AsyncClient,
AsyncClientConfig,
InviteEvent,
JoinError,
JoinResponse,
KeyVerificationCancel,
KeyVerificationEvent,
KeyVerificationKey,
@@ -43,6 +45,7 @@ try:
RoomSendResponse,
RoomTypingError,
SyncError,
SyncResponse,
ToDeviceError,
UploadError,
)
@@ -701,6 +704,7 @@ class MatrixChannel(BaseChannel):
client.add_response_callback(self._on_sync_error, SyncError)
client.add_response_callback(self._on_join_error, JoinError)
client.add_response_callback(self._on_send_error, RoomSendError)
client.add_response_callback(self._on_sync_invite_fallback, SyncResponse)
def _is_sas_sender_allowed(self, sender: str) -> bool:
return bool(sender and self.is_allowed(sender))
@@ -782,6 +786,49 @@ class MatrixChannel(BaseChannel):
with suppress(Exception):
self.client.stop_sync_forever()
async def _join_room_safe(self, room_id: str) -> bool:
"""Join a room, sending a non-empty POST body.
nio's ``Api.join()`` produces a POST with no body. Some homeservers
(notably Continuwuity) reject empty bodies with ``M_BAD_JSON``.
Sending ``"{}"`` satisfies both strict and lenient servers.
"""
client = self._require_client()
method, path = Api.join(client.access_token, room_id)
try:
resp = cast(
JoinResponse | JoinError,
await client._send( # type: ignore[reportPrivateUsage, reportUnknownMemberType]
JoinResponse, method, path, data="{}"
),
)
except Exception:
self.logger.error("Matrix join request exception for room={}", room_id, exc_info=True)
return False
if isinstance(resp, JoinError):
self.logger.error("Matrix auto-join failed for room={}: {}", room_id, resp)
return False
self.logger.info("Matrix auto-join succeeded: {}", room_id)
return True
async def _on_sync_invite_fallback(self, response: SyncResponse) -> None:
"""Safety net: join pending invites that the event callback may have missed.
Some homeservers (e.g. Continuwuity) deliver each invite only once.
If ``_on_room_invite`` fires but the join fails, the sync token
advances and the invite is never re-delivered. This callback inspects
the same ``SyncResponse`` for pending invites and joins them, acting
as a fallback alongside the event-based callback.
"""
if not response.rooms or not response.rooms.invite:
return
for room_id, invite_info in response.rooms.invite.items():
for event in cast(list[Any], invite_info.invite_state):
sender = getattr(event, "sender", None)
if sender and self.is_allowed(cast(str, sender)):
await self._join_room_safe(room_id)
break
async def _on_join_error(self, response: JoinError) -> None:
self._log_response_error("join", response)
@@ -838,8 +885,7 @@ class MatrixChannel(BaseChannel):
async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
if self.is_allowed(event.sender):
client = self._require_client()
await client.join(room.room_id)
await self._join_room_safe(room.room_id)
def _is_direct_room(self, room: MatrixRoom) -> bool:
count = getattr(room, "member_count", None)
@@ -4,13 +4,14 @@ import asyncio
import sys
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import unquote
import pytest
pytest.importorskip("nio")
pytest.importorskip("nh3")
pytest.importorskip("mistune")
from nio import RoomSendResponse, SyncError
from nio import JoinResponse, RoomSendResponse, SyncError
import nanobot.channels.matrix.runtime as matrix_module
from nanobot.bus.events import OutboundMessage
@@ -104,6 +105,15 @@ class _FakeAsyncClient:
async def join(self, room_id: str) -> None:
self.join_calls.append(room_id)
async def _send(self, response_class, method, path, data=None, **kwargs):
"""Minimal mock for nio's ``_send`` used by ``_join_room_safe``."""
if response_class is JoinResponse and method == "POST" and "/join/" in path:
encoded = path.split("/join/")[1].split("?")[0]
room_id = unquote(encoded)
self.join_calls.append(room_id)
return JoinResponse(room_id=room_id)
return response_class()
async def accept_key_verification(self, transaction_id: str):
self.operation_calls.append(f"accept:{transaction_id}")
self.accept_key_verification_calls.append(transaction_id)
@@ -308,7 +318,7 @@ async def test_start_skips_load_store_when_device_id_missing(
assert clients[0].load_store_called is False
assert len(clients[0].callbacks) == 3
assert clients[0].to_device_callbacks == []
assert len(clients[0].response_callbacks) == 3
assert len(clients[0].response_callbacks) == 4
await channel.stop()
@@ -590,6 +600,7 @@ async def test_room_invite_joins_when_sender_allowed() -> None:
assert client.join_calls == ["!room:matrix.org"]
@pytest.mark.asyncio
async def test_room_invite_respects_allow_list_when_configured() -> None:
channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus())
@@ -604,6 +615,61 @@ async def test_room_invite_respects_allow_list_when_configured() -> None:
assert client.join_calls == []
@pytest.mark.asyncio
async def test_on_sync_invite_fallback_joins_pending_invites() -> None:
"""_on_sync_invite_fallback joins rooms from sync invite_state for allowed senders."""
channel = MatrixChannel(
_make_config(allow_from=["@alice:matrix.org"]), MessageBus()
)
client = _FakeAsyncClient("", "", "", None)
channel.client = client
invite_event = SimpleNamespace(sender="@alice:matrix.org")
invite_info = SimpleNamespace(invite_state=[invite_event])
rooms = SimpleNamespace(invite={"!room:matrix.org": invite_info})
response = SimpleNamespace(rooms=rooms)
await channel._on_sync_invite_fallback(response)
assert client.join_calls == ["!room:matrix.org"]
@pytest.mark.asyncio
async def test_on_sync_invite_fallback_skips_when_no_invites() -> None:
"""_on_sync_invite_fallback is a no-op when sync has no invites."""
channel = MatrixChannel(
_make_config(allow_from=["@alice:matrix.org"]), MessageBus()
)
client = _FakeAsyncClient("", "", "", None)
channel.client = client
rooms = SimpleNamespace(invite={})
response = SimpleNamespace(rooms=rooms)
await channel._on_sync_invite_fallback(response)
assert client.join_calls == []
@pytest.mark.asyncio
async def test_on_sync_invite_fallback_skips_denied_sender() -> None:
"""_on_sync_invite_fallback respects the allow list."""
channel = MatrixChannel(
_make_config(allow_from=["@bob:matrix.org"]), MessageBus()
)
client = _FakeAsyncClient("", "", "", None)
channel.client = client
invite_event = SimpleNamespace(sender="@alice:matrix.org")
invite_info = SimpleNamespace(invite_state=[invite_event])
rooms = SimpleNamespace(invite={"!room:matrix.org": invite_info})
response = SimpleNamespace(rooms=rooms)
await channel._on_sync_invite_fallback(response)
assert client.join_calls == []
@pytest.mark.asyncio
async def test_on_message_sets_typing_for_allowed_sender() -> None:
channel = MatrixChannel(_make_config(), MessageBus())
+25
View File
@@ -81,6 +81,7 @@ from nanobot.webui.session_access import (
WebuiSessionAccess,
session_mentions_runtime_context,
)
from nanobot.webui.sidebar_state import write_webui_sidebar_state
from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
from nanobot.webui.transcription_ws import webui_transcription_event
from nanobot.webui.websocket_logging import websockets_server_logger
@@ -775,6 +776,30 @@ class WebSocketChannel(BaseChannel):
await self._send_event(connection, "attached", chat_id=cid)
await self._hydrate_after_subscribe(cid)
return
if t == "set_sidebar_state":
if connection not in self._webui_connections:
await self._send_event(connection, "error", detail="access_denied")
return
state = envelope.get("state")
if not isinstance(state, dict):
await self._send_event(
connection,
"error",
detail="invalid_sidebar_state",
)
return
try:
await asyncio.to_thread(
write_webui_sidebar_state,
cast(dict[str, Any], state),
)
except (OSError, ValueError):
await self._send_event(
connection,
"error",
detail="invalid_sidebar_state",
)
return
if t == "set_workspace_scope":
cid = envelope.get("chat_id")
if not _is_valid_chat_id(cid):
@@ -559,6 +559,34 @@ def test_only_bootstrap_tokens_mark_webui_connections(bus: MagicMock) -> None:
assert client_connection not in channel._webui_connections
@pytest.mark.asyncio
async def test_webui_persists_sidebar_state_larger_than_http_request_line(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
channel = _ch(bus)
conn = AsyncMock()
channel._webui_connections.add(conn)
session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)]
envelope = {
"type": "set_sidebar_state",
"state": {
"session_order": session_order,
"view": {"sort": "manual"},
},
}
assert len(json.dumps(envelope).encode()) > 8_192
await channel._dispatch_envelope(conn, "webui-client", envelope)
saved = json.loads((tmp_path / "webui" / "sidebar-state.json").read_text(encoding="utf-8"))
assert saved["session_order"] == session_order
assert saved["view"]["sort"] == "manual"
conn.send.assert_not_awaited()
@pytest.mark.asyncio
async def test_client_cannot_self_assert_webui_quote_context(bus: MagicMock) -> None:
channel = _ch(bus)
@@ -19,11 +19,6 @@ from nanobot.channels.websocket.runtime import WebSocketChannel, WebSocketConfig
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
from nanobot.optional_features import InstallResult
from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META,
RuntimeContextBlock,
append_runtime_context,
)
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
from nanobot.session.keys import UNIFIED_SESSION_KEY
from nanobot.session.manager import Session, SessionManager
@@ -256,7 +251,7 @@ async def test_bootstrap_returns_token_for_localhost(
@pytest.mark.asyncio
async def test_sessions_routes_require_bearer_token(
async def test_sessions_list_requires_bearer_token(
bus: MagicMock, tmp_path: Path
) -> None:
sm = _seed_session(tmp_path, key="websocket:abc")
@@ -278,14 +273,26 @@ async def test_sessions_routes_require_bearer_token(
# Server stays an opaque source: filesystem paths must not leak to the wire.
assert all("path" not in s for s in listing.json()["sessions"])
msgs = await _http_get(
"http://127.0.0.1:29902/api/sessions/websocket:abc/messages",
headers=auth,
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_legacy_session_messages_route_is_not_exposed(
bus: MagicMock, tmp_path: Path
) -> None:
sm = _seed_session(tmp_path, key="websocket:legacy")
channel = _ch(bus, session_manager=sm, port=29919)
server_task = asyncio.create_task(channel.start())
try:
token = channel.gateway.tokens.issue_api_token(300)
response = await _http_get(
"http://127.0.0.1:29919/api/sessions/websocket:legacy/messages",
headers={"Authorization": f"Bearer {token}"},
)
assert msgs.status_code == 200
body = msgs.json()
assert body["key"] == "websocket:abc"
assert [m["role"] for m in body["messages"]] == ["user", "assistant"]
assert response.status_code == 404
finally:
await channel.stop()
await server_task
@@ -2280,6 +2287,7 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
payload = {
"pinned_keys": ["websocket:sidebar"],
"archived_keys": ["websocket:old"],
"session_order": ["websocket:old", "websocket:sidebar"],
"title_overrides": {"websocket:sidebar": "Pinned work"},
"view": {"density": "compact", "show_archived": True},
}
@@ -2291,6 +2299,7 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
assert updated.status_code == 200
body = updated.json()
assert body["pinned_keys"] == ["websocket:sidebar"]
assert body["session_order"] == ["websocket:old", "websocket:sidebar"]
assert body["title_overrides"] == {"websocket:sidebar": "Pinned work"}
assert body["view"]["density"] == "compact"
@@ -2845,7 +2854,7 @@ async def test_session_delete_blocks_origin_automation_when_unified_enabled(
@pytest.mark.asyncio
async def test_session_routes_accept_percent_encoded_websocket_keys(
async def test_session_delete_accepts_percent_encoded_websocket_keys(
bus: MagicMock, tmp_path: Path
) -> None:
sm = _seed_session(tmp_path, key="websocket:encoded-key")
@@ -2855,13 +2864,6 @@ async def test_session_routes_accept_percent_encoded_websocket_keys(
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
msgs = await _http_get(
"http://127.0.0.1:29910/api/sessions/websocket%3Aencoded-key/messages",
headers=auth,
)
assert msgs.status_code == 200
assert msgs.json()["key"] == "websocket:encoded-key"
path = sm._get_session_path("websocket:encoded-key")
assert path.exists()
deleted = await _http_get(
@@ -2876,41 +2878,6 @@ async def test_session_routes_accept_percent_encoded_websocket_keys(
await server_task
@pytest.mark.asyncio
async def test_session_messages_hide_persisted_runtime_context(
bus: MagicMock, tmp_path: Path
) -> None:
sm = SessionManager(tmp_path)
session = sm.get_or_create("websocket:runtime-context")
content, marker = append_runtime_context(
"visible user text",
[RuntimeContextBlock(source="goal", content="private goal context")],
)
session.add_message(
"user",
content,
**{RUNTIME_CONTEXT_HISTORY_META: marker},
)
sm.save(session)
channel = _ch(bus, session_manager=sm, port=29919)
server_task = asyncio.create_task(channel.start())
try:
token = channel.gateway.tokens.issue_api_token(300)
response = await _http_get(
"http://127.0.0.1:29919/api/sessions/websocket:runtime-context/messages",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
message = response.json()["messages"][0]
assert message["content"] == "visible user text"
assert RUNTIME_CONTEXT_HISTORY_META not in message
assert "private goal context" not in response.text
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_webui_thread_resigns_assistant_media_urls(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -3114,7 +3081,7 @@ async def test_webui_thread_negotiates_gzip_for_large_payloads(
@pytest.mark.asyncio
async def test_session_routes_reject_non_websocket_keys(
async def test_session_delete_rejects_non_websocket_keys(
bus: MagicMock, tmp_path: Path
) -> None:
sm = _seed_many(
@@ -3131,14 +3098,6 @@ async def test_session_routes_reject_non_websocket_keys(
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
# The webui list already hides non-websocket sessions; handcrafted URLs
# should hit the same boundary rather than exposing or deleting them.
msgs = await _http_get(
"http://127.0.0.1:29909/api/sessions/cli:direct/messages",
headers=auth,
)
assert msgs.status_code == 404
doomed = sm._get_session_path("slack:C123")
assert doomed.exists()
deny_delete = await _http_get(
@@ -3153,7 +3112,7 @@ async def test_session_routes_reject_non_websocket_keys(
@pytest.mark.asyncio
async def test_session_routes_reject_invalid_key(
async def test_session_delete_rejects_invalid_key(
bus: MagicMock, tmp_path: Path
) -> None:
sm = _seed_session(tmp_path)
@@ -3166,7 +3125,7 @@ async def test_session_routes_reject_invalid_key(
# Invalid characters in the key -> regex match fails -> 404
# (route doesn't match, falls through to channel 404).
resp = await _http_get(
"http://127.0.0.1:29904/api/sessions/bad%20key/messages",
"http://127.0.0.1:29904/api/sessions/bad%20key/delete",
headers=auth,
)
assert resp.status_code in {400, 404}
@@ -1,11 +1,8 @@
"""Tests for the signed ``/api/media/<sig>/<payload>`` route and its replay
integration on ``/api/sessions/<key>/messages``.
"""Tests for the signed ``/api/media/<sig>/<payload>`` route and WebUI replay.
The route is the return path for images attached to persisted user turns:
:meth:`WebSocketChannel.gateway.media.sign_media_path` mints URLs during session reads,
and :meth:`GatewayHTTPHandler._handle_media_fetch` serves the bytes back.
These tests cover the two halves end-to-end plus the adversarial edges
(bad signatures, ``..`` traversal, non-existent files, non-image types).
The route is the return path for local media rendered by the WebUI. These tests
cover URL signing and serving end-to-end plus the adversarial edges (bad
signatures, ``..`` traversal, non-existent files, non-image types).
"""
from __future__ import annotations
@@ -20,7 +17,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.channels.websocket.runtime import WebSocketChannel, WebSocketConfig
from nanobot.session.manager import Session, SessionManager
from nanobot.session.manager import SessionManager
from nanobot.webui.gateway_services import build_gateway_services
from nanobot.webui.media_api import (
b64url_decode,
@@ -497,91 +494,3 @@ async def test_media_route_serves_svg_with_strict_csp(
assert resp.headers.get("x-content-type-options") == "nosniff"
assert "default-src 'none'" in resp.headers.get("content-security-policy", "")
assert "sandbox" in resp.headers.get("content-security-policy", "")
# ---------------------------------------------------------------------------
# /api/sessions/<key>/messages: media_urls hydration on session read
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_session_messages_exposes_signed_media_urls(
bus: MagicMock, tmp_path: Path
) -> None:
"""The read path must map persisted ``media`` paths onto signed URLs
and strip the raw path the client never learns the server's layout."""
media = tmp_path / "media"
media.mkdir()
img = media / "u.png"
img.write_bytes(_PNG_BYTES)
sm = SessionManager(tmp_path / "ws_state")
sess = Session(key="websocket:media-hydrate")
sess.add_message("user", "look at this", media=[str(img)])
sess.add_message("assistant", "nice")
sm.save(sess)
channel = _ch(bus, session_manager=sm, port=29925)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
server_task = asyncio.create_task(channel.start())
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
resp = await _http_get(
"http://127.0.0.1:29925/api/sessions/websocket:media-hydrate/messages",
headers=auth,
)
body = resp.json()
# The signed URL round-trips end-to-end: fetching it yields the same bytes.
user_msg = next(m for m in body["messages"] if m["role"] == "user")
urls = user_msg["media_urls"]
assert isinstance(urls, list) and len(urls) == 1
assert urls[0]["name"] == "u.png"
assert urls[0]["url"].startswith("/api/media/")
# Raw paths must not leak to the wire.
assert "media" not in user_msg
# And the URL actually works.
fetched = await _http_get(f"http://127.0.0.1:29925{urls[0]['url']}")
assert fetched.status_code == 200
assert fetched.content == _PNG_BYTES
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_session_messages_skips_vanished_media(
bus: MagicMock, tmp_path: Path
) -> None:
"""Paths that no longer resolve inside the media root produce no URL —
the message is still delivered, just without the preview."""
media = tmp_path / "media"
media.mkdir()
sm = SessionManager(tmp_path / "ws_state")
sess = Session(key="websocket:vanished")
sess.add_message("user", "missing pic", media=[str(media / "absent.png")])
sm.save(sess)
channel = _ch(bus, session_manager=sm, port=29926)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
server_task = asyncio.create_task(channel.start())
try:
token = channel.gateway.tokens.issue_api_token(300)
resp = await _http_get(
"http://127.0.0.1:29926/api/sessions/websocket:vanished/messages",
headers={"Authorization": f"Bearer {token}"},
)
user_msg = next(m for m in resp.json()["messages"] if m["role"] == "user")
# absent.png lives inside the media root so it *does* get a signed
# URL (we don't stat the file at signing time — that would slow
# the listing). Fetching the URL is where the 404 surfaces.
urls = user_msg.get("media_urls") or []
assert len(urls) == 1
fetched = await _http_get(f"http://127.0.0.1:29926{urls[0]['url']}")
assert fetched.status_code == 404
assert "media" not in user_msg
finally:
await channel.stop()
await server_task
+80 -7
View File
@@ -47,7 +47,10 @@ class WeixinConnectStore:
if not session_id:
raise ChannelConnectError("missing WeChat connect session")
if action == "poll":
return await self.poll(session_id)
return await self.poll(
session_id,
verify_code=(query_first(query, "verify_code") or "").strip(),
)
if action == "cancel":
return await self.cancel(session_id)
raise ChannelConnectError(f"unsupported WeChat connect action: {action}", status=404)
@@ -91,7 +94,7 @@ class WeixinConnectStore:
)
return self._start_payload(self._sessions[session_id])
async def poll(self, session_id: str) -> dict[str, Any]:
async def poll(self, session_id: str, *, verify_code: str = "") -> dict[str, Any]:
await self._cleanup()
session = self._sessions.get(session_id)
if session is None:
@@ -105,6 +108,7 @@ class WeixinConnectStore:
status_data = await session.channel.connect_poll_qr_code(
base_url=session.current_poll_base_url,
qrcode_id=session.qrcode_id,
verify_code=verify_code,
)
except Exception as exc:
if session.channel.connect_poll_error_is_retryable(exc):
@@ -120,6 +124,8 @@ class WeixinConnectStore:
status_payload = status_data
status = status_payload.get("status", "")
from nanobot.channels.weixin.runtime import MAX_QR_REFRESH_COUNT
if status == "confirmed":
if self._sessions.get(session_id) is not session:
return {
@@ -157,9 +163,66 @@ class WeixinConnectStore:
)
return self._pending_payload(session)
if status == "expired":
from nanobot.channels.weixin.runtime import MAX_QR_REFRESH_COUNT
if status == "need_verifycode":
return self._pending_payload(
session,
challenge="verify_code",
message=(
"That verification code did not match. Enter the new number shown in WeChat."
if verify_code
else "Enter the number shown in WeChat to continue."
),
verification_failed=bool(verify_code),
)
if status == "verify_code_blocked":
session.refresh_count += 1
if session.refresh_count > MAX_QR_REFRESH_COUNT:
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "failed",
"message": "Too many incorrect verification attempts. Try again later.",
}
try:
session.qrcode_id, session.qr_url = (
await session.channel.connect_fetch_qr_code()
)
except Exception as exc:
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "failed",
"message": f"Could not refresh WeChat QR code: {exc}",
}
session.current_poll_base_url = session.channel.connect_base_url
return self._pending_payload(
session,
message="Verification was blocked. Scan the refreshed QR code to try again.",
)
if status == "binded_redirect":
if not session.channel.connect_load_state():
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "failed",
"message": (
"WeChat reports an existing binding, but no local credentials were found."
),
}
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "succeeded",
"message": "WeChat is already connected to this nanobot instance.",
}
if status == "expired":
session.refresh_count += 1
if session.refresh_count > MAX_QR_REFRESH_COUNT:
self._sessions.pop(session_id, None)
@@ -238,15 +301,25 @@ class WeixinConnectStore:
}
@staticmethod
def _pending_payload(session: WeixinConnectSession) -> dict[str, Any]:
return {
def _pending_payload(
session: WeixinConnectSession,
*,
challenge: str = "",
message: str = "Waiting for WeChat scan.",
verification_failed: bool = False,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"session_id": session.id,
"status": "pending",
"qr_url": session.qr_url,
"interval_ms": 2000,
"expires_at_ms": int((session.created_wall + 600) * 1000),
"message": "Waiting for WeChat scan.",
"message": message,
}
if challenge:
payload["challenge"] = challenge
payload["verification_failed"] = verification_failed
return payload
__all__ = ["WeixinConnectStore"]
+14
View File
@@ -10,6 +10,20 @@ SETUP_SPEC = ChannelSetupSpec(
fields={
"token": field("secret"),
"allowFrom": field("list"),
"baseUrl": field(default="https://ilinkai.weixin.qq.com"),
"cdnBaseUrl": field(default="https://novac2c.cdn.weixin.qq.com/c2c"),
"routeTag": field(),
"stateDir": field(),
"pollTimeout": field("int", default=35),
"sendProgress": field("bool", default=False),
"sendToolHints": field("bool", default=False),
"replyProgressMessages": field("bool", default=False),
"replyProgressMaxMessages": field("int", default=2),
"contextMessageBudget": field("int", default=8),
"streaming": field("bool", default=True),
"blockStreaming": field("bool", default=False),
"blockStreamingMinChars": field("int", default=1200),
"blockStreamingMaxMessages": field("int", default=3),
},
required=(required("token"),),
official_url="https://weixin.qq.com/",
File diff suppressed because it is too large Load Diff
@@ -147,3 +147,129 @@ async def test_weixin_cancel_wins_over_inflight_confirmation(
assert cancelled["status"] == "cancelled"
assert completed["status"] == "cancelled"
assert not (state_dir / "account.json").exists()
@pytest.mark.asyncio
async def test_weixin_connect_store_handles_verification_code(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
state_dir = tmp_path / "weixin-state"
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
return "qr-verify", "https://qr.example/verify"
responses = [
{"status": "need_verifycode"},
{
"status": "confirmed",
"bot_token": "verified-token",
"ilink_user_id": "wx-user",
},
]
async def fake_api_get_with_base(
self: WeixinChannel,
*,
params: dict[str, Any],
**_kwargs: Any,
) -> dict[str, str]:
if len(responses) == 1:
assert params == {"qrcode": "qr-verify", "verify_code": "1234"}
return responses.pop(0)
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
store = WeixinConnectStore()
started = await store.start()
challenged = await store.poll(started["session_id"])
completed = await store.handle(
"poll",
{
"session_id": [started["session_id"]],
"verify_code": ["1234"],
},
)
assert challenged["status"] == "pending"
assert challenged["challenge"] == "verify_code"
assert completed["status"] == "succeeded"
@pytest.mark.asyncio
async def test_weixin_connect_store_treats_existing_binding_as_success(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
state_dir = tmp_path / "weixin-state"
state_dir.mkdir()
(state_dir / "account.json").write_text(
json.dumps({"token": "working-token"}),
encoding="utf-8",
)
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
return "qr-existing", "https://qr.example/existing"
async def fake_api_get_with_base(
self: WeixinChannel,
**_kwargs: Any,
) -> dict[str, str]:
return {"status": "binded_redirect"}
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
store = WeixinConnectStore()
started = await store.start(force=True)
completed = await store.poll(started["session_id"])
assert completed["status"] == "succeeded"
assert "already connected" in completed["message"]
assert json.loads((state_dir / "account.json").read_text())["token"] == "working-token"
@pytest.mark.asyncio
async def test_weixin_connect_store_rejects_existing_binding_without_local_credentials(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
state_dir = tmp_path / "weixin-state"
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
return "qr-missing", "https://qr.example/missing"
async def fake_api_get_with_base(
self: WeixinChannel,
**_kwargs: Any,
) -> dict[str, str]:
return {"status": "binded_redirect"}
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
store = WeixinConnectStore()
started = await store.start(force=True)
completed = await store.poll(started["session_id"])
assert completed["status"] == "failed"
assert "no local credentials" in completed["message"]
@@ -17,6 +17,7 @@ from nanobot.channels.weixin.runtime import (
ITEM_TEXT,
MESSAGE_TYPE_BOT,
WEIXIN_CHANNEL_VERSION,
WeixinAuthError,
WeixinChannel,
WeixinConfig,
_decrypt_aes_ecb,
@@ -67,11 +68,11 @@ def test_make_headers_includes_route_tag_when_configured() -> None:
assert headers["Authorization"] == "Bearer token"
assert headers["SKRouteTag"] == "123"
assert headers["iLink-App-Id"] == "bot"
assert headers["iLink-App-ClientVersion"] == str((2 << 16) | (1 << 8) | 1)
assert headers["iLink-App-ClientVersion"] == str((2 << 16) | (4 << 8) | 6)
def test_channel_version_matches_reference_plugin_version() -> None:
assert WEIXIN_CHANNEL_VERSION == "2.1.1"
assert WEIXIN_CHANNEL_VERSION == "2.4.6"
def test_save_and_load_state_persists_context_tokens(tmp_path) -> None:
@@ -159,6 +160,29 @@ def test_save_state_persists_explicit_config_token_over_stale_state(tmp_path) ->
assert saved["get_updates_buf"] == "current-cursor"
def test_save_state_preserves_qr_replacement_of_configured_token(tmp_path) -> None:
config = WeixinConfig(
enabled=True,
allow_from=["*"],
token="configured-token",
state_dir=str(tmp_path),
)
old_runtime = WeixinChannel(config, MessageBus())
old_runtime._token = "configured-token"
replacement = WeixinChannel(config, MessageBus())
replacement.connect_commit_account(
token="replacement-token",
base_url="https://new.example",
)
old_runtime._save_state()
saved = json.loads((tmp_path / "account.json").read_text())
assert saved["token"] == "replacement-token"
assert saved["base_url"] == "https://new.example"
def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_path) -> None:
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
@@ -442,15 +466,15 @@ async def test_send_without_context_token_raises() -> None:
@pytest.mark.asyncio
async def test_send_raises_when_session_is_paused() -> None:
async def test_send_raises_when_authentication_is_required() -> None:
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel._context_tokens["wx-user"] = "ctx-2"
channel._pause_session(60)
channel._auth_required = True
channel._send_text = AsyncMock()
with pytest.raises(RuntimeError, match="session paused"):
with pytest.raises(WeixinAuthError, match="bot token is stale"):
await channel.send(
type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
)
@@ -525,20 +549,21 @@ async def test_send_still_sends_text_when_typing_ticket_missing() -> None:
@pytest.mark.asyncio
async def test_poll_once_pauses_session_on_expired_errcode() -> None:
async def test_poll_once_requires_login_on_stale_token() -> None:
channel, _bus = _make_channel()
channel._client = SimpleNamespace(timeout=None)
channel._token = "token"
channel._api_post = AsyncMock(return_value={"ret": 0, "errcode": -14, "errmsg": "expired"})
with pytest.raises(WeixinAuthError, match="no replacement credentials"):
await channel._poll_once()
assert channel._session_pause_remaining_s() > 0
assert channel._auth_required is True
@pytest.mark.asyncio
async def test_poll_once_reloads_refreshed_state_after_session_pause(
tmp_path, monkeypatch: pytest.MonkeyPatch
async def test_poll_once_reloads_refreshed_state_after_stale_token(
tmp_path,
) -> None:
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
@@ -550,8 +575,13 @@ async def test_poll_once_reloads_refreshed_state_after_session_pause(
json.dumps({"token": "new-token", "base_url": "https://new.example"}),
encoding="utf-8",
)
channel._session_pause_until = time.time() + 10
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
channel._client = object()
channel._api_post = AsyncMock(
side_effect=[
{"ret": 0, "errcode": -14, "errmsg": "stale"},
{"ret": 0},
]
)
await channel._poll_once()
@@ -560,8 +590,8 @@ async def test_poll_once_reloads_refreshed_state_after_session_pause(
@pytest.mark.asyncio
async def test_poll_once_keeps_explicit_token_after_session_pause(
tmp_path, monkeypatch: pytest.MonkeyPatch
async def test_poll_once_keeps_explicit_token_and_requires_login(
tmp_path,
) -> None:
channel = WeixinChannel(
WeixinConfig(
@@ -577,24 +607,132 @@ async def test_poll_once_keeps_explicit_token_after_session_pause(
json.dumps({"token": "stale-token", "base_url": "https://stale.example"}),
encoding="utf-8",
)
channel._session_pause_until = time.time() + 10
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
channel._client = object()
channel._api_post = AsyncMock(
return_value={"ret": 0, "errcode": -14, "errmsg": "stale"}
)
with pytest.raises(WeixinAuthError, match="no replacement credentials"):
await channel._poll_once()
assert channel._token == "configured-token"
assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
@pytest.mark.asyncio
async def test_poll_once_loads_qr_replacement_for_configured_token(tmp_path) -> None:
config = WeixinConfig(
enabled=True,
allow_from=["*"],
token="configured-token",
state_dir=str(tmp_path),
)
replacement = WeixinChannel(config, MessageBus())
replacement.connect_commit_account(
token="replacement-token",
base_url="https://new.example",
)
channel = WeixinChannel(config, MessageBus())
channel._token = "configured-token"
channel._client = object()
channel._api_post = AsyncMock(
side_effect=[
{"ret": 0, "errcode": -14, "errmsg": "stale"},
{"ret": 0},
]
)
await channel._poll_once()
assert channel._token == "replacement-token"
assert channel.config.base_url == "https://new.example"
@pytest.mark.asyncio
async def test_start_uses_qr_replacement_for_configured_token(tmp_path) -> None:
config = WeixinConfig(
enabled=True,
allow_from=["*"],
token="configured-token",
state_dir=str(tmp_path),
)
connector = WeixinChannel(config, MessageBus())
connector.connect_commit_account(
token="replacement-token",
base_url="https://new.example",
)
channel = WeixinChannel(config, MessageBus())
observed_tokens: list[str] = []
async def stop_after_first_poll() -> None:
observed_tokens.append(channel._token)
channel._running = False
channel._notify_lifecycle = AsyncMock() # type: ignore[method-assign]
channel._poll_once = stop_after_first_poll # type: ignore[method-assign]
await channel.start()
await channel.stop()
assert observed_tokens == ["replacement-token"]
assert channel.config.base_url == "https://new.example"
@pytest.mark.asyncio
async def test_manager_surfaces_actionable_weixin_auth_error_without_traceback(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.channels import manager as manager_mod
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
MessageBus(),
)
channel.start = AsyncMock( # type: ignore[method-assign]
side_effect=WeixinAuthError(
"getupdates",
errcode=-14,
errmsg="stale",
)
)
errors: list[str] = []
tracebacks: list[str] = []
monkeypatch.setattr(
manager_mod.logger,
"error",
lambda message, *args: errors.append(message.format(*args)),
)
monkeypatch.setattr(
manager_mod.logger,
"exception",
lambda message, *args: tracebacks.append(message.format(*args)),
)
manager = manager_mod.ChannelManager.__new__(manager_mod.ChannelManager)
manager._channel_errors = {}
await manager._start_channel("weixin", channel)
assert manager._channel_errors["weixin"] == (
"WeChat login expired. Scan again to reconnect."
)
assert errors == [
"Failed to start channel weixin: WeChat login expired. Scan again to reconnect."
]
assert tracebacks == []
@pytest.mark.asyncio
async def test_qr_login_refreshes_expired_qr_and_then_succeeds(
no_qr_poll_delay,
) -> None:
channel, _bus = _make_channel()
channel._running = True
channel._save_state = lambda: None
channel._save_state = lambda **_kwargs: None
channel._print_qr_code = lambda url: None
channel._api_get = AsyncMock(
channel._api_post = AsyncMock(
side_effect=[
{"qrcode": "qr-1", "qrcode_img_content": "url-1"},
{"qrcode": "qr-2", "qrcode_img_content": "url-2"},
@@ -627,7 +765,7 @@ async def test_qr_login_returns_false_after_too_many_expired_qr_codes(
channel, _bus = _make_channel()
channel._running = True
channel._print_qr_code = lambda url: None
channel._api_get = AsyncMock(
channel._api_post = AsyncMock(
side_effect=[
{"qrcode": "qr-1", "qrcode_img_content": "url-1"},
{"qrcode": "qr-2", "qrcode_img_content": "url-2"},
@@ -655,7 +793,7 @@ async def test_qr_login_switches_polling_base_url_on_redirect_status(
) -> None:
channel, _bus = _make_channel()
channel._running = True
channel._save_state = lambda: None
channel._save_state = lambda **_kwargs: None
channel._print_qr_code = lambda url: None
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
@@ -689,7 +827,7 @@ async def test_qr_login_redirect_without_host_keeps_current_polling_base_url(
) -> None:
channel, _bus = _make_channel()
channel._running = True
channel._save_state = lambda: None
channel._save_state = lambda **_kwargs: None
channel._print_qr_code = lambda url: None
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
@@ -723,7 +861,7 @@ async def test_qr_login_resets_redirect_base_url_after_qr_refresh(
) -> None:
channel, _bus = _make_channel()
channel._running = True
channel._save_state = lambda: None
channel._save_state = lambda **_kwargs: None
channel._print_qr_code = lambda url: None
channel._fetch_qr_code = AsyncMock(side_effect=[("qr-1", "url-1"), ("qr-2", "url-2")])
@@ -1015,7 +1153,7 @@ async def test_qr_login_treats_temporary_connect_error_as_wait_and_recovers(
) -> None:
channel, _bus = _make_channel()
channel._running = True
channel._save_state = lambda: None
channel._save_state = lambda **_kwargs: None
channel._print_qr_code = lambda url: None
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
@@ -1045,7 +1183,7 @@ async def test_qr_login_treats_5xx_gateway_response_error_as_wait_and_recovers(
) -> None:
channel, _bus = _make_channel()
channel._running = True
channel._save_state = lambda: None
channel._save_state = lambda **_kwargs: None
channel._print_qr_code = lambda url: None
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
@@ -1080,6 +1218,32 @@ def test_decrypt_aes_ecb_strips_valid_pkcs7_padding() -> None:
assert decrypted == plaintext
def test_missing_aes_dependency_recommends_weixin_plugin(monkeypatch) -> None:
real_import = __import__
def fake_import(name, *args, **kwargs):
if name.startswith(("Crypto", "cryptography")):
raise ImportError("missing AES dependency")
return real_import(name, *args, **kwargs)
warnings: list[str] = []
monkeypatch.setattr("builtins.__import__", fake_import)
monkeypatch.setattr(
weixin_mod.logger,
"warning",
lambda message, *args: warnings.append(message.format(*args)),
)
key_b64 = "MDEyMzQ1Njc4OWFiY2RlZg=="
data = b"unencrypted media"
assert _encrypt_aes_ecb(data, key_b64) == data
assert _decrypt_aes_ecb(data, key_b64) == data
assert warnings == [
"Cannot encrypt media. Run `nanobot plugins enable weixin` to install WeChat support.",
"Cannot decrypt media. Run `nanobot plugins enable weixin` to install WeChat support.",
]
class _DummyDownloadResponse:
def __init__(self, content: bytes, status_code: int = 200) -> None:
self.content = content
@@ -1412,7 +1576,7 @@ async def test_send_text_raises_on_api_error() -> None:
return_value={"errcode": -14, "errmsg": "session expired"}
)
with pytest.raises(RuntimeError, match="WeChat send text error.*-14"):
with pytest.raises(WeixinAuthError, match="WeChat sendmessage failed.*errcode=-14"):
await channel._send_text("wx-user", "hello", "ctx-expired")
channel._api_post.assert_awaited_once()
@@ -1445,7 +1609,7 @@ async def test_send_text_raises_on_nonzero_ret_even_when_errcode_zero() -> None:
return_value={"ret": -100, "errcode": 0, "errmsg": "internal error"}
)
with pytest.raises(RuntimeError, match="WeChat send text error.*ret=-100.*errcode=0"):
with pytest.raises(RuntimeError, match="WeChat sendmessage failed.*ret=-100.*errcode=0"):
await channel._send_text("wx-user", "hello", "ctx-ok")
channel._api_post.assert_awaited_once()
@@ -0,0 +1,441 @@
from __future__ import annotations
import asyncio
import json
import time
from unittest.mock import AsyncMock
import httpx
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager
from nanobot.channels.weixin.manifest import SETUP_SPEC
from nanobot.channels.weixin.runtime import (
ITEM_TOOL_CALL_RESULT,
ITEM_TOOL_CALL_START,
WEIXIN_MAX_MESSAGE_LEN,
WeixinAPIError,
WeixinAuthError,
WeixinChannel,
WeixinConfig,
WeixinQuotaError,
sanitize_weixin_markdown,
split_weixin_message,
)
from nanobot.config.schema import Config
def _channel(**config: object) -> WeixinChannel:
return WeixinChannel(
WeixinConfig.model_validate(
{"enabled": True, "allowFrom": ["*"], **config}
),
MessageBus(),
)
def _ready_channel(**config: object) -> WeixinChannel:
channel = _channel(**config)
channel._client = object()
channel._token = "bot-token"
channel._context_tokens["wx-user"] = "ctx-1"
channel._context_token_at["wx-user"] = time.time()
channel._typing_tickets["wx-user"] = {
"ticket": "",
"next_fetch_at": time.time() + 3600,
}
return channel
def test_weixin_defaults_protect_context_quota() -> None:
config = WeixinConfig()
assert WEIXIN_MAX_MESSAGE_LEN == 1800
assert config.send_progress is False
assert config.send_tool_hints is False
assert config.reply_progress_messages is False
assert config.context_message_budget == 8
assert config.block_streaming is False
def test_weixin_webui_manifest_covers_runtime_configuration() -> None:
runtime_fields = set(WeixinConfig().model_dump(mode="json", by_alias=True))
assert set(SETUP_SPEC.fields) == runtime_fields - {"enabled"}
def test_reply_progress_opt_in_enables_progress_transport() -> None:
config = WeixinConfig(reply_progress_messages=True)
assert config.send_progress is True
assert config.send_tool_hints is True
@pytest.mark.parametrize(
("section", "send_progress", "send_tool_hints"),
[
({"enabled": True}, False, False),
({"enabled": True, "replyProgressMessages": True}, True, True),
({"enabled": True, "sendProgress": True, "sendToolHints": False}, True, False),
],
)
def test_channel_manager_preserves_weixin_quota_defaults(
section: dict[str, object],
send_progress: bool,
send_tool_hints: bool,
) -> None:
manager = ChannelManager.__new__(ChannelManager)
manager.config = Config.model_validate({"channels": {"weixin": section}})
manager.bus = MessageBus()
channel = manager._build_channel("weixin", WeixinChannel, section)
assert channel.send_progress is send_progress
assert channel.send_tool_hints is send_tool_hints
@pytest.mark.asyncio
async def test_channel_manager_does_not_retry_permanent_weixin_error(monkeypatch) -> None:
manager = ChannelManager.__new__(ChannelManager)
manager.config = Config.model_validate({"channels": {"sendMaxRetries": 3}})
manager.bus = MessageBus()
channel = _channel()
channel.send = AsyncMock(
side_effect=WeixinAPIError(
"sendmessage",
errcode=-1,
errmsg="business rejection",
retryable=False,
)
)
sleep = AsyncMock()
monkeypatch.setattr("nanobot.channels.manager.asyncio.sleep", sleep)
await manager._send_with_retry(
channel,
OutboundMessage(channel="weixin", chat_id="wx-user", content="test"),
)
channel.send.assert_awaited_once()
sleep.assert_not_awaited()
@pytest.mark.asyncio
async def test_weixin_http_clients_ignore_system_proxy(tmp_path, monkeypatch) -> None:
captured: list[dict[str, object]] = []
class FakeClient:
async def aclose(self) -> None:
return None
def make_client(**kwargs: object) -> FakeClient:
captured.append(kwargs)
return FakeClient()
monkeypatch.setattr("nanobot.channels.weixin.runtime.httpx.AsyncClient", make_client)
connect_channel = _channel(stateDir=str(tmp_path / "connect"))
connect_channel.connect_open_client()
await connect_channel.connect_close_client()
login_channel = _channel(stateDir=str(tmp_path / "login"))
login_channel._qr_login = AsyncMock(return_value=True)
assert await login_channel.login() is True
start_channel = _channel(token="configured-token", stateDir=str(tmp_path / "start"))
async def stop_after_poll() -> None:
start_channel._running = False
start_channel._notify_lifecycle = AsyncMock()
start_channel._poll_once = AsyncMock(side_effect=stop_after_poll)
await start_channel.start()
await start_channel.stop()
assert len(captured) == 3
assert all(kwargs["trust_env"] is False for kwargs in captured)
def test_markdown_sanitizer_preserves_code_and_escapes_bare_angles() -> None:
content = "before <tag> `x<y>`\n```python\na<b\n```\n![drop](https://x.test/a.png)"
sanitized = sanitize_weixin_markdown(content)
assert "before tag" in sanitized
assert "`x<y>`" in sanitized
assert "a<b" in sanitized
assert "![drop]" not in sanitized
def test_markdown_split_balances_fences_and_stays_within_limit() -> None:
chunks = split_weixin_message("```python\n" + ("x" * 4000) + "\n```")
assert len(chunks) >= 3
assert all(len(chunk) <= WEIXIN_MAX_MESSAGE_LEN for chunk in chunks)
assert all(chunk.count("```") % 2 == 0 for chunk in chunks)
@pytest.mark.asyncio
async def test_qr_fetch_posts_known_local_tokens(tmp_path) -> None:
state_dir = tmp_path / "weixin"
state_dir.mkdir()
(state_dir / "account.json").write_text(
json.dumps({"token": "persisted-token"}),
encoding="utf-8",
)
channel = _channel(stateDir=str(state_dir))
channel._api_post = AsyncMock(
return_value={"qrcode": "qr-1", "qrcode_img_content": "https://qr.test/1"}
)
assert await channel._fetch_qr_code() == ("qr-1", "https://qr.test/1")
channel._api_post.assert_awaited_once_with(
"ilink/bot/get_bot_qrcode?bot_type=3",
{"local_token_list": ["persisted-token"]},
auth=False,
include_base_info=False,
)
@pytest.mark.asyncio
async def test_qr_fetch_retries_without_rejected_local_tokens(tmp_path) -> None:
state_dir = tmp_path / "weixin"
state_dir.mkdir()
(state_dir / "account.json").write_text(
json.dumps({"token": "invalid-token"}),
encoding="utf-8",
)
channel = _channel(stateDir=str(state_dir))
channel._api_post = AsyncMock(
side_effect=[
{"ret": -3},
{"ret": 0, "qrcode": "qr-1", "qrcode_img_content": "https://qr.test/1"},
]
)
assert await channel._fetch_qr_code() == ("qr-1", "https://qr.test/1")
assert [call.args[1] for call in channel._api_post.await_args_list] == [
{"local_token_list": ["invalid-token"]},
{"local_token_list": []},
]
@pytest.mark.asyncio
async def test_qr_fetch_does_not_retry_invalid_request_without_local_tokens(tmp_path) -> None:
channel = _channel(stateDir=str(tmp_path / "weixin"))
channel._api_post = AsyncMock(return_value={"ret": -3})
with pytest.raises(WeixinAPIError, match="get_bot_qrcode failed.*ret=-3"):
await channel._fetch_qr_code()
channel._api_post.assert_awaited_once()
@pytest.mark.asyncio
async def test_lifecycle_notifications_are_best_effort() -> None:
channel = _ready_channel()
channel._api_post = AsyncMock(return_value={"ret": 0})
await channel._notify_lifecycle("start")
await channel._notify_lifecycle("stop")
assert [call.args[0] for call in channel._api_post.await_args_list] == [
"ilink/bot/msg/notifystart",
"ilink/bot/msg/notifystop",
]
def test_business_errors_have_explicit_retry_contracts() -> None:
channel = _channel()
with pytest.raises(WeixinQuotaError) as quota:
channel._raise_for_api_error("sendmessage", {"ret": -2})
with pytest.raises(WeixinAuthError) as auth:
channel._raise_for_api_error("getupdates", {"errcode": -14})
with pytest.raises(WeixinAPIError) as rejected:
channel._raise_for_api_error("sendmessage", {"ret": -100})
assert channel.should_retry_send_error(quota.value) is False
assert channel.should_retry_send_error(auth.value) is False
assert channel.should_retry_send_error(rejected.value) is False
assert channel.should_retry_send_error(httpx.ReadTimeout("slow")) is True
request = httpx.Request("POST", "https://ilinkai.weixin.qq.com/send")
for status_code in (408, 425, 429, 503):
response = httpx.Response(status_code, request=request)
error = httpx.HTTPStatusError(
"retryable response",
request=request,
response=response,
)
assert channel.should_retry_send_error(error) is True
rejected_response = httpx.Response(400, request=request)
rejected_http = httpx.HTTPStatusError(
"bad request",
request=request,
response=rejected_response,
)
assert channel.should_retry_send_error(rejected_http) is False
def test_error_classification_checks_ret_and_errcode_independently() -> None:
channel = _channel()
with pytest.raises(WeixinQuotaError):
channel._raise_for_api_error(
"sendmessage",
{"ret": -2, "errcode": -100},
)
with pytest.raises(WeixinAuthError):
channel._raise_for_api_error(
"getupdates",
{"ret": -14, "errcode": -100},
)
@pytest.mark.asyncio
async def test_stop_cancels_inflight_long_poll() -> None:
channel = _channel(token="configured-token")
poll_started = asyncio.Event()
poll_cancelled = asyncio.Event()
class FakeClient:
async def aclose(self) -> None:
return None
async def blocking_poll() -> None:
poll_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
poll_cancelled.set()
raise
channel._new_http_client = lambda _timeout: FakeClient() # type: ignore[method-assign]
channel._notify_lifecycle = AsyncMock()
channel._poll_once = blocking_poll # type: ignore[method-assign]
start_task = asyncio.create_task(channel.start())
await asyncio.wait_for(poll_started.wait(), timeout=1)
await asyncio.wait_for(channel.stop(), timeout=1)
await asyncio.wait_for(start_task, timeout=1)
assert poll_cancelled.is_set()
assert channel._poll_task is None
@pytest.mark.asyncio
async def test_retry_reuses_client_id_and_skips_completed_chunks() -> None:
channel = _ready_channel()
request = httpx.Request("POST", "https://ilinkai.weixin.qq.com/ilink/bot/sendmessage")
channel._api_post = AsyncMock(
side_effect=[
{"ret": 0},
httpx.ReadTimeout("ambiguous timeout", request=request),
{"ret": 0},
]
)
msg = OutboundMessage(
channel="weixin",
chat_id="wx-user",
content="x" * (WEIXIN_MAX_MESSAGE_LEN + 200),
)
with pytest.raises(httpx.ReadTimeout):
await channel.send(msg)
await channel.send(msg)
bodies = [call.args[1] for call in channel._api_post.await_args_list]
client_ids = [body["msg"]["client_id"] for body in bodies]
assert client_ids[0] != client_ids[1]
assert client_ids[1] == client_ids[2]
assert channel._context_send_counts["ctx-1"] == 2
@pytest.mark.asyncio
async def test_quota_rejection_defers_final_until_fresh_context() -> None:
channel = _ready_channel()
channel._api_post = AsyncMock(side_effect=[{"ret": -2}, {"ret": 0}])
msg = OutboundMessage(
channel="weixin",
chat_id="wx-user",
content="deferred answer",
)
with pytest.raises(WeixinQuotaError):
await channel.send(msg)
first_client_id = channel._api_post.await_args_list[0].args[1]["msg"]["client_id"]
assert "wx-user" in channel._deferred_outbound
channel._context_tokens["wx-user"] = "ctx-2"
channel._context_token_at["wx-user"] = time.time()
await channel._retry_deferred_messages("wx-user")
second_client_id = channel._api_post.await_args_list[1].args[1]["msg"]["client_id"]
assert second_client_id == first_client_id
assert "wx-user" not in channel._deferred_outbound
@pytest.mark.asyncio
async def test_local_context_budget_stops_before_extra_api_call() -> None:
channel = _ready_channel(contextMessageBudget=1)
channel._api_post = AsyncMock(return_value={"ret": 0})
await channel._send_text("wx-user", "one", "ctx-1")
with pytest.raises(WeixinQuotaError, match="local safety budget"):
await channel._send_text("wx-user", "two", "ctx-1")
channel._api_post.assert_awaited_once()
@pytest.mark.asyncio
async def test_bounded_block_streaming_reserves_one_final_message() -> None:
channel = _ready_channel(
blockStreaming=True,
blockStreamingMinChars=200,
blockStreamingMaxMessages=3,
)
channel._send_text = AsyncMock()
await channel.send_delta("wx-user", "a" * 250, stream_id="stream-1")
await channel.send_delta("wx-user", "b" * 250, stream_id="stream-1")
await channel.send_delta("wx-user", "c" * 250, stream_id="stream-1")
await channel.send_delta("wx-user", "done", stream_id="stream-1", stream_end=True)
assert channel._send_text.await_count == 3
assert "stream-1" not in channel._stream_buffers
assert "stream-1" not in channel._stream_sent_counts
@pytest.mark.asyncio
async def test_structured_progress_is_capped_and_uses_one_run_id() -> None:
channel = _ready_channel(
replyProgressMessages=True,
replyProgressMaxMessages=2,
)
channel._send_message_item = AsyncMock()
events = [
{"phase": "start", "call_id": "call-1", "name": "read_file"},
{"phase": "end", "call_id": "call-1", "name": "read_file"},
{"phase": "start", "call_id": "call-2", "name": "exec"},
]
await channel.send(
OutboundMessage(
channel="weixin",
chat_id="wx-user",
content="read_file",
event=ProgressEvent(content="read_file", tool_hint=True, tool_events=events),
)
)
assert channel._send_message_item.await_count == 2
first = channel._send_message_item.await_args_list[0]
second = channel._send_message_item.await_args_list[1]
assert first.args[1]["type"] == ITEM_TOOL_CALL_START
assert second.args[1]["type"] == ITEM_TOOL_CALL_RESULT
assert first.kwargs["run_id"] == second.kwargs["run_id"]
@@ -1,25 +1,148 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { channelTranslator } from "@/channel-plugins/i18n";
import {
channelTranslator,
type ChannelTranslator,
} from "@/channel-plugins/i18n";
import type { ChannelPluginConnectFlowProps } from "@/channel-plugins/types";
import { ChannelQrConnectFlow } from "@/components/settings/channels/ChannelQrConnectFlow";
import {
ChannelQrConnectFlow,
type ChannelQrConnectPendingContext,
} from "@/components/settings/channels/ChannelQrConnectFlow";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { ChannelConnectPayload } from "@/lib/types";
type WeixinVerificationPayload = ChannelConnectPayload & {
challenge: "verify_code";
verification_failed?: boolean;
};
export const WEIXIN_AUTH_EXPIRED_MESSAGE =
"WeChat login expired. Scan again to reconnect.";
function isVerificationChallenge(
payload: ChannelConnectPayload,
): payload is WeixinVerificationPayload {
return (
"challenge" in payload
&& payload.challenge === "verify_code"
&& (
!("verification_failed" in payload)
|| typeof payload.verification_failed === "boolean"
)
);
}
function weixinConnectMessage(
payload: ChannelConnectPayload,
tx: ChannelTranslator,
): string {
if (payload.status === "succeeded") {
return tx("custom.connected", "WeChat is connected.");
}
if (payload.status === "expired") {
return tx("custom.expired", WEIXIN_AUTH_EXPIRED_MESSAGE);
}
if (payload.status === "failed") {
return payload.message
?? tx("custom.failed", "Unable to connect WeChat. Try again.");
}
if (payload.status === "cancelled") {
return tx("custom.stopped", "WeChat login stopped.");
}
if (isVerificationChallenge(payload)) {
return payload.verification_failed
? tx(
"custom.verifyMismatch",
"That code did not match. Enter the new number shown in WeChat.",
)
: tx(
"custom.verifyDescription",
"Enter the number shown in WeChat to continue.",
);
}
return tx("custom.waiting", "Waiting for WeChat scan...");
}
export function WeixinConnectFlow({
token,
feature,
idleLabel,
connectRequestId,
onFeaturesUpdate,
}: ChannelPluginConnectFlowProps) {
const { t } = useTranslation();
const tx = channelTranslator(t, "weixin");
const [verificationCode, setVerificationCode] = useState("");
const authExpired = feature.runtime_error === WEIXIN_AUTH_EXPIRED_MESSAGE;
const scanAgainLabel = t("settings.channels.scanAgain", {
defaultValue: "Scan again",
});
const renderVerification = ({
connect,
busy,
poll,
}: ChannelQrConnectPendingContext) => {
if (!isVerificationChallenge(connect)) return null;
return (
<form
className="mt-3 space-y-2"
onSubmit={(event) => {
event.preventDefault();
const code = verificationCode.trim();
if (!code) return;
void poll({ verify_code: code }).then((payload) => {
if (payload && !isVerificationChallenge(payload)) {
setVerificationCode("");
}
});
}}
>
<div className="text-[12px] font-semibold text-foreground">
{tx("custom.verifyTitle", "Verification required")}
</div>
<p className="text-[12px] leading-5 text-muted-foreground">
{weixinConnectMessage(connect, tx)}
</p>
<div className="flex gap-2">
<Input
value={verificationCode}
onChange={(event) => setVerificationCode(event.target.value)}
inputMode="numeric"
autoComplete="one-time-code"
placeholder={tx("custom.verifyPlaceholder", "Code")}
className="h-8 max-w-40"
aria-invalid={connect.verification_failed || undefined}
/>
<Button
type="submit"
size="sm"
className="h-8 rounded-full px-3 text-[12px] font-semibold"
disabled={busy || !verificationCode.trim()}
>
{tx("custom.verifySubmit", "Verify")}
</Button>
</div>
</form>
);
};
return (
<ChannelQrConnectFlow
token={token}
channelName="weixin"
idleLabel={idleLabel}
startOptions={{ force: authExpired }}
idleLabel={authExpired ? scanAgainLabel : idleLabel}
connectRequestId={connectRequestId}
forceOnRepeat
onFeaturesUpdate={onFeaturesUpdate}
pausePolling={isVerificationChallenge}
suppressSucceeded={feature.runtime_status === "failed"}
renderPending={renderVerification}
resolveMessage={(payload) => weixinConnectMessage(payload, tx)}
labels={{
qrAlt: tx("custom.qrAlt", "WeChat login QR code"),
scanTitle: tx("custom.scanTitle", "Scan with WeChat"),
@@ -31,7 +154,7 @@ export function WeixinConnectFlow({
connected: tx("custom.connected", "WeChat is connected."),
stopped: tx("custom.stopped", "WeChat login stopped."),
connecting: tx("custom.connecting", "Connecting..."),
scanAgain: t("settings.channels.scanAgain", { defaultValue: "Scan again" }),
scanAgain: scanAgainLabel,
connect: t("settings.channels.connect", { defaultValue: "Connect" }),
}}
/>
@@ -0,0 +1,553 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Check, ChevronDown, ExternalLink, Loader2, Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { channelFieldMessageKey, channelTranslator } from "@/channel-plugins/i18n";
import { channelLocaleMessages } from "@/channel-plugins/locale-registry";
import type { ChannelPluginPanelProps } from "@/channel-plugins/types";
import { ToggleButton } from "@/components/settings/ToggleButton";
import {
chatAppGuideUrl,
docsUrlWithBase,
type ChannelConfigField,
} from "@/components/settings/channels/catalog";
import {
CredentialForm,
channelValuesForSave,
defaultChannelFieldValues,
} from "@/components/settings/channels/CredentialForm";
import { Button } from "@/components/ui/button";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { normalizeLocale } from "@/i18n/config";
import { configureChannel } from "@/lib/api";
import { logoFallbackUrls } from "@/lib/provider-brand";
import type {
ChannelRuntimeStatus,
ChannelSetupContractField,
NanobotFeatureInfo,
} from "@/lib/types";
import { cn } from "@/lib/utils";
import {
WEIXIN_AUTH_EXPIRED_MESSAGE,
WeixinConnectFlow,
} from "./WeixinConnectFlow";
export const WEIXIN_PRIMARY_FIELD_KEYS = [
"channels.weixin.sendProgress",
"channels.weixin.sendToolHints",
"channels.weixin.streaming",
] as const;
export const WEIXIN_ADVANCED_FIELD_KEYS = [
"channels.weixin.allowFrom",
"channels.weixin.token",
"channels.weixin.replyProgressMessages",
"channels.weixin.replyProgressMaxMessages",
"channels.weixin.contextMessageBudget",
"channels.weixin.blockStreaming",
"channels.weixin.blockStreamingMinChars",
"channels.weixin.blockStreamingMaxMessages",
"channels.weixin.baseUrl",
"channels.weixin.cdnBaseUrl",
"channels.weixin.routeTag",
"channels.weixin.stateDir",
"channels.weixin.pollTimeout",
] as const;
export function WeixinPanel({
token,
feature,
actionKey,
chatAppsDocsUrl,
showBrandLogos,
onAction,
onFeaturesUpdate,
}: ChannelPluginPanelProps) {
const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const channelTx = channelTranslator(t, "weixin");
const runtimeError = weixinRuntimeError(feature.runtime_error, channelTx);
const displayName = channelTx("displayName", "WeChat");
const enabledBusy = actionKey === `enable:${feature.name}`;
const disabledBusy = actionKey === `disable:${feature.name}`;
const channelBusy = enabledBusy || disabledBusy;
const channelChecked =
feature.runtime_status === "running" || feature.runtime_status === "starting";
const missingSupport = feature.enabled && !feature.installed;
const alwaysEnabled = feature.capabilities?.includes("always_enabled") ?? false;
const toggleChecked = alwaysEnabled || channelChecked;
const channelToggleDisabled =
alwaysEnabled
|| channelBusy
|| (!feature.install_supported && !feature.installed && !feature.enabled);
const [connectRequestId, setConnectRequestId] = useState(0);
const [visibleSecrets, setVisibleSecrets] = useState<Record<string, boolean>>({});
const [touchedFields, setTouchedFields] = useState<Set<string>>(() => new Set());
const [saving, setSaving] = useState(false);
const [saveRevision, setSaveRevision] = useState(0);
const [attemptedRevision, setAttemptedRevision] = useState(0);
const [saveState, setSaveState] = useState<"idle" | "saved">("idle");
const [saveError, setSaveError] = useState<string | null>(null);
const configValuesKey = JSON.stringify(feature.config_values ?? {});
const setupFieldsKey = JSON.stringify(feature.setup?.fields ?? []);
const configuredFields = useMemo(
() => new Set(feature.configured_fields ?? []),
[feature.configured_fields],
);
const onLabel = tx("settings.values.on", "On");
const offLabel = tx("settings.values.off", "Off");
const setupFields = weixinSetupFields(
feature,
i18n.resolvedLanguage ?? i18n.language,
);
const primaryFields = localizeBooleanFields(setupFields.primary, onLabel, offLabel);
const advancedFields = localizeBooleanFields(setupFields.advanced, onLabel, offLabel);
const editableFields = [...primaryFields, ...advancedFields];
const docsUrl = docsUrlWithBase(chatAppGuideUrl("wechat"), chatAppsDocsUrl)
?? chatAppGuideUrl("wechat");
const [fieldValues, setFieldValues] = useState<Record<string, string>>(() =>
defaultChannelFieldValues(editableFields, feature.config_values),
);
const fieldValuesRef = useRef(fieldValues);
const touchedFieldsRef = useRef(touchedFields);
const editableFieldsRef = useRef(editableFields);
const saveContextRef = useRef({
token,
enabled: feature.enabled,
onFeaturesUpdate,
});
editableFieldsRef.current = editableFields;
saveContextRef.current = {
token,
enabled: feature.enabled,
onFeaturesUpdate,
};
useEffect(() => {
const nextValues = defaultChannelFieldValues(editableFields, feature.config_values);
for (const key of touchedFieldsRef.current) {
nextValues[key] = fieldValuesRef.current[key] ?? "";
}
fieldValuesRef.current = nextValues;
setFieldValues(nextValues);
setVisibleSecrets({});
}, [configValuesKey, setupFieldsKey]);
useEffect(() => {
if (saveState !== "saved") return;
const timeout = window.setTimeout(() => setSaveState("idle"), 1500);
return () => window.clearTimeout(timeout);
}, [saveState]);
const saveSettings = useCallback(async (
values: Record<string, string>,
savedFields: Set<string>,
) => {
const context = saveContextRef.current;
setSaving(true);
setSaveError(null);
setSaveState("idle");
try {
const payload = await configureChannel(
context.token,
"weixin",
channelValuesForSave(editableFieldsRef.current, values),
{ enable: context.enabled },
);
const remainingFields = new Set(touchedFieldsRef.current);
for (const key of savedFields) {
if (fieldValuesRef.current[key] === values[key]) remainingFields.delete(key);
}
touchedFieldsRef.current = remainingFields;
setTouchedFields(remainingFields);
setSaveState(remainingFields.size ? "idle" : "saved");
if (payload.nanobot_features) context.onFeaturesUpdate(payload.nanobot_features);
} catch (err) {
setSaveError((err as Error).message);
} finally {
setSaving(false);
}
}, []);
useEffect(() => {
if (
!editableFields.length
|| !touchedFields.size
|| saving
|| saveRevision <= attemptedRevision
) return;
const timeout = window.setTimeout(() => {
setAttemptedRevision(saveRevision);
void saveSettings(
{ ...fieldValuesRef.current },
new Set(touchedFieldsRef.current),
);
}, 500);
return () => window.clearTimeout(timeout);
}, [
attemptedRevision,
editableFields.length,
saveRevision,
saveSettings,
saving,
touchedFields.size,
]);
const setFieldValue = (key: string, value: string) => {
if (fieldValuesRef.current[key] === value) return;
const nextValues = { ...fieldValuesRef.current, [key]: value };
const nextTouchedFields = new Set(touchedFieldsRef.current).add(key);
fieldValuesRef.current = nextValues;
touchedFieldsRef.current = nextTouchedFields;
setFieldValues(nextValues);
setTouchedFields(nextTouchedFields);
setSaveError(null);
setSaveState("idle");
setSaveRevision((current) => current + 1);
};
const toggleAriaLabel = t("settings.channels.toggleChannel", {
name: displayName,
defaultValue: "{{name}} channel",
});
return (
<aside className="min-h-full rounded-[20px] bg-settings-surface p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<WeixinLogo showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<h3 className="truncate text-[18px] font-semibold leading-6 text-foreground">
{displayName}
</h3>
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
{channelTx("description", "Use nanobot from WeChat conversations.")}
</p>
{missingSupport && feature.install_supported ? (
<Button
type="button"
size="sm"
variant="secondary"
disabled={enabledBusy}
onClick={() => onAction("enable", feature.name)}
className="mt-2 h-8 rounded-full px-3 text-[12px] font-semibold"
>
{enabledBusy ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{tx("settings.nanobotFeatures.installSupport", "Install support")}
</Button>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-2 pt-1">
<WeixinStatusBadge status={feature.runtime_status}>
{weixinStatusLabel(feature, tx)}
</WeixinStatusBadge>
{channelBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-hidden />
) : null}
<ToggleButton
checked={toggleChecked}
disabled={channelToggleDisabled}
ariaLabel={toggleAriaLabel}
label={toggleChecked ? onLabel : offLabel}
onChange={(checked) => {
if (checked && !channelChecked && feature.configured === false) {
setConnectRequestId((current) => current + 1);
return;
}
onAction(checked ? "enable" : "disable", feature.name);
}}
/>
</div>
</div>
{runtimeError ? (
<div className="mt-4 rounded-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive">
{runtimeError}
</div>
) : null}
<div className="mt-4 space-y-4">
<WeixinConnectFlow
token={token}
feature={feature}
idleLabel={channelTx("setup.primaryAction", "Connect WeChat")}
connectRequestId={connectRequestId}
onFeaturesUpdate={onFeaturesUpdate}
/>
{primaryFields.length ? (
<CredentialForm
fields={primaryFields}
values={fieldValues}
configuredFields={configuredFields}
visibleSecrets={visibleSecrets}
onChange={setFieldValue}
onToggleSecret={(key) => {
setVisibleSecrets((current) => ({ ...current, [key]: !current[key] }));
}}
compact
/>
) : null}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className={cn(
"flex items-center justify-end gap-1.5 text-[11px] leading-4 text-muted-foreground",
!saving && saveState !== "saved" && "sr-only",
)}
>
{saving ? (
<>
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
{tx("settings.actions.saving", "Saving")}
</>
) : saveState === "saved" ? (
<>
<Check className="h-3 w-3" aria-hidden />
{tx("settings.channels.savedSettings", "Saved settings.")}
</>
) : null}
</div>
{saveError ? (
<div
role="alert"
className="rounded-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive"
>
{saveError}
</div>
) : null}
{advancedFields.length ? (
<details className="group text-[12px] leading-5 text-muted-foreground">
<summary className="cursor-pointer list-none text-[12px] font-semibold text-foreground">
<span className="inline-flex items-center gap-1.5">
{tx("settings.channels.advanced", "Advanced")}
<ChevronDown
className="h-3.5 w-3.5 transition-transform group-open:rotate-180"
aria-hidden
/>
</span>
</summary>
<div className="mt-3">
<CredentialForm
fields={advancedFields}
values={fieldValues}
configuredFields={configuredFields}
visibleSecrets={visibleSecrets}
onChange={setFieldValue}
onToggleSecret={(key) => {
setVisibleSecrets((current) => ({ ...current, [key]: !current[key] }));
}}
compact
/>
</div>
</details>
) : null}
<div className="flex justify-end">
<WeixinGuideLink
url={docsUrl}
label={channelTx("setup.docsLabel", "Open WeChat setup")}
/>
</div>
</div>
</aside>
);
}
function weixinSetupFields(
feature: NanobotFeatureInfo,
locale: string,
): { primary: ChannelConfigField[]; advanced: ChannelConfigField[] } {
const fields = feature.setup?.fields ?? [];
const fieldsByKey = new Map(fields.map((field) => [field.key, field]));
const messages = channelLocaleMessages("weixin", normalizeLocale(locale))?.setup;
const knownKeys = new Set<string>([
...WEIXIN_PRIMARY_FIELD_KEYS,
...WEIXIN_ADVANCED_FIELD_KEYS,
]);
const extraKeys = fields
.map((field) => field.key)
.filter((key) => !knownKeys.has(key));
const hydrate = (keys: readonly string[]) => keys.flatMap((key) => {
const field = fieldsByKey.get(key);
if (!field) return [];
const copy = messages?.fields?.[channelFieldMessageKey("weixin", key)];
return [weixinConfigField(field, copy)];
});
return {
primary: hydrate(WEIXIN_PRIMARY_FIELD_KEYS),
advanced: hydrate([...WEIXIN_ADVANCED_FIELD_KEYS, ...extraKeys]),
};
}
function weixinConfigField(
field: ChannelSetupContractField,
copy: { label: string; placeholder?: string; help?: string; choices?: Record<string, string> }
| undefined,
): ChannelConfigField {
const choices = field.kind === "bool" ? ["true", "false"] : field.choices;
return {
key: field.key,
label: copy?.label ?? fieldLabel(field.field),
placeholder: copy?.placeholder,
help: copy?.help,
secret: field.kind === "secret",
optional: !field.required,
inputType: field.kind === "int" ? "number" : undefined,
defaultValue: field.default_value,
options:
field.kind === "enum" || field.kind === "bool"
? choices.map((choice) => ({
value: choice,
label: copy?.choices?.[choice] ?? fieldLabel(choice),
}))
: undefined,
};
}
function fieldLabel(value: string): string {
const spaced = value
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[_-]+/g, " ")
.trim();
return spaced ? spaced[0].toUpperCase() + spaced.slice(1) : value;
}
function WeixinLogo({ showBrandLogos }: { showBrandLogos: boolean }) {
const logoUrls = useMemo(() => logoFallbackUrls("https://weixin.qq.com/favicon.ico"), []);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
if (showBrandLogos && logoUrl) {
return (
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] bg-background">
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-5.5 w-5.5 max-h-6 max-w-6 object-contain"
onLoad={onLogoLoad}
onError={onLogoError}
/>
</span>
);
}
return (
<span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] bg-background text-[11px] font-bold"
style={{ color: "#07C160" }}
aria-hidden
>
WX
</span>
);
}
function WeixinGuideLink({ url, label }: { url: string; label: string }) {
const logoUrls = useMemo(() => logoFallbackUrls("https://weixin.qq.com/favicon.ico"), []);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline-flex max-w-full items-center gap-2 rounded-full bg-background/80 py-1 pl-1 pr-2.5 text-[11.5px] font-semibold text-foreground transition-colors hover:bg-background"
>
<span
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-full bg-muted/70 text-[9px] font-bold"
style={{ color: "#07C160" }}
aria-hidden
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-3.5 w-3.5 object-contain"
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : (
"WX"
)}
</span>
<span className="truncate">{label}</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</a>
);
}
function WeixinStatusBadge({
children,
status,
}: {
children: ReactNode;
status?: ChannelRuntimeStatus;
}) {
return (
<span className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium leading-4",
status === "failed"
? "bg-destructive/10 text-destructive"
: status === "running"
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-200"
: "bg-muted/75 text-muted-foreground",
)}>
{children}
</span>
);
}
function weixinStatusLabel(
feature: NanobotFeatureInfo,
tx: (key: string, fallback: string) => string,
): string {
if (feature.runtime_status === "failed") {
return tx("settings.channels.runtimeFailed", "Failed");
}
if (feature.runtime_status === "starting") {
return tx("settings.channels.runtimeStarting", "Starting");
}
if (feature.runtime_status === "running") return tx("settings.values.on", "On");
if (feature.enabled) return tx("settings.channels.runtimeStopped", "Not running");
return tx("settings.values.off", "Off");
}
function weixinRuntimeError(
error: string | undefined,
tx: (key: string, fallback: string) => string,
): string | undefined {
if (error === WEIXIN_AUTH_EXPIRED_MESSAGE) {
return tx("custom.expired", error);
}
return error;
}
function localizeBooleanFields(
fields: ChannelConfigField[],
onLabel: string,
offLabel: string,
): ChannelConfigField[] {
return fields.map((field) => {
const values = new Set(field.options?.map((option) => option.value));
if (values.size !== 2 || !values.has("true") || !values.has("false")) return field;
return {
...field,
options: field.options?.map((option) => ({
...option,
label: option.value === "true" ? onLabel : offLabel,
})),
};
});
}
+8 -4
View File
@@ -2,8 +2,14 @@ import type { ChannelUiContribution } from "@/channel-plugins/types";
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
import { WeixinConnectFlow } from "./WeixinConnectFlow";
import {
WEIXIN_ADVANCED_FIELD_KEYS,
WEIXIN_PRIMARY_FIELD_KEYS,
WeixinPanel,
} from "./WeixinPanel";
export default {
Panel: WeixinPanel,
ConnectFlow: WeixinConnectFlow,
canConnectBeforeConfigured: true,
aliases: {
@@ -18,10 +24,8 @@ export default {
mode: "connect",
command: "nanobot channels login weixin",
docsUrl: chatAppGuideUrl("wechat"),
manualFields: [
{ key: "channels.weixin.allowFrom" },
{ key: "channels.weixin.token" },
],
fields: WEIXIN_PRIMARY_FIELD_KEYS.map((key) => ({ key })),
manualFields: WEIXIN_ADVANCED_FIELD_KEYS.map((key) => ({ key })),
},
},
} satisfies ChannelUiContribution;
+23 -2
View File
@@ -20,7 +20,21 @@
"token": {
"label": "Token",
"placeholder": "Saved by QR login"
}
},
"sendProgress": { "label": "Send progress" },
"sendToolHints": { "label": "Send tool hints" },
"streaming": { "label": "Use streaming API" },
"replyProgressMessages": { "label": "Send structured progress" },
"replyProgressMaxMessages": { "label": "Structured progress limit" },
"contextMessageBudget": { "label": "Context message budget" },
"blockStreaming": { "label": "Send response blocks" },
"blockStreamingMinChars": { "label": "Minimum block size" },
"blockStreamingMaxMessages": { "label": "Block message limit" },
"baseUrl": { "label": "API URL" },
"cdnBaseUrl": { "label": "CDN URL" },
"routeTag": { "label": "Route tag" },
"stateDir": { "label": "State directory" },
"pollTimeout": { "label": "Poll timeout" }
}
},
"custom": {
@@ -30,6 +44,13 @@
"waiting": "Waiting for WeChat scan...",
"connected": "WeChat is connected.",
"stopped": "WeChat login stopped.",
"connecting": "Connecting..."
"connecting": "Connecting...",
"verifyTitle": "Verification required",
"verifyDescription": "Enter the number shown in WeChat to continue.",
"verifyMismatch": "That code did not match. Enter the new number shown in WeChat.",
"expired": "WeChat login expired. Scan again to reconnect.",
"failed": "Unable to connect WeChat. Try again.",
"verifyPlaceholder": "Code",
"verifySubmit": "Verify"
}
}
+23 -2
View File
@@ -20,7 +20,21 @@
"token": {
"label": "Token",
"placeholder": "Guardado al iniciar sesión por QR"
}
},
"sendProgress": { "label": "Enviar progreso" },
"sendToolHints": { "label": "Enviar indicaciones de herramientas" },
"streaming": { "label": "Usar API de streaming" },
"replyProgressMessages": { "label": "Enviar progreso estructurado" },
"replyProgressMaxMessages": { "label": "Límite de progreso estructurado" },
"contextMessageBudget": { "label": "Presupuesto de mensajes por contexto" },
"blockStreaming": { "label": "Enviar respuestas por bloques" },
"blockStreamingMinChars": { "label": "Tamaño mínimo del bloque" },
"blockStreamingMaxMessages": { "label": "Límite de mensajes por bloques" },
"baseUrl": { "label": "URL de la API" },
"cdnBaseUrl": { "label": "URL de la CDN" },
"routeTag": { "label": "Etiqueta de ruta" },
"stateDir": { "label": "Directorio de estado" },
"pollTimeout": { "label": "Tiempo de espera de consulta" }
}
},
"custom": {
@@ -30,6 +44,13 @@
"waiting": "Esperando el escaneo de WeChat...",
"connected": "WeChat está conectado.",
"stopped": "Inicio de WeChat detenido.",
"connecting": "Conectando..."
"connecting": "Conectando...",
"verifyTitle": "Se requiere verificación",
"verifyDescription": "Introduce el número que aparece en WeChat para continuar.",
"verifyMismatch": "El código no coincide. Introduce el nuevo número que aparece en WeChat.",
"expired": "El inicio de sesión de WeChat caducó. Escanea de nuevo para volver a conectarte.",
"failed": "No se pudo conectar WeChat. Inténtalo de nuevo.",
"verifyPlaceholder": "Código",
"verifySubmit": "Verificar"
}
}
+23 -2
View File
@@ -20,7 +20,21 @@
"token": {
"label": "Jeton",
"placeholder": "Enregistré après la connexion QR"
}
},
"sendProgress": { "label": "Envoyer la progression" },
"sendToolHints": { "label": "Envoyer les indications doutils" },
"streaming": { "label": "Utiliser lAPI de streaming" },
"replyProgressMessages": { "label": "Envoyer la progression structurée" },
"replyProgressMaxMessages": { "label": "Limite de progression structurée" },
"contextMessageBudget": { "label": "Budget de messages du contexte" },
"blockStreaming": { "label": "Envoyer la réponse par blocs" },
"blockStreamingMinChars": { "label": "Taille minimale dun bloc" },
"blockStreamingMaxMessages": { "label": "Limite de messages par blocs" },
"baseUrl": { "label": "URL de lAPI" },
"cdnBaseUrl": { "label": "URL du CDN" },
"routeTag": { "label": "Étiquette de routage" },
"stateDir": { "label": "Répertoire d’état" },
"pollTimeout": { "label": "Délai dinterrogation" }
}
},
"custom": {
@@ -30,6 +44,13 @@
"waiting": "En attente du scan WeChat...",
"connected": "WeChat est connecté.",
"stopped": "Connexion WeChat arrêtée.",
"connecting": "Connexion..."
"connecting": "Connexion...",
"verifyTitle": "Vérification requise",
"verifyDescription": "Saisissez le nombre affiché dans WeChat pour continuer.",
"verifyMismatch": "Le code ne correspond pas. Saisissez le nouveau nombre affiché dans WeChat.",
"expired": "La connexion WeChat a expiré. Scannez à nouveau pour vous reconnecter.",
"failed": "Impossible de connecter WeChat. Réessayez.",
"verifyPlaceholder": "Code",
"verifySubmit": "Vérifier"
}
}
+23 -2
View File
@@ -20,7 +20,21 @@
"token": {
"label": "Token",
"placeholder": "Disimpan saat login QR"
}
},
"sendProgress": { "label": "Kirim progres" },
"sendToolHints": { "label": "Kirim petunjuk alat" },
"streaming": { "label": "Gunakan API streaming" },
"replyProgressMessages": { "label": "Kirim progres terstruktur" },
"replyProgressMaxMessages": { "label": "Batas progres terstruktur" },
"contextMessageBudget": { "label": "Anggaran pesan konteks" },
"blockStreaming": { "label": "Kirim respons per blok" },
"blockStreamingMinChars": { "label": "Ukuran blok minimum" },
"blockStreamingMaxMessages": { "label": "Batas pesan blok" },
"baseUrl": { "label": "URL API" },
"cdnBaseUrl": { "label": "URL CDN" },
"routeTag": { "label": "Tag rute" },
"stateDir": { "label": "Direktori status" },
"pollTimeout": { "label": "Batas waktu polling" }
}
},
"custom": {
@@ -30,6 +44,13 @@
"waiting": "Menunggu pemindaian WeChat...",
"connected": "WeChat sudah terhubung.",
"stopped": "Login WeChat dihentikan.",
"connecting": "Menghubungkan..."
"connecting": "Menghubungkan...",
"verifyTitle": "Verifikasi diperlukan",
"verifyDescription": "Masukkan angka yang ditampilkan di WeChat untuk melanjutkan.",
"verifyMismatch": "Kode tidak cocok. Masukkan angka baru yang ditampilkan di WeChat.",
"expired": "Login WeChat telah kedaluwarsa. Pindai lagi untuk menghubungkan kembali.",
"failed": "Tidak dapat menghubungkan WeChat. Coba lagi.",
"verifyPlaceholder": "Kode",
"verifySubmit": "Verifikasi"
}
}
+23 -2
View File
@@ -20,7 +20,21 @@
"token": {
"label": "トークン",
"placeholder": "QR ログインで保存"
}
},
"sendProgress": { "label": "進捗を送信" },
"sendToolHints": { "label": "ツールのヒントを送信" },
"streaming": { "label": "ストリーミング API を使用" },
"replyProgressMessages": { "label": "構造化された進捗を送信" },
"replyProgressMaxMessages": { "label": "構造化進捗の上限" },
"contextMessageBudget": { "label": "コンテキストのメッセージ予算" },
"blockStreaming": { "label": "応答をブロック単位で送信" },
"blockStreamingMinChars": { "label": "最小ブロックサイズ" },
"blockStreamingMaxMessages": { "label": "ブロックメッセージの上限" },
"baseUrl": { "label": "API URL" },
"cdnBaseUrl": { "label": "CDN URL" },
"routeTag": { "label": "ルートタグ" },
"stateDir": { "label": "状態ディレクトリ" },
"pollTimeout": { "label": "ポーリングタイムアウト" }
}
},
"custom": {
@@ -30,6 +44,13 @@
"waiting": "WeChat のスキャンを待っています...",
"connected": "WeChat に接続しました。",
"stopped": "WeChat ログインを停止しました。",
"connecting": "接続中..."
"connecting": "接続中...",
"verifyTitle": "確認が必要です",
"verifyDescription": "WeChat に表示された数字を入力してください。",
"verifyMismatch": "コードが一致しません。WeChat に表示された新しい数字を入力してください。",
"expired": "WeChat のログイン期限が切れました。再接続するにはもう一度スキャンしてください。",
"failed": "WeChat に接続できません。もう一度お試しください。",
"verifyPlaceholder": "コード",
"verifySubmit": "確認"
}
}
+23 -2
View File
@@ -20,7 +20,21 @@
"token": {
"label": "토큰",
"placeholder": "QR 로그인으로 저장됨"
}
},
"sendProgress": { "label": "진행 상황 보내기" },
"sendToolHints": { "label": "도구 힌트 보내기" },
"streaming": { "label": "스트리밍 API 사용" },
"replyProgressMessages": { "label": "구조화된 진행 상황 보내기" },
"replyProgressMaxMessages": { "label": "구조화된 진행 메시지 한도" },
"contextMessageBudget": { "label": "컨텍스트 메시지 예산" },
"blockStreaming": { "label": "응답을 블록으로 보내기" },
"blockStreamingMinChars": { "label": "최소 블록 크기" },
"blockStreamingMaxMessages": { "label": "블록 메시지 한도" },
"baseUrl": { "label": "API URL" },
"cdnBaseUrl": { "label": "CDN URL" },
"routeTag": { "label": "경로 태그" },
"stateDir": { "label": "상태 디렉터리" },
"pollTimeout": { "label": "폴링 제한 시간" }
}
},
"custom": {
@@ -30,6 +44,13 @@
"waiting": "WeChat 스캔을 기다리는 중...",
"connected": "WeChat이 연결되었습니다.",
"stopped": "WeChat 로그인이 중지되었습니다.",
"connecting": "연결 중..."
"connecting": "연결 중...",
"verifyTitle": "인증 필요",
"verifyDescription": "계속하려면 WeChat에 표시된 숫자를 입력하세요.",
"verifyMismatch": "코드가 일치하지 않습니다. WeChat에 표시된 새 숫자를 입력하세요.",
"expired": "WeChat 로그인이 만료되었습니다. 다시 연결하려면 다시 스캔하세요.",
"failed": "WeChat에 연결할 수 없습니다. 다시 시도하세요.",
"verifyPlaceholder": "코드",
"verifySubmit": "인증"
}
}
@@ -20,7 +20,21 @@
"token": {
"label": "Token",
"placeholder": "Salvo pelo login via QR"
}
},
"sendProgress": { "label": "Enviar progresso" },
"sendToolHints": { "label": "Enviar dicas de ferramentas" },
"streaming": { "label": "Usar API de streaming" },
"replyProgressMessages": { "label": "Enviar progresso estruturado" },
"replyProgressMaxMessages": { "label": "Limite de progresso estruturado" },
"contextMessageBudget": { "label": "Orçamento de mensagens do contexto" },
"blockStreaming": { "label": "Enviar resposta em blocos" },
"blockStreamingMinChars": { "label": "Tamanho mínimo do bloco" },
"blockStreamingMaxMessages": { "label": "Limite de mensagens em blocos" },
"baseUrl": { "label": "URL da API" },
"cdnBaseUrl": { "label": "URL da CDN" },
"routeTag": { "label": "Etiqueta de rota" },
"stateDir": { "label": "Diretório de estado" },
"pollTimeout": { "label": "Tempo limite da consulta" }
}
},
"custom": {
@@ -30,6 +44,13 @@
"waiting": "Aguardando leitura do WeChat...",
"connected": "WeChat está conectado.",
"stopped": "Login do WeChat interrompido.",
"connecting": "Conectando..."
"connecting": "Conectando...",
"verifyTitle": "Verificação necessária",
"verifyDescription": "Digite o número exibido no WeChat para continuar.",
"verifyMismatch": "O código não corresponde. Digite o novo número exibido no WeChat.",
"expired": "O login do WeChat expirou. Escaneie novamente para reconectar.",
"failed": "Não foi possível conectar o WeChat. Tente novamente.",
"verifyPlaceholder": "Código",
"verifySubmit": "Verificar"
}
}
+23 -2
View File
@@ -20,7 +20,21 @@
"token": {
"label": "Token",
"placeholder": "Được lưu khi đăng nhập QR"
}
},
"sendProgress": { "label": "Gửi tiến trình" },
"sendToolHints": { "label": "Gửi gợi ý công cụ" },
"streaming": { "label": "Sử dụng API phát trực tiếp" },
"replyProgressMessages": { "label": "Gửi tiến trình có cấu trúc" },
"replyProgressMaxMessages": { "label": "Giới hạn tiến trình có cấu trúc" },
"contextMessageBudget": { "label": "Ngân sách tin nhắn ngữ cảnh" },
"blockStreaming": { "label": "Gửi phản hồi theo khối" },
"blockStreamingMinChars": { "label": "Kích thước khối tối thiểu" },
"blockStreamingMaxMessages": { "label": "Giới hạn tin nhắn theo khối" },
"baseUrl": { "label": "URL API" },
"cdnBaseUrl": { "label": "URL CDN" },
"routeTag": { "label": "Thẻ định tuyến" },
"stateDir": { "label": "Thư mục trạng thái" },
"pollTimeout": { "label": "Thời gian chờ thăm dò" }
}
},
"custom": {
@@ -30,6 +44,13 @@
"waiting": "Đang chờ quét WeChat...",
"connected": "WeChat đã kết nối.",
"stopped": "Đăng nhập WeChat đã dừng.",
"connecting": "Đang kết nối..."
"connecting": "Đang kết nối...",
"verifyTitle": "Cần xác minh",
"verifyDescription": "Nhập số hiển thị trong WeChat để tiếp tục.",
"verifyMismatch": "Mã không khớp. Nhập số mới hiển thị trong WeChat.",
"expired": "Đăng nhập WeChat đã hết hạn. Hãy quét lại để kết nối lại.",
"failed": "Không thể kết nối WeChat. Hãy thử lại.",
"verifyPlaceholder": "Mã",
"verifySubmit": "Xác minh"
}
}
@@ -21,7 +21,21 @@
"token": {
"label": "令牌",
"placeholder": "二维码登录后自动保存"
}
},
"sendProgress": { "label": "发送进度消息" },
"sendToolHints": { "label": "发送工具提示" },
"streaming": { "label": "使用流式 API" },
"replyProgressMessages": { "label": "发送结构化进度" },
"replyProgressMaxMessages": { "label": "结构化进度消息上限" },
"contextMessageBudget": { "label": "上下文消息预算" },
"blockStreaming": { "label": "分块发送回复" },
"blockStreamingMinChars": { "label": "最小分块字符数" },
"blockStreamingMaxMessages": { "label": "分块消息上限" },
"baseUrl": { "label": "API 地址" },
"cdnBaseUrl": { "label": "CDN 地址" },
"routeTag": { "label": "路由标签" },
"stateDir": { "label": "状态目录" },
"pollTimeout": { "label": "轮询超时" }
}
},
"custom": {
@@ -31,6 +45,13 @@
"waiting": "正在等待微信扫码...",
"connected": "微信已连接。",
"stopped": "微信登录已停止。",
"connecting": "正在连接..."
"connecting": "正在连接...",
"verifyTitle": "需要验证",
"verifyDescription": "输入手机微信中显示的数字以继续。",
"verifyMismatch": "验证码不匹配,请输入微信中显示的新数字。",
"expired": "微信登录已过期,请重新扫码连接。",
"failed": "无法连接微信,请重试。",
"verifyPlaceholder": "验证码",
"verifySubmit": "验证"
}
}
@@ -21,7 +21,21 @@
"token": {
"label": "權杖",
"placeholder": "二維碼登入後自動儲存"
}
},
"sendProgress": { "label": "傳送進度訊息" },
"sendToolHints": { "label": "傳送工具提示" },
"streaming": { "label": "使用串流 API" },
"replyProgressMessages": { "label": "傳送結構化進度" },
"replyProgressMaxMessages": { "label": "結構化進度訊息上限" },
"contextMessageBudget": { "label": "上下文訊息預算" },
"blockStreaming": { "label": "分塊傳送回覆" },
"blockStreamingMinChars": { "label": "最小分塊字元數" },
"blockStreamingMaxMessages": { "label": "分塊訊息上限" },
"baseUrl": { "label": "API 位址" },
"cdnBaseUrl": { "label": "CDN 位址" },
"routeTag": { "label": "路由標籤" },
"stateDir": { "label": "狀態目錄" },
"pollTimeout": { "label": "輪詢逾時" }
}
},
"custom": {
@@ -31,6 +45,13 @@
"waiting": "正在等待微信掃碼...",
"connected": "微信已連接。",
"stopped": "微信登入已停止。",
"connecting": "正在連接..."
"connecting": "正在連接...",
"verifyTitle": "需要驗證",
"verifyDescription": "輸入手機微信中顯示的數字以繼續。",
"verifyMismatch": "驗證碼不符,請輸入微信中顯示的新數字。",
"expired": "微信登入已過期,請重新掃碼連線。",
"failed": "無法連接微信,請重試。",
"verifyPlaceholder": "驗證碼",
"verifySubmit": "驗證"
}
}
+2 -1
View File
@@ -32,6 +32,7 @@ from nanobot.cli.models import (
)
from nanobot.config.loader import get_config_path, load_config, resolve_config_env_vars
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
console = Console()
@@ -1674,7 +1675,7 @@ def _quick_start_oauth_login(config: Config, provider_name: str) -> bool:
login_oauth_interactive,
)
except ImportError:
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
console.print(f"[red]{OAUTH_CLI_KIT_MISSING_MESSAGE}[/red]")
return False
try:
+6 -5
View File
@@ -12,6 +12,7 @@ import typer
from rich.console import Console
from nanobot import __logo__
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
if TYPE_CHECKING:
from nanobot.providers.registry import ProviderSpec
@@ -74,7 +75,7 @@ def _required_module_attribute(module_name: str, attribute: str) -> object:
def _load_openai_oauth_client() -> tuple[_GetOAuthToken, _LoginOAuthInteractive]:
"""Load the optional untyped OAuth client behind a typed boundary."""
"""Load the untyped OAuth client behind a typed boundary."""
return (
cast(_GetOAuthToken, _required_module_attribute("oauth_cli_kit", "get_token")),
cast(
@@ -85,7 +86,7 @@ def _load_openai_oauth_client() -> tuple[_GetOAuthToken, _LoginOAuthInteractive]
def _load_openai_oauth_storage() -> tuple[_OAuthProviderConfig, _FileTokenStorageFactory]:
"""Load the optional untyped OAuth storage API behind a typed boundary."""
"""Load the untyped OAuth storage API behind a typed boundary."""
return (
cast(
_OAuthProviderConfig,
@@ -241,7 +242,7 @@ def _login_openai_codex() -> None:
f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]"
)
except ImportError:
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
console.print(f"[red]{OAUTH_CLI_KIT_MISSING_MESSAGE}[/red]")
raise typer.Exit(1)
@@ -250,7 +251,7 @@ def _logout_openai_codex() -> None:
try:
provider_config, storage_factory = _load_openai_oauth_storage()
except ImportError:
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
console.print(f"[red]{OAUTH_CLI_KIT_MISSING_MESSAGE}[/red]")
raise typer.Exit(1)
storage = storage_factory(token_filename=provider_config.token_filename)
@@ -309,7 +310,7 @@ def _logout_github_copilot() -> None:
try:
from nanobot.providers.github_copilot_provider import get_storage
except ImportError:
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
console.print(f"[red]{OAUTH_CLI_KIT_MISSING_MESSAGE}[/red]")
raise typer.Exit(1)
storage = get_storage()
+6
View File
@@ -0,0 +1,6 @@
"""Shared recovery guidance for OAuth dependency failures."""
OAUTH_CLI_KIT_MISSING_MESSAGE = (
"This nanobot installation is missing the required oauth-cli-kit package. "
"Reinstall or upgrade nanobot-ai using the same installation method."
)
+1 -1
View File
@@ -586,7 +586,7 @@ class OpenAICompatProvider(LLMProvider):
if os.environ.get("LANGFUSE_SECRET_KEY"):
logger.warning(
"LANGFUSE_SECRET_KEY is set but langfuse is not installed; "
"install with `pip install langfuse` to enable tracing"
"run `nanobot plugins enable langfuse` to enable tracing"
)
from openai import AsyncOpenAI as _AsyncOpenAI
AsyncOpenAI = _AsyncOpenAI
+33 -7
View File
@@ -36,6 +36,7 @@ from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
FILE_MAX_MESSAGES = 2000
SESSION_CACHE_MAX_SIZE = 128
MIN_REPLAY_MAX_MESSAGES = 120
MIN_COMPACTED_REPLAY_MESSAGES = 8
REPLAY_TOKENS_PER_MESSAGE = 100
_MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
@@ -191,19 +192,37 @@ class Session:
extend_to_user: bool = False,
include_runtime_context: bool = True,
) -> list[dict[str, Any]]:
"""Return unconsolidated messages for LLM input.
"""Return recent replayable messages for LLM input.
History is sliced by message count first (``max_messages``), then by
token budget from the tail (``max_tokens``) when provided.
"""
unconsolidated = self.messages[self.last_consolidated:]
replay_start = self.last_consolidated
if replay_start:
# ``last_consolidated`` is archive progress, not a replay boundary.
# Keep a small raw suffix for continuity, extending back to the user
# that started an assistant/tool sequence when necessary.
recent_start = recent_message_start_index(
self.messages,
MIN_COMPACTED_REPLAY_MESSAGES,
extend_to_user=True,
)
replay_start = min(replay_start, recent_start)
replayable = self.messages[replay_start:]
max_messages = max_messages if max_messages > 0 else FILE_MAX_MESSAGES
unarchived_count = len(self.messages) - self.last_consolidated
if replay_start < self.last_consolidated and unarchived_count < max_messages:
# The archived replay suffix can exceed the nominal count when one
# tool-heavy turn spans the boundary. Preserve that complete turn.
start_idx = 0
else:
start_idx = recent_message_start_index(
unconsolidated,
replayable,
max_messages,
extend_to_user=extend_to_user,
)
sliced = unconsolidated[start_idx:]
sliced = replayable[start_idx:]
# Avoid starting mid-turn when possible, except for proactive
# assistant deliveries that the user may be replying to.
@@ -352,16 +371,23 @@ class Session:
start_idx = max(0, len(self.messages) - max_messages)
if extend_to_user:
start_idx = next(
recovered_user = next(
(i for i in range(start_idx, -1, -1) if self.messages[i].get("role") == "user"),
start_idx,
None,
)
if recovered_user is not None:
start_idx = recovered_user
if start_idx > 0 and self.messages[start_idx - 1].get("_channel_delivery"):
start_idx -= 1
retained = self.messages[start_idx:]
# Prefer starting at a user turn when one exists within the retained window.
# Prefer starting at a user turn (or its preceding _channel_delivery) when one exists within the retained window.
first_user = next((i for i, m in enumerate(retained) if m.get("role") == "user"), None)
if first_user is not None:
if first_user > 0 and retained[first_user - 1].get("_channel_delivery"):
retained = retained[first_user - 1:]
else:
retained = retained[first_user:]
elif not extend_to_user:
# If the hard-capped tail is assistant/tool-only, anchor to the
+4 -18
View File
@@ -3,14 +3,13 @@
Persisted subagent announcements mirror ``agent/subagent_announce.md``: header,
full ``Task:`` assignment (model context), ``Result:``, and a trailing model-only
``Summarize`` instruction. External channels (embedded WebUI, session previews)
should show only the header plus a truncated result body."""
should show only the header plus a truncated result body.
"""
from __future__ import annotations
from typing import Any, cast
# Cap Result section length so WebSocket session replay stays readable; full text
# remains on disk for LLM replay (we only mutate outgoing API copies in websocket).
# Cap the Result section so session previews stay readable; full text remains on
# disk for LLM replay.
_SUBAGENT_CHANNEL_RESULT_MAX_CHARS = 800
@@ -44,16 +43,3 @@ def scrub_subagent_announce_body(content: str) -> str:
if header and body:
return f"{header}\n\n{body}"
return header or body or stripped
def scrub_subagent_messages_for_channel(messages: list[dict[str, Any]]) -> None:
"""Mutate message dicts in place when they carry ``subagent_result`` inject."""
for msg in messages:
if not isinstance(cast(object, msg), dict):
continue
if msg.get("injected_event") != "subagent_result":
continue
raw = msg.get("content")
if not isinstance(raw, str) or not raw.strip():
continue
msg["content"] = scrub_subagent_announce_body(raw)
+1 -33
View File
@@ -13,7 +13,7 @@ import shutil
import uuid
from collections.abc import Callable
from pathlib import Path
from typing import Any, cast
from typing import Any
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
@@ -32,7 +32,6 @@ from nanobot.webui.http_utils import (
MediaDirProvider = Callable[[str | None], Path]
SignedMediaPath = Callable[[Path], dict[str, str] | None]
SignedMediaUrl = Callable[[Path], str | None]
def b64url_encode(data: bytes) -> str:
@@ -190,37 +189,6 @@ def signed_media_attachments(
return out
def attach_signed_media_urls(
payload: dict[str, Any],
*,
sign_path: SignedMediaUrl,
) -> None:
"""Replace raw media path lists in a WebUI session payload with signed URLs."""
messages = payload.get("messages")
if not isinstance(messages, list):
return
raw_messages = cast(list[Any], messages)
for msg in raw_messages:
if not isinstance(msg, dict):
continue
message = cast(dict[str, Any], msg)
media = message.get("media")
if not isinstance(media, list) or not media:
continue
media_entries = cast(list[Any], media)
urls: list[dict[str, str]] = []
for entry in media_entries:
if not isinstance(entry, str) or not entry:
continue
signed = sign_path(Path(entry))
if signed is None:
continue
urls.append({"url": signed, "name": Path(entry).name})
if urls:
message["media_urls"] = urls
message.pop("media", None)
def serve_signed_media(
sig: str,
payload: str,
-4
View File
@@ -17,7 +17,6 @@ from nanobot.webui.attachment_ingress import (
)
from nanobot.webui.ingress_policy import AttachmentIngressLimits
from nanobot.webui.media_api import (
attach_signed_media_urls,
serve_signed_media,
sign_media_path,
sign_or_stage_media_path,
@@ -99,9 +98,6 @@ class WebUIMediaGateway:
sign_path=self.sign_or_stage_media_path,
)
def augment_media_urls(self, payload: dict[str, Any]) -> None:
attach_signed_media_urls(payload, sign_path=self.sign_media_path)
def augment_transcript_media(self, paths: list[str]) -> list[dict[str, Any]]:
return signed_media_attachments(
paths,
+6 -13
View File
@@ -4,7 +4,7 @@ The WebSocket channel owns transport/authentication. This module owns the
settings payload shape and the allowlisted config mutations exposed to WebUI.
"""
# oauth-cli-kit is an optional dependency and does not publish type stubs.
# oauth-cli-kit does not publish type stubs.
# pyright: reportMissingTypeStubs=false
from __future__ import annotations
@@ -36,6 +36,7 @@ from nanobot.providers.image_generation import (
get_image_gen_provider,
image_gen_provider_names,
)
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name
from nanobot.security.network import is_loopback_host
from nanobot.security.workspace_access import workspace_sandbox_status
@@ -1794,9 +1795,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
try:
from nanobot.providers.openai_codex_oauth import start_openai_codex_oauth_login
except ImportError:
raise WebUISettingsError(
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
) from None
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
try:
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
@@ -1834,9 +1833,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
login_github_copilot,
)
except ImportError:
raise WebUISettingsError(
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
) from None
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
token = get_github_copilot_login_status()
if not token:
@@ -1934,18 +1931,14 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
from oauth_cli_kit.storage import FileTokenStorage
except ImportError:
raise WebUISettingsError(
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
) from None
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
_clear_webui_oauth_flows(spec.name)
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
elif spec.name == "github_copilot":
try:
from nanobot.providers.github_copilot_provider import get_storage
except ImportError:
raise WebUISettingsError(
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
) from None
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
token_path = get_storage().get_token_path()
elif spec.name == "xai_grok":
from nanobot.providers.xai_oauth import logout_xai_oauth
+3 -1
View File
@@ -25,7 +25,7 @@ _MAX_KEY_LEN = 512
_MAX_TITLE_LEN = 160
_MAX_TAG_LEN = 40
_ALLOWED_DENSITIES = {"comfortable", "compact"}
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc"}
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"}
def webui_sidebar_state_path() -> Path:
@@ -37,6 +37,7 @@ def default_webui_sidebar_state() -> dict[str, Any]:
"schema_version": WEBUI_SIDEBAR_STATE_SCHEMA_VERSION,
"pinned_keys": [],
"archived_keys": [],
"session_order": [],
"title_overrides": {},
"project_name_overrides": {},
"tags_by_key": {},
@@ -138,6 +139,7 @@ def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
state = default_webui_sidebar_state()
state["pinned_keys"] = _clean_string_list(raw.get("pinned_keys"))
state["archived_keys"] = _clean_string_list(raw.get("archived_keys"))
state["session_order"] = _clean_string_list(raw.get("session_order"))
state["title_overrides"] = _clean_title_overrides(raw.get("title_overrides"))
state["project_name_overrides"] = _clean_title_overrides(
raw.get("project_name_overrides")
-34
View File
@@ -26,10 +26,8 @@ from websockets.http11 import Response
from nanobot.command.builtin import builtin_command_palette
from nanobot.cron.session_turns import is_bound_cron_job
from nanobot.cron.types import CronJob, CronSchedule
from nanobot.runtime_context import public_history_messages
from nanobot.security.workspace_access import WorkspaceScope
from nanobot.triggers.local_types import LocalTrigger
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.file_preview import (
WebUIFilePreviewError,
file_preview_availability_payload,
@@ -462,10 +460,6 @@ class GatewayHTTPHandler:
# -- Session routes -----------------------------------------------------
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
if m:
return self._handle_session_messages(request, m.group(1))
m = re.match(r"^/api/sessions/([^/]+)/webui-thread$", got)
if m:
return self._handle_webui_thread_get(request, m.group(1))
@@ -527,34 +521,6 @@ class GatewayHTTPHandler:
cleaned.append(row)
return {"sessions": cleaned}
def _handle_session_messages(self, request: WsRequest, key: str) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
if self.session_manager is None:
return _http_error(503, "session manager unavailable")
decoded_key = _decode_api_key(key)
if decoded_key is None:
return _http_error(400, "invalid session key")
if not _is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
data = self.session_manager.read_session_file(decoded_key)
if data is None:
return _http_error(404, "session not found")
messages = data.get("messages")
if isinstance(messages, list):
session_messages = cast(list[dict[str, Any]], messages)
scrub_subagent_messages_for_channel(session_messages)
raw_session_messages = cast(list[Any], messages)
data["messages"] = public_history_messages(
[
cast(dict[str, Any], message)
for message in raw_session_messages
if isinstance(message, dict)
]
)
self.media.augment_media_urls(data)
return _http_json_response(data)
def _handle_webui_thread_get(self, request: WsRequest, key: str) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
+172
View File
@@ -0,0 +1,172 @@
import json
import shutil
from pathlib import Path
import pytest
from nanobot.agent.agent_plugins import AGENT_PLUGIN_SCHEMA, discover_agent_plugin_skills
from nanobot.agent.skills import SkillsLoader
def _write_skill(root: Path, name: str, *, description: str = "Plugin skill.") -> Path:
skill = root / "skills" / name
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n",
encoding="utf-8",
)
return skill
def _write_plugin(
workspace: Path,
directory: str,
*,
name: str | None = None,
manifest: dict[str, object] | None = None,
) -> Path:
root = workspace / "plugins" / directory
root.mkdir(parents=True)
payload = manifest or {
"$schema": AGENT_PLUGIN_SCHEMA,
"name": name or directory,
}
(root / "plugin.json").write_text(json.dumps(payload), encoding="utf-8")
return root
def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "acme-tools")
_write_skill(plugin, "release-notes", description="Draft release notes from changes.")
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
assert loader.list_skills() == [
{
"name": "release-notes",
"path": str(plugin / "skills" / "release-notes" / "SKILL.md"),
"source": "plugin",
"plugin": "acme-tools",
}
]
assert loader.get_explicitly_invoked_skills("Use $release-notes") == ["release-notes"]
assert "Draft release notes" in (loader.load_skill("release-notes") or "")
assert "### Agent Plugin skills" in loader.build_skills_summary()
assert "`acme-tools/skills/release-notes/SKILL.md`" in loader.build_skills_summary()
def test_skills_loader_sees_plugin_installed_after_startup(tmp_path: Path) -> None:
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
assert loader.list_skills() == []
plugin = _write_plugin(tmp_path, "acme-tools")
_write_skill(plugin, "release-notes")
assert [entry["name"] for entry in loader.list_skills()] == ["release-notes"]
shutil.rmtree(plugin)
assert loader.list_skills() == []
assert loader.build_skills_summary() == ""
def test_agent_plugin_skills_are_direct_children_only(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "acme-tools")
_write_skill(plugin, "direct")
nested = plugin / "skills" / "group" / "nested"
nested.mkdir(parents=True)
(nested / "SKILL.md").write_text(
"---\nname: nested\ndescription: Nested skill.\n---\n",
encoding="utf-8",
)
assert [skill.name for skill in discover_agent_plugin_skills(tmp_path)] == ["direct"]
@pytest.mark.parametrize(
"manifest",
[
{"$schema": "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json", "name": "demo"},
{"$schema": AGENT_PLUGIN_SCHEMA, "name": "Bad-Name"},
{"$schema": AGENT_PLUGIN_SCHEMA, "name": "demo", "author": None},
{"$schema": AGENT_PLUGIN_SCHEMA, "name": "demo", "keywords": None},
],
)
def test_invalid_agent_plugin_manifest_is_skipped(
tmp_path: Path,
manifest: dict[str, object],
) -> None:
plugin = _write_plugin(tmp_path, "demo", manifest=manifest)
_write_skill(plugin, "example")
assert discover_agent_plugin_skills(tmp_path) == []
def test_unknown_manifest_fields_and_non_object_extensions_are_ignored(tmp_path: Path) -> None:
plugin = _write_plugin(
tmp_path,
"demo",
manifest={
"$schema": AGENT_PLUGIN_SCHEMA,
"name": "demo",
"futureField": True,
"extensions": "invalid but non-fatal",
},
)
_write_skill(plugin, "example")
assert [skill.name for skill in discover_agent_plugin_skills(tmp_path)] == ["example"]
@pytest.mark.parametrize(
("skill_name", "frontmatter"),
[
("wrong-directory", "name: another\ndescription: Mismatch."),
("missing-description", "name: missing-description"),
("Bad-Name", "name: Bad-Name\ndescription: Invalid name."),
],
)
def test_invalid_agent_skill_is_skipped(
tmp_path: Path,
skill_name: str,
frontmatter: str,
) -> None:
plugin = _write_plugin(tmp_path, "demo")
skill = plugin / "skills" / skill_name
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text(f"---\n{frontmatter}\n---\n", encoding="utf-8")
assert discover_agent_plugin_skills(tmp_path) == []
def test_workspace_skill_overrides_plugin_skill(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "demo")
_write_skill(plugin, "shared", description="Plugin version.")
workspace_skill = tmp_path / "skills" / "shared"
workspace_skill.mkdir(parents=True)
(workspace_skill / "SKILL.md").write_text(
"---\nname: shared\ndescription: Workspace version.\n---\n",
encoding="utf-8",
)
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
assert [entry["source"] for entry in loader.list_skills()] == ["workspace"]
assert "Workspace version" in (loader.load_skill("shared") or "")
def test_plugin_skill_symlink_cannot_escape_plugin_root(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "demo")
outside = tmp_path / "outside"
_write_skill(outside, "escaped")
skills_root = plugin / "skills"
skills_root.mkdir()
try:
(skills_root / "escaped").symlink_to(
outside / "skills" / "escaped",
target_is_directory=True,
)
except OSError as exc:
pytest.skip(f"directory symlink unavailable: {exc}")
assert discover_agent_plugin_skills(tmp_path) == []
+9 -28
View File
@@ -80,8 +80,6 @@ def _make_fake_compact(
track_archived: list | None = None,
track_count: bool = False,
):
from nanobot.session.manager import Session as _Session
state = {"count": 0}
async def _fake_compact(key: str, *, runtime, max_suffix: int = 8) -> str:
@@ -92,25 +90,8 @@ def _make_fake_compact(
if not tail:
loop.sessions.save(session)
return ""
probe = _Session(
key=session.key,
messages=tail.copy(),
created_at=session.created_at,
updated_at=session.updated_at,
metadata={},
last_consolidated=0,
)
result = probe.retain_recent_legal_suffix(
max_suffix,
extend_to_user=True,
)
visible_suffix = probe.messages
archive_msgs = result.dropped
if not archive_msgs:
loop.sessions.save(session)
return ""
archive_end = session.last_consolidated + len(tail)
archive_msgs = tail
last_active = session.updated_at
s = summary
@@ -126,7 +107,7 @@ def _make_fake_compact(
"last_active": last_active.isoformat(),
}
session.last_consolidated = len(session.messages) - len(visible_suffix)
session.last_consolidated = archive_end
loop.sessions.save(session)
return s
@@ -365,7 +346,7 @@ class TestAutoCompact:
await loop.close_mcp()
@pytest.mark.asyncio
async def test_auto_compact_archives_prefix_without_deleting_history(self, tmp_path):
async def test_auto_compact_archives_full_tail_without_deleting_history(self, tmp_path):
loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6)
@@ -378,7 +359,7 @@ class TestAutoCompact:
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
assert len(archived_messages) == 4
assert len(archived_messages) == 12
session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 12
assert session_after.messages[0]["content"] == "msg user 0"
@@ -473,7 +454,7 @@ class TestAutoCompact:
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
assert len(archived_messages) == 2
assert len(archived_messages) == 10
await loop.close_mcp()
@@ -515,7 +496,7 @@ class TestAutoCompactIdleDetection:
await loop._process_message(msg)
session_after = loop.sessions.get_or_create("cli:test")
assert len(archived_messages) == 4
assert len(archived_messages) == 12
assert any(m["content"] == "old user 0" for m in session_after.messages)
assert not any(
m["content"] == "old user 0"
@@ -724,7 +705,7 @@ class TestAutoCompactEdgeCases:
await loop._process_message(msg)
session_after = loop.sessions.get_or_create("cli:test")
assert archived_messages == []
assert [message["content"] for message in archived_messages] == ["previous message"]
assert any(m["content"] == "previous message" for m in session_after.messages)
assert any(m["content"] == "interrupted response" for m in session_after.messages)
@@ -912,7 +893,7 @@ class TestProactiveAutoCompact:
assert len(session_after.get_history(max_messages=10)) == (
loop.auto_compact._RECENT_SUFFIX_MESSAGES
)
assert len(archived_messages) == 2
assert len(archived_messages) == 10
entry = loop.auto_compact._summaries.get("cli:test")
assert entry is not None
assert entry[0] == "User chatted about old things."
+26 -2
View File
@@ -405,13 +405,37 @@ class TestCheckExpired:
scheduler.assert_not_called()
assert "dream:20260602-155256" not in ac._archiving
def test_already_trimmed_session_skips(self):
"""Expired session with no removable tail should not be re-scheduled."""
def test_short_unarchived_session_schedules(self):
"""A short idle session still needs an archive entry for Dream."""
ac = _make_autocompact(ttl=15)
mock_sm = MagicMock(spec=SessionManager)
last_active = datetime(2026, 1, 1, 10, 0, 0)
session = _make_session("cli:short", updated_at=last_active)
_add_turns(session, 2)
mock_sm.list_sessions.return_value = [
{"key": "cli:short", "updated_at": last_active.isoformat()},
]
mock_sm.get_or_create.return_value = session
ac.sessions = mock_sm
scheduled = []
def scheduler(coro):
scheduled.append(coro)
coro.close()
ac.check_expired(scheduler, _runtime)
assert len(scheduled) == 1
assert ac._archiving == {"cli:short"}
def test_fully_archived_session_skips(self):
ac = _make_autocompact(ttl=15)
mock_sm = MagicMock(spec=SessionManager)
last_active = datetime(2026, 1, 1, 10, 0, 0)
session = _make_session("cli:done", updated_at=last_active)
_add_turns(session, 2)
session.last_consolidated = len(session.messages)
mock_sm.list_sessions.return_value = [
{"key": "cli:done", "updated_at": last_active.isoformat()},
]
+110 -11
View File
@@ -391,6 +391,25 @@ class TestConsolidatorTokenBudget:
assert len(captured["history"]) == 160
assert captured["history"][0]["content"].endswith("msg-0")
async def test_estimate_includes_recent_archived_replay(self, consolidator, runtime):
session = Session(key="test:archived-replay")
for i in range(10):
session.add_message("user", f"msg-{i}")
session.last_consolidated = len(session.messages)
captured: dict[str, list[dict]] = {}
def build_messages(**kwargs):
captured["history"] = kwargs["history"]
return kwargs["history"]
consolidator._build_messages = build_messages
consolidator.estimate_session_prompt_tokens(session, runtime=runtime)
assert len(captured["history"]) == 8
assert captured["history"][0]["content"] == "msg-2"
async def test_replay_window_overflow_is_archived_even_under_token_budget(
self,
consolidator,
@@ -620,7 +639,7 @@ class TestCompactIdleSession:
)
@pytest.mark.asyncio
async def test_archives_prefix_preserves_messages_and_hides_prefix(
async def test_archives_full_tail_preserves_messages_and_replays_recent_suffix(
self, real_consolidator, mock_provider, runtime
):
mock_provider.chat_with_retry.return_value = MagicMock(
@@ -645,7 +664,7 @@ class TestCompactIdleSession:
reloaded = sessions.get_or_create("cli:test")
assert len(reloaded.messages) == 40
assert reloaded.messages[0]["content"] == "user msg 0"
assert reloaded.last_consolidated == 32
assert reloaded.last_consolidated == 40
assert reloaded.provider_state is None
visible = reloaded.get_history(max_messages=40)
assert len(visible) == 8
@@ -657,6 +676,82 @@ class TestCompactIdleSession:
assert "last_active" in meta
assert reloaded.updated_at == old_ts
@pytest.mark.asyncio
async def test_short_idle_session_archives_once(
self, real_consolidator, mock_provider, store, runtime
):
mock_provider.chat_with_retry.return_value = MagicMock(
content="Short summary.", finish_reason="stop"
)
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:short")
session.add_message("user", "hello")
session.add_message("assistant", "hi")
sessions.save(session)
first = await real_consolidator.compact_idle_session("cli:short", runtime=runtime)
second = await real_consolidator.compact_idle_session("cli:short", runtime=runtime)
assert first == "Short summary."
assert second == ""
mock_provider.chat_with_retry.assert_awaited_once()
assert len(store.read_unprocessed_history(since_cursor=0)) == 1
reloaded = sessions.get_or_create("cli:short")
assert reloaded.last_consolidated == 2
assert [message["content"] for message in reloaded.get_history()] == ["hello", "hi"]
@pytest.mark.asyncio
async def test_new_messages_advance_existing_archive_progress(
self, real_consolidator, mock_provider, runtime
):
mock_provider.chat_with_retry.return_value = MagicMock(
content="Summary.", finish_reason="stop"
)
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:incremental")
session.add_message("user", "first user")
session.add_message("assistant", "first assistant")
sessions.save(session)
await real_consolidator.compact_idle_session("cli:incremental", runtime=runtime)
current = sessions.get_or_create("cli:incremental")
current.add_message("user", "second user")
current.add_message("assistant", "second assistant")
sessions.save(current)
await real_consolidator.compact_idle_session("cli:incremental", runtime=runtime)
assert mock_provider.chat_with_retry.await_count == 2
latest_prompt = mock_provider.chat_with_retry.await_args_list[-1].kwargs["messages"][1][
"content"
]
assert "second user" in latest_prompt
assert "first user" not in latest_prompt
assert sessions.get_or_create("cli:incremental").last_consolidated == 4
@pytest.mark.asyncio
async def test_concurrent_append_remains_unarchived(
self, real_consolidator, mock_provider, runtime
):
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:concurrent")
session.add_message("user", "captured user")
session.add_message("assistant", "captured assistant")
sessions.save(session)
async def append_during_archive(**_kwargs):
current = sessions.get_or_create("cli:concurrent")
current.add_message("user", "late user")
current.add_message("assistant", "late assistant")
return LLMResponse(content="Summary.", finish_reason="stop")
mock_provider.chat_with_retry.side_effect = append_during_archive
await real_consolidator.compact_idle_session("cli:concurrent", runtime=runtime)
reloaded = sessions.get_or_create("cli:concurrent")
assert len(reloaded.messages) == 4
assert reloaded.last_consolidated == 2
@pytest.mark.asyncio
async def test_summarizes_retained_suffix_not_just_dropped_prefix(
self, real_consolidator, mock_provider, runtime
@@ -686,10 +781,10 @@ class TestCompactIdleSession:
assert "CORRECTED_FINAL_RESULT_alpha" in summarized
@pytest.mark.asyncio
async def test_raw_dumps_only_dropped_messages_on_llm_failure(
async def test_raw_dumps_full_archive_batch_on_llm_failure(
self, real_consolidator, mock_provider, store, runtime
):
"""Extra summary context must not enter raw fallback. Regression for #4264."""
"""The fallback covers the same full range as successful idle archival."""
mock_provider.chat_with_retry.side_effect = RuntimeError("LLM unavailable")
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:rawdrop")
@@ -707,7 +802,7 @@ class TestCompactIdleSession:
raw = "\n".join(e["content"] for e in store.read_unprocessed_history(since_cursor=0))
assert "[RAW]" in raw
assert "user msg 0" in raw
assert "RETAINED_SUFFIX_marker" not in raw
assert "RETAINED_SUFFIX_marker" in raw
reloaded = sessions.get_or_create("cli:rawdrop")
assert len(reloaded.messages) == 38
assert reloaded.messages[-1]["content"] == "RETAINED_SUFFIX_marker"
@@ -805,8 +900,12 @@ class TestCompactIdleSession:
reloaded = sessions.get_or_create("cli:fail")
assert len(reloaded.messages) == 20
assert reloaded.messages[0]["content"] == "u0"
assert reloaded.last_consolidated == 16
assert reloaded.last_consolidated == 20
assert [m["content"] for m in reloaded.get_history(max_messages=20)] == [
"u6",
"a6",
"u7",
"a7",
"u8",
"a8",
"u9",
@@ -835,10 +934,10 @@ class TestCompactIdleSession:
assert result == "Tail summary."
reloaded = sessions.get_or_create("cli:offset")
assert len(reloaded.messages) == 60
assert reloaded.last_consolidated == 56
assert reloaded.last_consolidated == 60
# Verify only the unconsolidated tail was processed:
# 10 unconsolidated messages (50-59), keep suffix of 4 → archive 6
# All 10 unconsolidated messages (50-59) are archived exactly once.
archived_call = mock_provider.chat_with_retry.call_args
user_content = archived_call.kwargs["messages"][1]["content"]
# Should contain only tail messages, not early ones
@@ -846,7 +945,7 @@ class TestCompactIdleSession:
assert "u25" in user_content or "a25" in user_content
@pytest.mark.asyncio
async def test_extended_suffix_archives_only_hidden_prefix(
async def test_full_archive_keeps_extended_legal_replay_suffix(
self,
real_consolidator,
mock_provider,
@@ -870,7 +969,7 @@ class TestCompactIdleSession:
reloaded = sessions.get_or_create("cli:noncontiguous")
assert len(reloaded.messages) == 25
assert reloaded.last_consolidated == 14
assert reloaded.last_consolidated == 25
assert [m["content"] for m in reloaded.get_history(max_messages=25)] == [
"user-14",
"assistant-00",
@@ -1034,7 +1133,7 @@ class TestConsolidatorSessionRefresh:
session_after = sessions.get_or_create("cli:test")
assert len(session_after.messages) == 40
assert session_after.last_consolidated == 32
assert session_after.last_consolidated == 40
assert len(session_after.get_history(max_messages=40)) == 8
+2 -3
View File
@@ -1074,9 +1074,8 @@ async def test_process_message_persists_media_paths_on_user_turn(tmp_path: Path)
"""User turns that attach images must record the media paths alongside
the text so the webui can rehydrate previews on session replay.
This is the producer half of the signed-media-URL round-trip: paths are
stored here, then :meth:`WebSocketChannel._augment_media_urls` maps them
onto signed URLs on the way out.
The WebUI transcript replay can use these paths to restore attachment
previews when it backfills from canonical session history.
"""
img_a = tmp_path / "uuid-1.png"
img_a.write_bytes(_PNG_1X1)
+17
View File
@@ -1092,6 +1092,23 @@ class TestMainMenuUpdate:
assert config.providers.openai.api_key == "${UNRELATED_MISSING_KEY}"
assert config.providers.openai_codex.proxy == "${CODEX_PROXY}"
def test_quick_start_openai_codex_reports_incomplete_installation(self, monkeypatch):
import oauth_cli_kit
messages: list[str] = []
monkeypatch.delattr(oauth_cli_kit, "get_token")
monkeypatch.setattr(
onboard_wizard.console,
"print",
lambda message, *args, **kwargs: messages.append(str(message)),
)
assert onboard_wizard._quick_start_oauth_login(Config(), "openai_codex") is False
assert messages == [
"[red]This nanobot installation is missing the required oauth-cli-kit package. "
"Reinstall or upgrade nanobot-ai using the same installation method.[/red]"
]
def test_quick_start_openai_codex_runs_interactive_login_for_bad_cached_token(
self, monkeypatch
):
@@ -208,6 +208,71 @@ def test_orphan_trim_with_last_consolidated():
assert all(m.get("role") != "tool" or m["tool_call_id"].startswith("new_") for m in history)
def test_get_history_replays_recent_messages_after_full_archive():
session = Session(key="test:fully-archived")
for i in range(10):
session.messages.append({"role": "user", "content": f"u{i}"})
session.messages.append({"role": "assistant", "content": f"a{i}"})
session.last_consolidated = len(session.messages)
history = session.get_history(max_messages=100)
assert [message["content"] for message in history] == [
"u6",
"a6",
"u7",
"a7",
"u8",
"a8",
"u9",
"a9",
]
def test_get_history_extends_compacted_replay_to_preceding_user():
session = Session(key="test:compacted-tool-turn")
session.messages.extend(
[
{"role": "user", "content": "old"},
{"role": "assistant", "content": "old answer"},
{"role": "user", "content": "run tools"},
*_tool_turn("keep", 0),
*_tool_turn("keep", 1),
*_tool_turn("keep", 2),
{"role": "assistant", "content": "done"},
]
)
session.last_consolidated = len(session.messages)
history = session.get_history(max_messages=100)
assert history[0]["content"] == "run tools"
assert history[-1]["content"] == "done"
_assert_no_orphans(history)
def test_compacted_tool_turn_can_extend_past_message_cap():
session = Session(key="test:long-compacted-tool-turn")
session.messages.extend(
[
{"role": "user", "content": "old"},
{"role": "assistant", "content": "old answer"},
{"role": "user", "content": "run many tools"},
]
)
for i in range(50):
session.messages.extend(_tool_turn("keep", i))
session.messages.append({"role": "assistant", "content": "done"})
session.last_consolidated = len(session.messages)
history = session.get_history(max_messages=120)
assert len(history) > 120
assert history[0]["content"] == "run many tools"
assert history[-1]["content"] == "done"
_assert_no_orphans(history)
# --- Edge: no tool messages at all ---
def test_no_tool_messages_unchanged():
+259
View File
@@ -0,0 +1,259 @@
from nanobot.session.manager import Session
def _assert_no_orphans(history: list[dict]) -> None:
declared = {
tc["id"]
for m in history
if m.get("role") == "assistant"
for tc in (m.get("tool_calls") or [])
}
orphans = [
m.get("tool_call_id")
for m in history
if m.get("role") == "tool" and m.get("tool_call_id") not in declared
]
assert orphans == [], f"orphan tool_call_ids: {orphans}"
def _delivery(content: str) -> dict:
return {"role": "assistant", "content": content, "_channel_delivery": True}
def _tool_turn(prefix: str, idx: int) -> list[dict]:
return [
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": f"{prefix}_{idx}_a",
"type": "function",
"function": {"name": "x", "arguments": "{}"},
},
{
"id": f"{prefix}_{idx}_b",
"type": "function",
"function": {"name": "y", "arguments": "{}"},
},
],
},
{"role": "tool", "tool_call_id": f"{prefix}_{idx}_a", "name": "x", "content": "ok"},
{"role": "tool", "tool_call_id": f"{prefix}_{idx}_b", "name": "y", "content": "ok"},
]
def _contents(messages: list[dict]) -> list[str]:
return [m.get("content") for m in messages]
def _has_delivery(messages: list[dict]) -> bool:
return any(m.get("_channel_delivery") for m in messages)
# --- Hard-cap trimming must preserve a proactive delivery the user replied to ---
def test_retain_hard_cap_keeps_delivery_before_user():
session = Session(key="test:cap-delivery")
session.messages.append({"role": "user", "content": "setup"})
session.messages.append(_delivery("Remember to drink water"))
session.messages.append({"role": "user", "content": "ok"})
session.messages.append({"role": "assistant", "content": "great"})
session.retain_recent_legal_suffix(3)
assert _has_delivery(session.messages), "delivery dropped by hard-cap trim"
assert _contents(session.messages) == [
"Remember to drink water",
"ok",
"great",
]
def test_retain_hard_cap_matches_get_history_boundary():
"""The trimmed suffix must start on the same message as get_history()."""
session = Session(key="test:cap-boundary")
session.messages.append({"role": "user", "content": "setup"})
session.messages.append(_delivery("You have 3 pending tasks"))
session.messages.append({"role": "user", "content": "show them"})
session.messages.append({"role": "assistant", "content": "done"})
expected = session.get_history(max_messages=3)
session.retain_recent_legal_suffix(3)
assert _contents(session.messages) == _contents(expected)
def test_retain_extend_to_user_keeps_delivery_before_recovered_user():
session = Session(key="test:extend-delivery")
session.messages.append({"role": "user", "content": "setup"})
session.messages.append({"role": "assistant", "content": "work"})
session.messages.append(_delivery("Reminder: deploy at 17:00"))
session.messages.append({"role": "user", "content": "ok"})
session.messages.append({"role": "assistant", "content": "a1"})
session.messages.append({"role": "assistant", "content": "a2"})
session.messages.append({"role": "assistant", "content": "a3"})
session.retain_recent_legal_suffix(3, extend_to_user=True)
assert _has_delivery(session.messages), "delivery dropped by extend_to_user trim"
assert session.messages[0]["content"] == "Reminder: deploy at 17:00"
assert session.messages[-1]["content"] == "a3"
def test_retain_extend_to_user_matches_get_history_boundary():
session = Session(key="test:extend-boundary")
session.messages.append({"role": "user", "content": "setup"})
session.messages.append({"role": "assistant", "content": "work"})
session.messages.append(_delivery("Reminder: review the draft"))
session.messages.append({"role": "user", "content": "ok"})
session.messages.append({"role": "assistant", "content": "a1"})
session.messages.append({"role": "assistant", "content": "a2"})
session.messages.append({"role": "assistant", "content": "a3"})
expected = session.get_history(max_messages=3, extend_to_user=True)
session.retain_recent_legal_suffix(3, extend_to_user=True)
assert _contents(session.messages) == _contents(expected)
def test_retain_extend_to_user_does_not_extend_delivery_only_tail():
session = Session(key="test:extend-no-user")
for i in range(4):
session.messages.append(_delivery(f"notification {i}"))
session.retain_recent_legal_suffix(3, extend_to_user=True)
assert _contents(session.messages) == [
"notification 1",
"notification 2",
"notification 3",
]
# --- Only the immediately-preceding delivery is part of the anchor ---
def test_retain_keeps_only_immediate_delivery():
session = Session(key="test:multi-delivery")
session.messages.append({"role": "user", "content": "setup"})
session.messages.append(_delivery("old scheduled note"))
session.messages.append(_delivery("new scheduled note"))
session.messages.append({"role": "user", "content": "ok"})
session.messages.append({"role": "assistant", "content": "great"})
session.retain_recent_legal_suffix(3)
kept = _contents(session.messages)
assert kept == ["new scheduled note", "ok", "great"], kept
def test_retain_drops_delivery_not_adjacent_to_anchor_user():
"""A delivery that does not immediately precede the retained user turn is
not part of the anchor and should not be force-retained."""
session = Session(key="test:nonadjacent")
session.messages.append({"role": "user", "content": "setup"})
session.messages.append(_delivery("unrelated scheduled note"))
session.messages.append({"role": "assistant", "content": "reply"})
session.messages.append({"role": "user", "content": "ok"})
session.messages.append({"role": "assistant", "content": "great"})
session.retain_recent_legal_suffix(2)
assert not _has_delivery(session.messages)
assert _contents(session.messages) == ["ok", "great"]
# --- Delivery preservation through the production entry points ---
def test_enforce_file_cap_keeps_delivery_in_session():
session = Session(key="test:cap-delivery")
session.messages.append({"role": "user", "content": "setup"})
session.messages.append(_delivery("Remember to drink water"))
session.messages.append({"role": "user", "content": "ok"})
session.messages.append({"role": "assistant", "content": "great"})
archived: list[list[dict]] = []
session.enforce_file_cap(on_archive=archived.append, limit=3)
archived_flat = [m for chunk in archived for m in chunk]
assert _has_delivery(session.messages)
assert not any(m.get("_channel_delivery") for m in archived_flat)
def test_enforce_file_cap_archives_only_prefix():
session = Session(key="test:cap-prefix")
session.messages.append({"role": "user", "content": "setup"})
session.messages.append({"role": "assistant", "content": "first reply"})
session.messages.append(_delivery("Remember to drink water"))
session.messages.append({"role": "user", "content": "ok"})
session.messages.append({"role": "assistant", "content": "great"})
archived: list[list[dict]] = []
session.enforce_file_cap(on_archive=archived.append, limit=3)
archived_flat = [m for chunk in archived for m in chunk]
assert _has_delivery(session.messages)
assert _contents(archived_flat) == ["setup", "first reply"]
def test_compact_probe_keeps_delivery_in_visible_suffix():
"""compact_idle_session() trims a probe copy with extend_to_user=True; the
visible suffix it keeps must still contain the delivery message."""
tail = [
{"role": "user", "content": "setup"},
{"role": "assistant", "content": "work"},
_delivery("Reminder: deploy at 17:00"),
{"role": "user", "content": "ok"},
{"role": "assistant", "content": "a1"},
{"role": "assistant", "content": "a2"},
{"role": "assistant", "content": "a3"},
]
probe = Session(key="test:probe", messages=tail, last_consolidated=0)
probe.retain_recent_legal_suffix(3, extend_to_user=True)
assert _has_delivery(probe.messages)
assert probe.messages[0]["content"] == "Reminder: deploy at 17:00"
# --- Trimming must stay coherent with the rest of replay ---
def test_retain_then_replay_keeps_delivery_and_no_orphans():
session = Session(key="test:replay-after-trim")
session.messages.append({"role": "user", "content": "setup"})
session.messages.append(_delivery("You have 3 pending tasks"))
session.messages.append({"role": "user", "content": "show them"})
session.messages.extend(_tool_turn("cur", 0))
session.messages.append({"role": "assistant", "content": "done"})
session.retain_recent_legal_suffix(6)
assert _has_delivery(session.messages)
history = session.get_history(max_messages=500)
_assert_no_orphans(history)
assert any(m.get("content") == "You have 3 pending tasks" for m in history)
def test_retain_keeps_delivery_when_user_inside_window():
"""When the capped window already contains a user, its immediately
preceding delivery must stay attached to it."""
session = Session(key="test:window-user")
session.messages.append({"role": "user", "content": "setup"})
session.messages.append({"role": "assistant", "content": "a0"})
session.messages.append(_delivery("Reminder"))
session.messages.append({"role": "user", "content": "ok"})
session.messages.append({"role": "assistant", "content": "a1"})
session.messages.append({"role": "assistant", "content": "a2"})
expected = session.get_history(max_messages=4)
session.retain_recent_legal_suffix(4)
assert _has_delivery(session.messages)
assert _contents(session.messages) == _contents(expected)
@@ -17,6 +17,7 @@ from nanobot.bus.outbound_events import (
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager
from nanobot.channels.mattermost.runtime import MattermostChannel
from nanobot.config.schema import Config
@@ -311,6 +312,38 @@ class TestProgressFiltering:
assert manager._should_send_progress("mock", tool_hint=False) is False
assert manager._should_send_progress("mock", tool_hint=True) is False
def test_channel_config_defaults_do_not_override_global_policy(self, bus):
manager = ChannelManager.__new__(ChannelManager)
manager.config = Config.model_validate({
"channels": {
"sendProgress": False,
"sendToolHints": False,
},
})
manager.bus = bus
channel = manager._build_channel(
"mattermost",
MattermostChannel,
{"enabled": True},
)
assert channel.send_progress is False
assert channel.send_tool_hints is False
opted_in = manager._build_channel(
"mattermost",
MattermostChannel,
{
"enabled": True,
"sendProgress": True,
"sendToolHints": True,
},
)
assert opted_in.send_progress is True
assert opted_in.send_tool_hints is True
def test_progress_visibility_returns_false_for_missing_channel(self, manager):
assert manager._should_send_progress("nonexistent", tool_hint=False) is False
assert manager._should_send_progress("nonexistent", tool_hint=True) is False
+4 -1
View File
@@ -686,7 +686,10 @@ def test_provider_login_openai_codex_handles_missing_oauth_symbol(monkeypatch):
result = runner.invoke(app, ["provider", "login", "openai-codex"])
assert result.exit_code == 1
assert "oauth_cli_kit not installed" in result.stdout
assert (
"This nanobot installation is missing the required oauth-cli-kit package. "
"Reinstall or upgrade nanobot-ai using the same installation method."
) in re.sub(r"\s+", " ", result.stdout)
assert result.exception is not None
+58 -6
View File
@@ -9,6 +9,7 @@ from types import SimpleNamespace
import pytest
from nanobot.agent.agent_plugins import discover_agent_plugin_skills
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
@@ -391,6 +392,9 @@ def test_install_dispatches_safe_pip_and_installs_skill(
"_fetch_skill_content",
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
)
legacy = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text("legacy", encoding="utf-8")
payload = manager.install("gimp")
@@ -400,9 +404,21 @@ def test_install_dispatches_safe_pip_and_installs_skill(
assert "state_recorded" in payload["last_action"]["verification"]
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
plugin = manager.workspace / "plugins" / "cli-app-gimp"
skill = plugin / "skills" / "cli-app-gimp" / "SKILL.md"
assert skill.is_file()
assert json.loads((plugin / "plugin.json").read_text(encoding="utf-8")) == {
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "cli-app-gimp",
"version": "1.0.0",
"description": "Public duplicate entry",
}
assert "name: cli-app-gimp" in skill.read_text(encoding="utf-8")
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
assert [item.name for item in discover_agent_plugin_skills(manager.workspace)] == [
"cli-app-gimp"
]
assert not legacy.exists()
def test_run_argv_logs_command_exit_and_output(
@@ -487,7 +503,14 @@ def test_install_records_available_cli_without_reinstalling(
assert "entry_point_available" in payload["last_action"]["verification"]
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["feishu"]["entry_point_path"] == str(resolved)
skill = manager.workspace / "skills" / "cli-app-feishu" / "SKILL.md"
skill = (
manager.workspace
/ "plugins"
/ "cli-app-feishu"
/ "skills"
/ "cli-app-feishu"
/ "SKILL.md"
)
assert skill.is_file()
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
@@ -704,7 +727,8 @@ def test_uninstall_removes_installed_state_and_generated_skill(
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
skill_dir = manager.workspace / "skills" / "cli-app-gimp"
plugin_dir = manager.workspace / "plugins" / "cli-app-gimp"
skill_dir = plugin_dir / "skills" / "cli-app-gimp"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
monkeypatch.setattr(
@@ -717,7 +741,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
assert payload["last_action"]["ok"] is True
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert not skill_dir.exists()
assert not plugin_dir.exists()
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
@@ -845,19 +869,47 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path
"name": "zoom",
"entry_point": "cli-anything-zoom",
"source": "public",
"skill": "skills/cli-app-zoom/SKILL.md",
"skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
"tool": "run_cli_app",
},
{
"name": "gimp",
"entry_point": "cli-anything-gimp",
"source": "harness",
"skill": "skills/cli-app-gimp/SKILL.md",
"skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
"tool": "run_cli_app",
},
]
def test_legacy_underscored_skill_remains_visible_and_removable(tmp_path: Path) -> None:
manager = _manager(tmp_path)
legacy = manager.workspace / "skills" / "cli-app-unimol_tools" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text(
"---\nname: cli-app-unimol_tools\ndescription: Legacy Uni-Mol app.\n---\n",
encoding="utf-8",
)
manager._save_installed(
{"unimol_tools": {"entry_point": "cli-anything-unimol-tools", "source": "harness"}}
)
app = {
"name": "unimol_tools",
"entry_point": "cli-anything-unimol-tools",
"install_cmd": "pip install cli-anything-unimol-tools",
}
assert manager._app_payload(app, manager._load_installed())["skill_installed"] is True
assert manager.mentioned_installed_apps("use @unimol_tools")[0]["skill"] == (
"skills/cli-app-unimol_tools/SKILL.md"
)
manager.remove_skill("unimol_tools")
assert not legacy.exists()
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
+23 -2
View File
@@ -39,7 +39,7 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
assert "CLI App Mention: @zoom" in joined
assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
@@ -61,4 +61,25 @@ def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
assert "CLI App Attachment: @zoom" in joined
assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
def test_structured_cli_app_attachment_uses_existing_legacy_skill(tmp_path):
legacy = tmp_path / "skills" / "cli-app-unimol_tools" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
lines = runtime_lines(
SimpleNamespace(
content="please use @unimol_tools",
metadata={
"cli_apps": [{
"name": "unimol_tools",
"entry_point": "cli-anything-unimol-tools",
}],
},
),
tmp_path,
)
assert "skill=skills/cli-app-unimol_tools/SKILL.md" in "\n".join(lines)
@@ -1,5 +1,6 @@
from unittest.mock import patch, sentinel
from nanobot.providers import openai_compat_provider
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.registry import ProviderSpec
@@ -59,3 +60,22 @@ async def test_openai_compat_provider_timeout_can_be_overridden_by_env(monkeypat
await provider._ensure_client()
assert mock_async_openai.call_args.kwargs["timeout"] == 45.0
async def test_missing_langfuse_warning_recommends_plugin_command(monkeypatch) -> None:
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "secret")
monkeypatch.setattr(openai_compat_provider, "AsyncOpenAI", None)
with (
patch("importlib.util.find_spec", return_value=None),
patch("openai.AsyncOpenAI") as mock_async_openai,
patch("nanobot.providers.openai_compat_provider.logger.warning") as mock_warning,
):
provider = OpenAICompatProvider(api_key="test-key", api_base="https://example.com/v1")
await provider._ensure_client()
mock_warning.assert_called_once_with(
"LANGFUSE_SECRET_KEY is set but langfuse is not installed; "
"run `nanobot plugins enable langfuse` to enable tracing"
)
mock_async_openai.assert_called_once()
@@ -57,7 +57,7 @@ def test_loaded_corrupt_offset_keeps_messages(tmp_path: Path):
def test_valid_offset_is_preserved():
session = _session(10, 4)
assert session.last_consolidated == 4
assert len(session.get_history()) == 6
assert len(session.get_history()) == 8
def test_loaded_null_metadata_becomes_empty_dict(tmp_path: Path):
+3 -1
View File
@@ -820,4 +820,6 @@ async def test_olostep_package_missing_returns_install_hint(monkeypatch):
tool = _tool(provider="olostep", api_key="olostep-key")
result = await tool.execute(query="test query")
assert result == "Error: olostep package not installed. Run: pip install olostep"
assert result == (
"Error: Olostep support is not installed. Run `nanobot plugins enable olostep`."
)
+1 -22
View File
@@ -1,9 +1,6 @@
"""Tests for subagent announce text shaping on external channel surfaces."""
from nanobot.utils.subagent_channel_display import (
scrub_subagent_announce_body,
scrub_subagent_messages_for_channel,
)
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
def test_scrub_subagent_keeps_header_and_result_only() -> None:
@@ -22,24 +19,6 @@ Summarize this naturally for the user. Keep it brief."""
assert "Summarize" not in out
def test_scrub_subagent_messages_mutates_matching_rows() -> None:
messages: list[dict] = [
{"role": "assistant", "content": "hi"},
{
"role": "assistant",
"content": (
"[Subagent 'x' completed successfully]\n\nTask: t\n\nResult:\nr\n\nSummarize this naturally"
),
"injected_event": "subagent_result",
},
]
scrub_subagent_messages_for_channel(messages)
assert messages[0]["content"] == "hi"
assert "Task:" not in messages[1]["content"]
assert "[Subagent 'x' completed successfully]" in messages[1]["content"]
assert "r" in messages[1]["content"]
def test_scrub_normalizes_crlf_before_result_marker() -> None:
raw = "[Subagent 'z' failed]\r\n\r\nTask: x\r\n\r\nResult:\r\none line\r\n\r\nSummarize this naturally"
out = scrub_subagent_announce_body(raw)
+6 -1
View File
@@ -26,6 +26,7 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
{
"pinned_keys": ["websocket:a", "websocket:a", "", 123],
"archived_keys": ["websocket:b"],
"session_order": ["websocket:b", "websocket:a", "websocket:b"],
"title_overrides": {"websocket:a": " Release notes ", "bad": ""},
"project_name_overrides": {"/repo": " Core ", "bad": ""},
"tags_by_key": {"websocket:a": ["work", "work", ""]},
@@ -41,6 +42,7 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
assert state["schema_version"] == 1
assert state["pinned_keys"] == ["websocket:a"]
assert state["archived_keys"] == ["websocket:b"]
assert state["session_order"] == ["websocket:b", "websocket:a"]
assert state["title_overrides"] == {"websocket:a": "Release notes"}
assert state["project_name_overrides"] == {"/repo": "Core"}
assert state["tags_by_key"] == {"websocket:a": ["work"]}
@@ -61,17 +63,20 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
{
"pinned_keys": ["websocket:a"],
"archived_keys": ["websocket:b"],
"session_order": ["websocket:b", "websocket:a"],
"title_overrides": {"websocket:a": "Release"},
"project_name_overrides": {"/repo": "Core"},
"view": {"density": "compact", "show_previews": True},
"view": {"density": "compact", "show_previews": True, "sort": "manual"},
}
)
assert state["pinned_keys"] == ["websocket:a"]
assert state["archived_keys"] == ["websocket:b"]
assert state["session_order"] == ["websocket:b", "websocket:a"]
assert state["title_overrides"] == {"websocket:a": "Release"}
assert state["project_name_overrides"] == {"/repo": "Core"}
assert state["view"]["density"] == "compact"
assert state["view"]["show_previews"] is True
assert state["view"]["sort"] == "manual"
assert webui_sidebar_state_path().is_file()
assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"]
+8 -2
View File
@@ -1624,7 +1624,10 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
with pytest.raises(WebUISettingsError) as exc:
login_oauth_provider({"provider": ["openai-codex"]})
assert "oauth_cli_kit not installed. Run: pip install oauth-cli-kit" in str(exc.value)
assert str(exc.value) == (
"This nanobot installation is missing the required oauth-cli-kit package. "
"Reinstall or upgrade nanobot-ai using the same installation method."
)
def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
@@ -1642,7 +1645,10 @@ def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
with pytest.raises(WebUISettingsError) as exc:
login_oauth_provider({"provider": ["github-copilot"]})
assert "oauth_cli_kit not installed. Run: pip install oauth-cli-kit" in str(exc.value)
assert str(exc.value) == (
"This nanobot installation is missing the required oauth-cli-kit package. "
"Reinstall or upgrade nanobot-ai using the same installation method."
)
def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
+26 -3
View File
@@ -1004,6 +1004,7 @@ function Shell({
useState<Record<string, WorkspaceScopePayload>>({});
const runningChatIdsRef = useRef<Set<string>>(new Set());
const activeChatIdRef = useRef<string | null>(null);
const pendingCreatedSessionKeyRef = useRef<string | null>(null);
const hostSidebarPreviewCloseTimerRef = useRef<number | null>(null);
const effectiveRuntimeSurface =
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
@@ -1181,8 +1182,15 @@ function Shell({
}, [loading, sessions]);
useEffect(() => {
if (loading || !activeKey) return;
if (sessions.some((session) => session.key === activeKey)) return;
if (loading) return;
const pendingCreatedKey = pendingCreatedSessionKeyRef.current;
if (pendingCreatedKey && sessions.some((session) => session.key === pendingCreatedKey)) {
pendingCreatedSessionKeyRef.current = null;
}
if (!activeKey || sessions.some((session) => session.key === activeKey)) return;
// WebKit can commit the route before useSessions' optimistic insert.
// Keep that just-created destination valid until the session list catches up.
if (pendingCreatedKey === activeKey) return;
const currentRoute = readShellRoute();
navigate(
currentRoute.view === "chat"
@@ -1366,9 +1374,11 @@ function Shell({
try {
const scope = workspaceScope ?? activeWorkspaceScope;
const chatId = await createChat(scope);
const key = `websocket:${chatId}`;
pendingCreatedSessionKeyRef.current = key;
navigate({
view: "chat",
activeKey: `websocket:${chatId}`,
activeKey: key,
settingsSection: "overview",
});
setMobileSidebarOpen(false);
@@ -1596,6 +1606,17 @@ function Shell({
[activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState],
);
const onReorderSessions = useCallback(
(sessionOrder: string[]) => {
void updateSidebarState((current) => ({
...current,
session_order: sessionOrder,
view: { ...current.view, sort: "manual" },
}));
},
[updateSidebarState],
);
const onToggleArchived = useCallback(() => {
void updateSidebarState((current) => ({
...current,
@@ -1916,6 +1937,7 @@ function Shell({
onTogglePin,
onRequestRename,
onToggleArchive,
onReorderSessions,
onToggleGroup,
onRequestRenameProject,
onNewChatInProject,
@@ -1929,6 +1951,7 @@ function Shell({
onToggleArchived,
pinnedKeys: sidebarState.pinned_keys,
archivedKeys: sidebarState.archived_keys,
sessionOrder: sidebarState.session_order,
titleOverrides: sidebarState.title_overrides,
projectNameOverrides: sidebarState.project_name_overrides,
collapsedGroups: sidebarState.collapsed_groups,
+93 -3
View File
@@ -40,6 +40,7 @@ import {
visibleSessionsForGroup,
type ChatGroupLabels,
} from "@/lib/chat-groups";
import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag";
import { cn } from "@/lib/utils";
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
@@ -55,11 +56,13 @@ interface ChatListProps {
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onReorderSessions?: (keys: string[]) => void;
onToggleGroup?: (groupId: string) => void;
onRequestRenameProject?: (projectKey: string, label: string) => void;
onNewChatInProject?: (projectPath: string, projectName: string) => void;
pinnedKeys?: string[];
archivedKeys?: string[];
sessionOrder?: string[];
titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>;
@@ -84,11 +87,13 @@ export const ChatList = memo(function ChatList({
onTogglePin,
onRequestRename,
onToggleArchive,
onReorderSessions,
onToggleGroup,
onRequestRenameProject,
onNewChatInProject,
pinnedKeys = [],
archivedKeys = [],
sessionOrder = [],
titleOverrides = {},
projectNameOverrides = {},
collapsedGroups = {},
@@ -106,6 +111,11 @@ export const ChatList = memo(function ChatList({
}: ChatListProps) {
const { t } = useTranslation();
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
const [draggedSessionKey, setDraggedSessionKey] = useState<string | null>(null);
const [sessionDropTarget, setSessionDropTarget] = useState<{
edge: "before" | "after";
key: string;
} | null>(null);
const activeRowRef = useRef<HTMLDivElement>(null);
const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"),
@@ -123,6 +133,7 @@ export const ChatList = memo(function ChatList({
archivedKeys,
titleOverrides,
projectNameOverrides,
sessionOrder,
showArchived,
sort,
defaultWorkspacePath,
@@ -136,6 +147,7 @@ export const ChatList = memo(function ChatList({
sort,
titleOverrides,
projectNameOverrides,
sessionOrder,
defaultWorkspacePath,
],
);
@@ -155,6 +167,21 @@ export const ChatList = memo(function ChatList({
() => limitedGroups.reduce((total, group) => total + group.sessions.length, 0),
[limitedGroups],
);
const pinned = useMemo(() => new Set(pinnedKeys), [pinnedKeys]);
const archived = useMemo(() => new Set(archivedKeys), [archivedKeys]);
const sessionLanes = useMemo(() => {
const lanes = new Map<string, string>();
for (const group of groups) {
const scope = group.id.startsWith("date:") ? "timeline" : group.id;
for (const session of group.sessions) {
const status = pinned.has(session.key)
? "pinned"
: archived.has(session.key) ? "archived" : "normal";
lanes.set(session.key, `${scope}:${status}`);
}
}
return lanes;
}, [archived, groups, pinned]);
const hiddenSessionCount = Math.max(0, totalSessionCount - visibleSessionCount);
useEffect(() => {
@@ -177,13 +204,30 @@ export const ChatList = memo(function ChatList({
);
}
const pinned = new Set(pinnedKeys);
const archived = new Set(archivedKeys);
const running = new Set(runningChatIds);
const updated = new Set(updatedChatIds);
const compact = density === "compact";
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
const canReorderSession = (targetKey: string) => (
!!draggedSessionKey
&& draggedSessionKey !== targetKey
&& sessionLanes.get(draggedSessionKey) === sessionLanes.get(targetKey)
);
const reorderSession = (targetKey: string, edge: "before" | "after") => {
if (!draggedSessionKey || !canReorderSession(targetKey) || !onReorderSessions) return;
const keys = groups.flatMap((group) => group.sessions.map((session) => session.key));
const reordered = keys.filter((key) => key !== draggedSessionKey);
const targetIndex = reordered.indexOf(targetKey);
if (targetIndex < 0) return;
reordered.splice(targetIndex + (edge === "after" ? 1 : 0), 0, draggedSessionKey);
const groupedKeys = new Set(keys);
onReorderSessions([
...reordered,
...sessionOrder.filter((key) => !groupedKeys.has(key)),
]);
};
return (
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
<SidebarSelectionHighlight
@@ -260,7 +304,41 @@ export const ChatList = memo(function ChatList({
? "updated"
: null;
return (
<li key={s.key} className="min-w-0">
<li
key={s.key}
className="relative min-w-0"
onDragOver={(event) => {
if (!canReorderSession(s.key)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
const rect = event.currentTarget.getBoundingClientRect();
setSessionDropTarget({
key: s.key,
edge: event.clientY < rect.top + rect.height / 2 ? "before" : "after",
});
}}
onDrop={(event) => {
if (!canReorderSession(s.key)) return;
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
const edge = event.clientY < rect.top + rect.height / 2
? "before"
: "after";
reorderSession(s.key, edge);
setDraggedSessionKey(null);
setSessionDropTarget(null);
}}
>
{sessionDropTarget?.key === s.key ? (
<span
aria-hidden
data-session-drop-edge={sessionDropTarget.edge}
className={cn(
"pointer-events-none absolute inset-x-2 z-20 h-0.5 rounded-full bg-primary",
sessionDropTarget.edge === "before" ? "-top-px" : "-bottom-px",
)}
/>
) : null}
<div
ref={active ? activeRowRef : undefined}
data-chat-row={s.key}
@@ -276,10 +354,22 @@ export const ChatList = memo(function ChatList({
<button
type="button"
onClick={() => onSelect(s.key)}
draggable
onDragStart={(event) => {
setDraggedSessionKey(s.key);
setSessionDropTarget(null);
writeDraggedSession(event.dataTransfer, s.key);
}}
onDragEnd={() => {
clearDraggedSession();
setDraggedSessionKey(null);
setSessionDropTarget(null);
}}
aria-current={active ? "page" : undefined}
title={tooltipTitle}
className={cn(
"min-w-0 flex-1 overflow-hidden text-left",
"cursor-grab active:cursor-grabbing",
compact ? "py-1" : "py-1.5",
projectMode && "pl-7",
)}
@@ -182,6 +182,7 @@ export function SessionMentionToken({
testId={`${testIdPrefix}-session-mention-${mention.name}`}
title={`Session: ${mention.title || mention.name}`}
color={INLINE_TOKEN_HIGHLIGHT_COLOR}
className={variant === "composer" ? "font-normal" : undefined}
>
{label}
</InlineTokenHighlight>
@@ -222,6 +223,7 @@ export function CliAppMentionToken({
testId={`${testIdPrefix}-cli-mention-${app.name}`}
title={t("thread.composer.mentions.cliTitle", { name: app.display_name || app.name })}
color={color}
className={variant === "composer" ? "font-normal" : undefined}
>
<span
className={cn("relative inline-block", showLogo && "text-transparent")}
@@ -278,6 +280,7 @@ export function McpPresetMentionToken({
testId={`${testIdPrefix}-mcp-mention-${preset.name}`}
title={t("thread.composer.mentions.mcpTitle", { name: preset.display_name || preset.name })}
color={color}
className={variant === "composer" ? "font-normal" : undefined}
>
<span
className={cn("relative inline-block", showLogo && "text-transparent")}
+4
View File
@@ -40,6 +40,7 @@ interface SidebarProps {
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onReorderSessions: (keys: string[]) => void;
onToggleGroup: (groupId: string) => void;
onRequestRenameProject: (projectKey: string, label: string) => void;
onNewChatInProject: (projectPath: string, projectName: string) => void;
@@ -57,6 +58,7 @@ interface SidebarProps {
collapsed?: boolean;
pinnedKeys?: string[];
archivedKeys?: string[];
sessionOrder?: string[];
titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>;
@@ -227,11 +229,13 @@ export function Sidebar(props: SidebarProps) {
onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename}
onToggleArchive={props.onToggleArchive}
onReorderSessions={props.onReorderSessions}
onToggleGroup={props.onToggleGroup}
onRequestRenameProject={props.onRequestRenameProject}
onNewChatInProject={props.onNewChatInProject}
pinnedKeys={props.pinnedKeys}
archivedKeys={props.archivedKeys}
sessionOrder={props.sessionOrder}
titleOverrides={props.titleOverrides}
projectNameOverrides={props.projectNameOverrides}
collapsedGroups={props.collapsedGroups}
+316 -281
View File
@@ -3312,6 +3312,7 @@ function ModelsSettings({
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) });
const [editorOpen, setEditorOpen] = useState(false);
const [editorRowKey, setEditorRowKey] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState<number | null>(null);
const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = useState<number | null>(null);
@@ -3338,6 +3339,10 @@ function ModelsSettings({
})),
];
const selectedPreset = namedPresetsByName.get(form.modelPreset) ?? null;
const activeEditorRowKey =
editorRowKey ??
presetRows.find((row) => row.name === selectedPreset?.name)?.key ??
null;
useEffect(() => {
setAdvancedOpen(false);
}, [editorOpen, selectedPreset?.name]);
@@ -3371,11 +3376,15 @@ function ModelsSettings({
form.temperature < 0 ||
form.temperature > 2;
const selectedPresetReferenced = Boolean(
selectedPreset && (settings.model_call_order ?? []).includes(selectedPreset.name),
selectedPreset && callOrder.includes(selectedPreset.name),
);
const callOrderBusy = orderSaving || saving;
const selectPreset = (preset: SettingsPayload["model_presets"][number]) => {
const toggleCurrentPreset = !creating && selectedPreset?.name === preset.name;
const selectPreset = (
preset: SettingsPayload["model_presets"][number],
rowKey: string,
) => {
const toggleCurrentPreset =
!creating && selectedPreset?.name === preset.name && activeEditorRowKey === rowKey;
onSelectConfiguration();
if (toggleCurrentPreset) {
setEditorOpen((open) => !open);
@@ -3392,6 +3401,7 @@ function ModelsSettings({
temperature: preset.temperature,
reasoningEffort: preset.reasoning_effort ?? "",
}));
setEditorRowKey(rowKey);
setEditorOpen(true);
};
@@ -3432,280 +3442,19 @@ function ModelsSettings({
onChangeCallOrder(next);
};
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>
{tx("settings.models.presets", "Model presets")}
</SettingsSectionTitle>
<SettingsGroup>
{!settings.model_call_order_editable ? (
<div className="flex flex-col gap-4 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="flex min-w-0 items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-muted-foreground">
<ListOrdered className="h-4 w-4" aria-hidden />
</span>
<div className="min-w-0">
<p className="text-[14px] font-medium text-foreground">
{tx("settings.models.convertTitle", "Convert the current model setup")}
</p>
<p className="mt-0.5 max-w-[34rem] text-[12px] leading-5 text-muted-foreground">
{tx(
"settings.models.convertHelp",
"Turn the existing primary and fallback models into presets so their order can be managed here.",
)}
</p>
</div>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0 rounded-full"
disabled={migrationSaving}
onClick={onMigrate}
>
{migrationSaving ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{migrationSaving
? tx("settings.models.converting", "Converting...")
: tx("settings.models.convertAction", "Convert to presets")}
</Button>
</div>
) : (
<>
<div role="list" className="divide-y divide-border/45">
{presetRows.map(({ key, name, orderIndex, preset }) => {
const ordered = orderIndex >= 0;
const provider = preset
? modelPresetProviderKey(preset, settings)
: settings.agent.resolved_provider ?? settings.agent.provider;
const presetConfigured = preset
? settingsProviderConfigured(
settings,
preset.provider,
preset.resolved_provider,
)
: true;
const isDropTarget =
ordered &&
dragOverCallOrderIndex === orderIndex &&
draggedCallOrderIndex !== orderIndex;
const dropAfterTarget =
isDropTarget &&
draggedCallOrderIndex !== null &&
draggedCallOrderIndex < orderIndex;
return (
const renderPresetEditor = () => (
<div
key={key}
role="listitem"
tabIndex={ordered ? 0 : -1}
draggable={ordered && !callOrderBusy}
aria-label={
ordered
? `${preset?.label ?? name}. ${tx(
"settings.models.dragToReorder",
"Drag to reorder",
)}`
: preset?.label ?? name
}
data-testid={`model-call-order-row-${name}`}
onDragStart={(event) => {
if (!ordered || callOrderBusy) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", name);
setDraggedCallOrderIndex(orderIndex);
setDragOverCallOrderIndex(orderIndex);
}}
onDragEnd={() => {
setDraggedCallOrderIndex(null);
setDragOverCallOrderIndex(null);
}}
onDragEnter={(event) => {
if (ordered && draggedCallOrderIndex !== null) {
event.preventDefault();
setDragOverCallOrderIndex(orderIndex);
}
}}
onDragOver={(event) => {
if (!ordered || draggedCallOrderIndex === null) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}}
onDrop={(event) => {
if (!ordered) return;
event.preventDefault();
dropCallOrderItem(orderIndex);
}}
onKeyDown={(event) => {
if (event.currentTarget !== event.target) return;
if (ordered && event.key === "ArrowUp") {
event.preventDefault();
moveCallOrderItem(orderIndex, -1);
} else if (ordered && event.key === "ArrowDown") {
event.preventDefault();
moveCallOrderItem(orderIndex, 1);
} else if ((event.key === "Enter" || event.key === " ") && preset) {
event.preventDefault();
selectPreset(preset);
}
}}
className={cn(
"group relative flex min-h-[76px] select-none items-center gap-3 px-4 py-3 outline-none transition-[background-color,opacity] duration-150 sm:px-5",
ordered &&
(callOrderBusy
? "cursor-wait"
: "cursor-grab active:cursor-grabbing"),
"hover:bg-muted/25",
isDropTarget &&
!dropAfterTarget &&
"before:absolute before:inset-x-4 before:top-0 before:z-10 before:h-0.5 before:rounded-full before:bg-foreground sm:before:inset-x-5",
isDropTarget &&
dropAfterTarget &&
"after:absolute after:inset-x-4 after:bottom-0 after:z-10 after:h-0.5 after:rounded-full after:bg-foreground sm:after:inset-x-5",
ordered && draggedCallOrderIndex === orderIndex && "opacity-35",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
)}
id="model-preset-editor"
data-testid="model-preset-editor"
className="mx-3 mb-3 divide-y divide-border/45 overflow-hidden rounded-[18px] border border-border/45 bg-background/80 shadow-sm motion-reduce:animate-none animate-in fade-in-0 slide-in-from-top-1 duration-200 sm:mx-5 lg:mx-auto lg:w-[calc(100%-2.5rem)] lg:max-w-6xl"
>
{ordered ? (
<GripVertical
className="pointer-events-none h-4 w-4 shrink-0 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground"
aria-hidden
/>
) : (
<span className="h-4 w-4 shrink-0" aria-hidden />
)}
<button
type="button"
aria-pressed={selectedPreset?.name === name}
aria-expanded={selectedPreset?.name === name && editorOpen}
disabled={!preset}
onClick={() => preset && selectPreset(preset)}
className="flex min-w-0 flex-1 items-center gap-3 rounded-[12px] text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{ordered ? (
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-muted font-mono text-[11px] font-semibold tabular-nums text-muted-foreground">
{orderIndex + 1}
</span>
) : (
<span className="h-7 w-7 shrink-0" aria-hidden />
)}
<ProviderPickerIcon
provider={provider}
showBrandLogos={showBrandLogos}
unconfigured={!presetConfigured}
/>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 flex-wrap items-center gap-2">
<span className="truncate text-[14px] font-medium text-foreground">
{preset?.label ?? name}
</span>
{orderIndex === 0 ? (
<StatusPill tone="success">
{tx("settings.models.primary", "Primary")}
</StatusPill>
) : !ordered ? (
<StatusPill tone="neutral">
{tx("settings.models.disabled", "Disabled")}
</StatusPill>
) : null}
{!presetConfigured ? (
<span className="text-[11px] font-medium text-amber-700 dark:text-amber-300">
{tx(
"settings.models.providerSetupRequired",
"Provider setup required",
)}
</span>
) : null}
</span>
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
{preset?.model ?? name}
</span>
</span>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
selectedPreset?.name === name && editorOpen && "rotate-90",
)}
aria-hidden
/>
</button>
<button
type="button"
role="switch"
aria-checked={ordered}
aria-label={
ordered
? tx("settings.models.removeFromOrder", "Disable preset")
: tx("settings.models.addToOrder", "Enable preset")
}
disabled={callOrderBusy || (ordered && callOrder.length <= 1)}
onClick={() => {
if (ordered) {
removeCallOrderItem(orderIndex);
} else if (preset) {
onChangeCallOrder([...callOrder, preset.name]);
}
}}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40",
ordered ? "bg-foreground" : "bg-muted-foreground/25",
)}
>
<span
className={cn(
"h-4 w-4 rounded-full bg-background shadow-sm transition-transform",
ordered ? "translate-x-[18px]" : "translate-x-0.5",
)}
aria-hidden
/>
</button>
</div>
);
})}
</div>
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
{!creating ? (
<Button
size="sm"
variant="ghost"
className="rounded-full"
disabled={callOrderBusy}
onClick={() => {
setEditorOpen(true);
onBeginCreate();
}}
>
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{tx("settings.models.newPreset", "New model preset")}
</Button>
) : (
<span />
)}
{orderSaving ? (
<SettingsStatusMessage>
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
{tx("settings.actions.saving", "Saving...")}
</span>
</SettingsStatusMessage>
) : null}
</div>
</>
)}
{editorOpen && (selectedPreset || creating) ? (
<>
<div className="flex min-h-[52px] items-center justify-between gap-3 bg-muted/15 px-4 py-3 sm:px-5">
{creating ? (
<div className="flex min-h-[52px] items-center px-4 py-3 sm:px-5">
<span className="text-[13px] font-semibold text-foreground/85">
{creating
? tx("settings.models.newPreset", "New model preset")
: tx("settings.models.editPreset", "Edit preset")}
{tx("settings.models.newPreset", "New model preset")}
</span>
</div>
) : null}
<SettingsRow title={tx("settings.models.presetName", "Preset name")}>
<Input
autoFocus={creating}
@@ -3821,24 +3570,32 @@ function ModelsSettings({
{tx("settings.actions.cancel", "Cancel")}
</Button>
) : selectedPreset ? (
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<Button
size="sm"
variant="ghost"
className="self-start rounded-full text-muted-foreground hover:text-destructive"
className="rounded-full text-muted-foreground hover:text-destructive"
disabled={selectedPresetReferenced || saving || orderSaving}
title={
selectedPresetReferenced
? tx(
"settings.models.removeBeforeDelete",
"Remove this preset from the call order before deleting it.",
)
: undefined
aria-describedby={
selectedPresetReferenced ? "model-preset-delete-hint" : undefined
}
onClick={() => onDeleteConfiguration(selectedPreset)}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{tx("settings.actions.delete", "Delete")}
</Button>
{selectedPresetReferenced ? (
<span
id="model-preset-delete-hint"
className="text-[11px] leading-4 text-muted-foreground"
>
{tx(
"settings.models.removeBeforeDelete",
"Remove this preset from the call order before deleting it.",
)}
</span>
) : null}
</div>
) : null}
<div className="flex items-center justify-end gap-3">
<Button
@@ -3860,8 +3617,286 @@ function ModelsSettings({
</Button>
</div>
</div>
</>
</div>
);
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>
{tx("settings.models.presets", "Model presets")}
</SettingsSectionTitle>
<SettingsGroup>
{!settings.model_call_order_editable ? (
<div className="flex flex-col gap-4 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="flex min-w-0 items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-muted-foreground">
<ListOrdered className="h-4 w-4" aria-hidden />
</span>
<div className="min-w-0">
<p className="text-[14px] font-medium text-foreground">
{tx("settings.models.convertTitle", "Convert the current model setup")}
</p>
<p className="mt-0.5 max-w-[34rem] text-[12px] leading-5 text-muted-foreground">
{tx(
"settings.models.convertHelp",
"Turn the existing primary and fallback models into presets so their order can be managed here.",
)}
</p>
</div>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0 rounded-full"
disabled={migrationSaving}
onClick={onMigrate}
>
{migrationSaving ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{migrationSaving
? tx("settings.models.converting", "Converting...")
: tx("settings.models.convertAction", "Convert to presets")}
</Button>
</div>
) : (
<>
<div role="list" className="divide-y divide-border/45">
{presetRows.map(({ key, name, orderIndex, preset }) => {
const ordered = orderIndex >= 0;
const provider = preset
? modelPresetProviderKey(preset, settings)
: settings.agent.resolved_provider ?? settings.agent.provider;
const presetConfigured = preset
? settingsProviderConfigured(
settings,
preset.provider,
preset.resolved_provider,
)
: true;
const isDropTarget =
ordered &&
dragOverCallOrderIndex === orderIndex &&
draggedCallOrderIndex !== orderIndex;
const dropAfterTarget =
isDropTarget &&
draggedCallOrderIndex !== null &&
draggedCallOrderIndex < orderIndex;
const isSelected =
editorOpen &&
!creating &&
activeEditorRowKey === key &&
selectedPreset?.name === name;
const presetRow = (
<div
tabIndex={ordered ? 0 : -1}
draggable={ordered && !callOrderBusy}
aria-label={
ordered
? `${preset?.label ?? name}. ${tx(
"settings.models.dragToReorder",
"Drag to reorder",
)}`
: preset?.label ?? name
}
data-testid={`model-call-order-row-${name}`}
onDragStart={(event) => {
if (!ordered || callOrderBusy) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", name);
setDraggedCallOrderIndex(orderIndex);
setDragOverCallOrderIndex(orderIndex);
}}
onDragEnd={() => {
setDraggedCallOrderIndex(null);
setDragOverCallOrderIndex(null);
}}
onDragEnter={(event) => {
if (ordered && draggedCallOrderIndex !== null) {
event.preventDefault();
setDragOverCallOrderIndex(orderIndex);
}
}}
onDragOver={(event) => {
if (!ordered || draggedCallOrderIndex === null) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}}
onDrop={(event) => {
if (!ordered) return;
event.preventDefault();
dropCallOrderItem(orderIndex);
}}
onKeyDown={(event) => {
if (event.currentTarget !== event.target) return;
if (ordered && event.key === "ArrowUp") {
event.preventDefault();
moveCallOrderItem(orderIndex, -1);
} else if (ordered && event.key === "ArrowDown") {
event.preventDefault();
moveCallOrderItem(orderIndex, 1);
} else if ((event.key === "Enter" || event.key === " ") && preset) {
event.preventDefault();
selectPreset(preset, key);
}
}}
className={cn(
"group relative flex min-h-[76px] select-none items-center gap-3 px-4 py-3 outline-none transition-[background-color,opacity] duration-150 sm:px-5",
ordered &&
(callOrderBusy
? "cursor-wait"
: "cursor-grab active:cursor-grabbing"),
"hover:bg-muted/25",
isDropTarget &&
!dropAfterTarget &&
"before:absolute before:inset-x-4 before:top-0 before:z-10 before:h-0.5 before:rounded-full before:bg-foreground sm:before:inset-x-5",
isDropTarget &&
dropAfterTarget &&
"after:absolute after:inset-x-4 after:bottom-0 after:z-10 after:h-0.5 after:rounded-full after:bg-foreground sm:after:inset-x-5",
ordered && draggedCallOrderIndex === orderIndex && "opacity-35",
isSelected && "bg-muted/45 hover:bg-muted/45",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
)}
>
{ordered ? (
<GripVertical
className="pointer-events-none h-4 w-4 shrink-0 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground"
aria-hidden
/>
) : (
<span className="h-4 w-4 shrink-0" aria-hidden />
)}
<button
type="button"
aria-pressed={selectedPreset?.name === name}
aria-expanded={isSelected}
aria-controls={isSelected ? "model-preset-editor" : undefined}
disabled={!preset}
onClick={() => preset && selectPreset(preset, key)}
className="flex min-w-0 flex-1 items-center gap-3 rounded-[12px] text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{ordered ? (
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-muted font-mono text-[11px] font-semibold tabular-nums text-muted-foreground">
{orderIndex + 1}
</span>
) : (
<span className="h-7 w-7 shrink-0" aria-hidden />
)}
<ProviderPickerIcon
provider={provider}
showBrandLogos={showBrandLogos}
unconfigured={!presetConfigured}
/>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 flex-wrap items-center gap-2">
<span className="truncate text-[14px] font-medium text-foreground">
{preset?.label ?? name}
</span>
{orderIndex === 0 ? (
<StatusPill tone="success">
{tx("settings.models.primary", "Primary")}
</StatusPill>
) : !ordered ? (
<StatusPill tone="neutral">
{tx("settings.models.disabled", "Disabled")}
</StatusPill>
) : null}
{!presetConfigured ? (
<span className="text-[11px] font-medium text-amber-700 dark:text-amber-300">
{tx(
"settings.models.providerSetupRequired",
"Provider setup required",
)}
</span>
) : null}
</span>
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
{preset?.model ?? name}
</span>
</span>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
isSelected && "rotate-90",
)}
aria-hidden
/>
</button>
<button
type="button"
role="switch"
aria-checked={ordered}
aria-label={
ordered
? tx("settings.models.removeFromOrder", "Disable preset")
: tx("settings.models.addToOrder", "Enable preset")
}
disabled={callOrderBusy || (ordered && callOrder.length <= 1)}
onClick={() => {
if (ordered) {
removeCallOrderItem(orderIndex);
} else if (preset) {
onChangeCallOrder([...callOrder, preset.name]);
}
}}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40",
ordered ? "bg-foreground" : "bg-muted-foreground/25",
)}
>
<span
className={cn(
"h-4 w-4 rounded-full bg-background shadow-sm transition-transform",
ordered ? "translate-x-[18px]" : "translate-x-0.5",
)}
aria-hidden
/>
</button>
</div>
);
return (
<div key={key} role="listitem">
{presetRow}
{isSelected ? renderPresetEditor() : null}
</div>
);
})}
</div>
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
{!creating ? (
<Button
size="sm"
variant="ghost"
className="rounded-full"
disabled={callOrderBusy}
onClick={() => {
setEditorRowKey(null);
setEditorOpen(true);
onBeginCreate();
}}
>
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{tx("settings.models.newPreset", "New model preset")}
</Button>
) : (
<span />
)}
{orderSaving ? (
<SettingsStatusMessage>
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
{tx("settings.actions.saving", "Saving...")}
</span>
</SettingsStatusMessage>
) : null}
</div>
{creating && editorOpen ? renderPresetEditor() : null}
</>
)}
</SettingsGroup>
</section>
</div>
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import QRCode from "qrcode";
import { Check, Loader2, Network, RotateCcw } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -34,6 +34,14 @@ export type ChannelConnectStartOptions = {
force?: boolean;
};
export type ChannelQrConnectPendingContext = {
connect: ChannelConnectPayload;
busy: boolean;
poll: (
params?: Readonly<Record<string, string>>,
) => Promise<ChannelConnectPayload | null>;
};
export function ChannelQrConnectFlow({
token,
channelName,
@@ -43,6 +51,10 @@ export function ChannelQrConnectFlow({
forceOnRepeat = false,
labels,
onFeaturesUpdate,
pausePolling,
renderPending,
resolveMessage,
suppressSucceeded = false,
}: {
token: string;
channelName: string;
@@ -52,6 +64,10 @@ export function ChannelQrConnectFlow({
forceOnRepeat?: boolean;
labels: ChannelQrConnectLabels;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
pausePolling?: (payload: ChannelConnectPayload) => boolean;
renderPending?: (context: ChannelQrConnectPendingContext) => ReactNode;
resolveMessage?: (payload: ChannelConnectPayload) => string | undefined;
suppressSucceeded?: boolean;
}) {
const pageVisible = usePageVisibility();
const { t } = useTranslation();
@@ -72,6 +88,10 @@ export function ChannelQrConnectFlow({
const pending = connect?.status === "pending";
const succeeded = connect?.status === "succeeded";
const canStart = !pending && !busy;
const pollingPaused = Boolean(connect && pausePolling?.(connect));
const displayMessage = connect
? resolveMessage?.(connect) ?? connect.message
: undefined;
useEffect(() => {
if (!connect?.qr_url) {
@@ -96,8 +116,14 @@ export function ChannelQrConnectFlow({
}, [connect?.qr_url]);
useEffect(() => {
if (!connect?.session_id || connect.status !== "pending" || !pageVisible) return;
if (
!connect?.session_id
|| connect.status !== "pending"
|| pollingPaused
|| !pageVisible
) return;
let cancelled = false;
const sessionId = connect.session_id;
const poll = async () => {
if (pollInFlight.current) return;
pollInFlight.current = true;
@@ -105,7 +131,7 @@ export function ChannelQrConnectFlow({
const payload = await pollChannelConnect(
tokenRef.current,
channelName,
connect.session_id,
sessionId,
);
if (cancelled) return;
setConnect((current) => ({
@@ -142,6 +168,7 @@ export function ChannelQrConnectFlow({
connect?.status,
onFeaturesUpdate,
pageVisible,
pollingPaused,
]);
const start = useCallback(async (force = false) => {
@@ -188,6 +215,40 @@ export function ChannelQrConnectFlow({
}
};
const submitPoll = async (
params: Readonly<Record<string, string>> = {},
): Promise<ChannelConnectPayload | null> => {
if (!connect?.session_id) return null;
setBusy(true);
setError(null);
try {
const payload = await pollChannelConnect(
tokenRef.current,
channelName,
connect.session_id,
"",
params,
);
setConnect((current) => ({
...(current ?? payload),
...payload,
qr_url: payload.qr_url ?? current?.qr_url,
}));
if (payload.nanobot_features) {
onFeaturesUpdate(payload.nanobot_features);
}
if (payload.status !== "pending") {
setError(null);
}
return payload;
} catch (err) {
setError((err as Error).message);
return null;
} finally {
setBusy(false);
}
};
return (
<div className="mt-3 space-y-3">
{pending ? (
@@ -210,10 +271,12 @@ export function ChannelQrConnectFlow({
<p className="mt-1 text-[12.5px] leading-5 text-muted-foreground">
{labels.scanDescription}
</p>
{renderPending?.({ connect, busy, poll: submitPoll }) ?? (
<div className="mt-3 flex items-center gap-2 text-[12px] text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
{labels.waiting}
</div>
)}
<div className="mt-4 flex flex-wrap justify-end gap-2">
<Button
type="button"
@@ -230,16 +293,16 @@ export function ChannelQrConnectFlow({
</div>
) : null}
{succeeded ? (
{succeeded && !suppressSucceeded ? (
<div className="flex items-center gap-2 rounded-[12px] border border-emerald-500/20 px-3 py-2 text-[12px] font-medium text-emerald-700 dark:text-emerald-200">
<Check className="h-3.5 w-3.5" aria-hidden />
{connect.message ?? labels.connected}
{displayMessage ?? labels.connected}
</div>
) : null}
{connect && ["expired", "failed", "cancelled"].includes(connect.status) ? (
<div className="rounded-[12px] border border-border/60 px-3 py-2 text-[12px] leading-5 text-muted-foreground">
{connect.message || labels.stopped}
{displayMessage || labels.stopped}
</div>
) : null}
@@ -307,7 +307,7 @@ export function AgentActivityCluster({
}
if (!wasStreaming || userToggledOuter) return undefined;
setCompletionHoldOpen(true);
const timeout = window.setTimeout(() => setCompletionHoldOpen(false), 900);
const timeout = window.setTimeout(() => setCompletionHoldOpen(false), 300);
return () => window.clearTimeout(timeout);
}, [isTurnStreaming, userToggledOuter]);
+182 -20
View File
@@ -105,6 +105,11 @@ import {
isSideChannelLifecycle,
slashCommandLifecycle,
} from "@/lib/slash-command";
import {
clearDraggedSession,
hasDraggedSession,
readDraggedSession,
} from "@/lib/session-drag";
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
import { cn } from "@/lib/utils";
@@ -317,6 +322,35 @@ type MentionCandidate = {
}
);
interface MentionInsertion {
value: string;
cursor: number;
tokenStart: number;
tokenEnd: number;
}
function mentionInsertion(
value: string,
name: string,
start: number,
end: number,
): MentionInsertion {
const from = Math.min(Math.max(start, 0), value.length);
const to = Math.min(Math.max(end, from), value.length);
const prefix = value.slice(0, from);
const suffix = value.slice(to);
const leadingSpace = prefix && !/\s$/.test(prefix) ? " " : "";
const trailingSpace = /^\s/.test(suffix) ? "" : " ";
const tokenStart = prefix.length + leadingSpace.length;
const tokenEnd = tokenStart + name.length + 1;
return {
value: `${prefix}${leadingSpace}@${name}${trailingSpace}${suffix}`,
cursor: tokenEnd + trailingSpace.length,
tokenStart,
tokenEnd,
};
}
function sessionMentionBase(session: ChatSummary): string {
const label = session.title?.trim() || session.preview.trim() || "session";
const slug = label
@@ -936,6 +970,11 @@ export function ThreadComposer({
const { t } = useTranslation();
const [value, setValue] = useState("");
const [selectedSessionMentions, setSelectedSessionMentions] = useState<SessionMention[]>([]);
const [sessionDragPreview, setSessionDragPreview] = useState<{
mention: SessionMention;
start: number;
end: number;
} | null>(null);
const [inlineError, setInlineError] = useState<string | null>(null);
const [voiceErrorFading, setVoiceErrorFading] = useState(false);
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
@@ -1263,6 +1302,22 @@ export function ThreadComposer({
() => splitCapabilityMentionSegments(value, cliApps, mcpPresets, selectedSessionMentions),
[cliApps, mcpPresets, selectedSessionMentions, value],
);
const sessionDragInsertion = sessionDragPreview
? mentionInsertion(
value,
sessionDragPreview.mention.name,
sessionDragPreview.start,
sessionDragPreview.end,
)
: null;
const displayMentionSegments = sessionDragInsertion && sessionDragPreview
? splitCapabilityMentionSegments(
sessionDragInsertion.value,
cliApps,
mcpPresets,
[...selectedSessionMentions, sessionDragPreview.mention],
)
: mentionSegments;
const activeSessionMentions = useMemo(() => {
const seen = new Set<string>();
return mentionSegments.flatMap((segment) => {
@@ -1351,7 +1406,7 @@ export function ThreadComposer({
const showCliAppMenu = filteredMentionCandidates.length > 0;
const showAnyPalette = showSlashMenu || showCliAppMenu;
const hasMentionDecorations = mentionSegments.some(
const hasMentionDecorations = displayMentionSegments.some(
(segment) => segment.kind !== "text",
);
const activeCliMentionApps = useMemo(() => {
@@ -1606,10 +1661,13 @@ export function ThreadComposer({
[isStreaming, onStop, recentSlashCommands, resizeTextarea, skillQuery, value],
);
const chooseMentionCandidate = useCallback(
(candidate: MentionCandidate) => {
if (!cliAppMention) return;
const insertMentionCandidate = useCallback(
(candidate: MentionCandidate, start: number, end: number) => {
if (candidate.kind === "session") {
const alreadySelected = activeSessionMentions.some(
(mention) => mention.session_key === candidate.mention.session_key,
);
if (!alreadySelected && activeSessionMentions.length >= SESSION_MENTIONS_LIMIT) return;
const name = candidate.name.toLowerCase();
setSelectedSessionMentions([
...activeSessionMentions.filter((mention) => (
@@ -1619,12 +1677,9 @@ export function ThreadComposer({
candidate.mention,
]);
}
const suffix = value.slice(cliAppMention.end);
const mention = `@${candidate.name}${suffix.startsWith(" ") ? "" : " "}`;
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
const nextCursor = cliAppMention.start + mention.length;
setValue(next);
setCursorPosition(nextCursor);
const insertion = mentionInsertion(value, candidate.name, start, end);
setValue(insertion.value);
setCursorPosition(insertion.cursor);
setCliAppMenuDismissed(true);
setSlashMenuDismissed(false);
setInlineError(null);
@@ -1633,12 +1688,89 @@ export function ThreadComposer({
const el = textareaRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(nextCursor, nextCursor);
el.setSelectionRange(insertion.cursor, insertion.cursor);
});
},
[activeSessionMentions, cliAppMention, resizeTextarea, value],
[activeSessionMentions, resizeTextarea, value],
);
const chooseMentionCandidate = useCallback(
(candidate: MentionCandidate) => {
if (!cliAppMention) return;
insertMentionCandidate(candidate, cliAppMention.start, cliAppMention.end);
},
[cliAppMention, insertMentionCandidate],
);
const handleSessionDrop = useCallback((event: React.DragEvent) => {
if (!hasDraggedSession(event.dataTransfer)) return false;
event.preventDefault();
clearDraggedSession();
const preview = sessionDragPreview;
setSessionDragPreview(null);
if (disabled) return true;
const sessionKey = readDraggedSession(event.dataTransfer);
const mention = availableSessionMentions.find(
(candidate) => candidate.session_key === (sessionKey ?? preview?.mention.session_key),
);
if (!mention) return true;
const caret = preview?.start ?? textareaRef.current?.selectionStart ?? value.length;
insertMentionCandidate(
{
kind: "session",
name: mention.name,
displayName: mention.title || mention.name,
mention,
},
caret,
preview?.end ?? textareaRef.current?.selectionEnd ?? caret,
);
return true;
}, [availableSessionMentions, disabled, insertMentionCandidate, sessionDragPreview, value.length]);
const previewSessionDrop = useCallback((event: React.DragEvent) => {
if (!hasDraggedSession(event.dataTransfer)) return false;
if (disabled) {
event.dataTransfer.dropEffect = "none";
setSessionDragPreview(null);
return true;
}
const sessionKey = readDraggedSession(event.dataTransfer);
const mention = availableSessionMentions.find(
(candidate) => candidate.session_key === sessionKey,
);
const alreadySelected = mention && activeSessionMentions.some(
(candidate) => candidate.session_key === mention.session_key,
);
if (!mention || (!alreadySelected && activeSessionMentions.length >= SESSION_MENTIONS_LIMIT)) {
event.dataTransfer.dropEffect = "none";
setSessionDragPreview(null);
return true;
}
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
const start = textareaRef.current?.selectionStart ?? value.length;
const end = textareaRef.current?.selectionEnd ?? start;
setSessionDragPreview((current) => (
current?.mention.session_key === mention.session_key
&& current.start === start
&& current.end === end
? current
: { mention, start, end }
));
return true;
}, [activeSessionMentions, availableSessionMentions, disabled, value.length]);
useEffect(() => {
if (!sessionDragPreview) return;
const clearPreview = () => {
clearDraggedSession();
setSessionDragPreview(null);
};
document.addEventListener("dragend", clearPreview);
return () => document.removeEventListener("dragend", clearPreview);
}, [sessionDragPreview]);
const clearComposerText = useCallback((restoreFocus = true) => {
setValue("");
setSelectedSessionMentions([]);
@@ -2067,10 +2199,25 @@ export function ThreadComposer({
e.preventDefault();
submit();
}}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onDragEnter={(event) => {
if (!previewSessionDrop(event)) onDragEnter(event);
}}
onDragOver={(event) => {
if (!previewSessionDrop(event)) onDragOver(event);
}}
onDragLeave={(event) => {
if (!hasDraggedSession(event.dataTransfer)) {
onDragLeave(event);
return;
}
const nextTarget = event.relatedTarget;
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
setSessionDragPreview(null);
}
}}
onDrop={(event) => {
if (!handleSessionDrop(event)) onDrop(event);
}}
className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
>
{showSlashMenu ? (
@@ -2100,6 +2247,7 @@ export function ThreadComposer({
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
disabled && "opacity-60",
sessionDragPreview && "ring-1 ring-primary/25",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
goalState?.active &&
"goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45",
@@ -2184,9 +2332,12 @@ export function ThreadComposer({
<div className="relative">
{hasMentionDecorations ? (
<ComposerCliMentionOverlay
segments={mentionSegments}
segments={displayMentionSegments}
isHero={isHero}
className={inputTextClasses}
ghostRange={sessionDragInsertion
? { start: sessionDragInsertion.tokenStart, end: sessionDragInsertion.tokenEnd }
: null}
/>
) : null}
<textarea
@@ -2209,7 +2360,7 @@ export function ThreadComposer({
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
onPaste={onPaste}
rows={1}
placeholder={resolvedPlaceholder}
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
disabled={disabled}
aria-label={t("thread.composer.inputAria")}
className={cn(
@@ -2226,7 +2377,7 @@ export function ThreadComposer({
role="alert"
className={cn(
"mx-3 mb-1 max-h-10 overflow-hidden rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
"text-[11.5px] font-medium text-destructive transition-[max-height,margin,padding,opacity] duration-500 ease-out",
"text-[11.5px] font-medium text-destructive transition-[max-height,margin,padding,opacity] [transition-duration:220ms] ease-out motion-reduce:transition-none",
voiceErrorFading && "mb-0 max-h-0 border-transparent py-0 opacity-0",
)}
>
@@ -2596,11 +2747,14 @@ function ComposerCliMentionOverlay({
segments,
isHero,
className,
ghostRange,
}: {
segments: CapabilityMentionSegment[];
isHero: boolean;
className: string;
ghostRange?: { start: number; end: number } | null;
}) {
let offset = 0;
return (
<div
aria-hidden
@@ -2610,16 +2764,24 @@ function ComposerCliMentionOverlay({
)}
>
{segments.map((segment, index) => {
const start = offset;
offset += segment.text.length;
if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>;
}
const isGhost = ghostRange?.start === start && ghostRange.end === offset;
return (
<CapabilityMentionToken
<span
key={`${segment.kind}-${index}`}
data-testid={isGhost ? "composer-session-drag-preview" : undefined}
className={cn(isGhost && "opacity-45 transition-opacity duration-100")}
>
<CapabilityMentionToken
segment={segment}
variant="composer"
isHero={isHero}
/>
</span>
);
})}
</div>
+1 -1
View File
@@ -1455,7 +1455,7 @@ export function ThreadShell({
{t("thread.loadingConversation")}
</div>
) : (
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 [animation-duration:220ms] motion-reduce:animate-none">
<HeroGreeting text={t(heroGreetingKey)} />
</div>
);
+18 -1
View File
@@ -97,6 +97,11 @@ function isKeyboardEditableElement(element: Element | null): element is HTMLElem
].includes(element.type);
}
function isThreadDisclosureTarget(target: EventTarget | null): boolean {
return target instanceof Element
&& target.closest("[data-thread-disclosure]") !== null;
}
type ThreadScrollDirection = "backward" | "forward";
const KEYBOARD_SCROLL_DIRECTIONS: Readonly<
@@ -572,7 +577,12 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
handleDirectionalInput(directionFromDelta(event.deltaY));
};
const handlePointerDown = (event: PointerEvent) => {
if (event.button === 0 && event.target === el) yieldCameraToUser();
if (
event.button === 0
&& (event.target === el || isThreadDisclosureTarget(event.target))
) {
yieldCameraToUser();
}
};
let lastTouchY: number | null = null;
const handleTouchStart = (event: TouchEvent) => {
@@ -600,6 +610,13 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
) {
return;
}
if (
(event.key === "Enter" || event.key === " ")
&& isThreadDisclosureTarget(event.target)
) {
yieldCameraToUser();
return;
}
handleDirectionalInput(keyboardScrollDirection(event));
};
el.addEventListener("scroll", handleScroll, { passive: true });
@@ -41,7 +41,7 @@ function ReasoningMarker({ streaming }: { streaming: boolean }) {
useEffect(() => {
if (wasStreamingRef.current && !streaming) {
setJustCompleted(true);
const timeout = window.setTimeout(() => setJustCompleted(false), 650);
const timeout = window.setTimeout(() => setJustCompleted(false), 300);
wasStreamingRef.current = streaming;
return () => window.clearTimeout(timeout);
}
@@ -35,6 +35,7 @@ export function ThinkingReasoningShell({
>
<button
type="button"
data-thread-disclosure=""
className="group inline-flex min-h-5 items-center self-start gap-1.5 bg-transparent p-0"
onClick={onToggle}
aria-expanded={expanded}
@@ -51,8 +52,8 @@ export function ThinkingReasoningShell({
</span>
<span
className={cn(
"inline-flex shrink-0 transition-transform [transition-duration:600ms] ease-out",
"motion-reduce:[transition-duration:220ms]",
"inline-flex shrink-0 transition-transform [transition-duration:220ms] ease-out",
"motion-reduce:transition-none",
expanded && "rotate-180",
)}
>
@@ -69,26 +70,40 @@ export function ThinkingReasoningShell({
<div
className={cn(
"grid transition-[grid-template-rows,opacity] [transition-duration:600ms] ease-out motion-reduce:[transition-duration:220ms]",
"grid transition-[grid-template-rows,opacity] [transition-duration:220ms] ease-out motion-reduce:transition-none",
expanded
? "grid-rows-[1fr] opacity-100"
: "pointer-events-none grid-rows-[0fr] opacity-0",
)}
>
<div className="min-h-0 overflow-hidden">
<div className="relative min-h-0 overflow-hidden">
<div
ref={viewportRef}
data-testid={expanded ? "agent-activity-scroll" : undefined}
data-fade-top={fadeTop}
data-fade-bottom={fadeBottom}
onScroll={onScroll}
className="activity-scroll-fade mt-1.5 max-h-[180px] overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
className="mt-1.5 max-h-[180px] overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
aria-hidden={!expanded}
>
<div ref={contentRef} className="flex flex-col gap-0.5">
{children}
</div>
</div>
{fadeTop ? (
<span
data-testid="activity-scroll-fade-top"
className="pointer-events-none absolute inset-x-0 top-1.5 z-10 h-3.5 bg-gradient-to-b from-background to-transparent"
aria-hidden
/>
) : null}
{fadeBottom ? (
<span
data-testid="activity-scroll-fade-bottom"
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-3.5 bg-gradient-to-t from-background to-transparent"
aria-hidden
/>
) : null}
</div>
</div>
</div>
+7 -36
View File
@@ -142,7 +142,7 @@
pointer-events: none;
background-color: rgb(236 141 49);
opacity: 0;
transition: opacity 600ms ease-in-out;
transition: opacity 220ms ease-out;
}
.composer-model-badge[data-fallback="true"]::before {
@@ -352,8 +352,7 @@
*/
.thread-layout {
grid-template-rows: minmax(min-content, 1fr) auto 0fr;
transition:
grid-template-rows 900ms cubic-bezier(0.33, 1, 0.68, 1);
transition: grid-template-rows 220ms ease-out;
}
.thread-layout[data-layout="thread"] {
grid-template-rows: minmax(0, 1fr) auto 0fr;
@@ -404,7 +403,7 @@
animation: composer-status-strip-exit 180ms ease-in both;
}
.composer-status-drawer {
--composer-status-drawer-duration: 600ms;
--composer-status-drawer-duration: 220ms;
--composer-status-drawer-easing: ease-out;
display: grid;
@@ -501,14 +500,14 @@
animation: none;
}
.composer-status-drawer {
transition-duration: 220ms;
transition-duration: 0.01ms;
}
.composer-status-drawer-content {
transform: translateY(2px);
transition-duration: 140ms, 220ms;
transform: none;
transition-duration: 120ms, 0.01ms;
}
.thread-layout {
transition-duration: 360ms;
transition-duration: 0.01ms;
}
.run-pulse-icon,
.run-pulse-icon * {
@@ -682,34 +681,6 @@
container-type: inline-size;
}
/* Soften only the activity edges that have clipped content beyond them. */
.activity-scroll-fade[data-fade-top="true"][data-fade-bottom="false"] {
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 14px);
mask-image: linear-gradient(to bottom, transparent, #000 14px);
}
.activity-scroll-fade[data-fade-top="false"][data-fade-bottom="true"] {
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 14px), transparent);
mask-image: linear-gradient(to bottom, #000 calc(100% - 14px), transparent);
}
.activity-scroll-fade[data-fade-top="true"][data-fade-bottom="true"] {
-webkit-mask-image: linear-gradient(
to bottom,
transparent,
#000 14px,
#000 calc(100% - 14px),
transparent
);
mask-image: linear-gradient(
to bottom,
transparent,
#000 14px,
#000 calc(100% - 14px),
transparent
);
}
@supports (content-visibility: auto) {
.thread-render-unit {
content-visibility: auto;
+8 -21
View File
@@ -1,16 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useClient } from "@/providers/ClientProvider";
import {
fetchSidebarState,
updateSidebarState as persistSidebarState,
} from "@/lib/api";
import { fetchSidebarState } from "@/lib/api";
import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
schema_version: 1,
pinned_keys: [],
archived_keys: [],
session_order: [],
title_overrides: {},
project_name_overrides: {},
tags_by_key: {},
@@ -83,13 +81,14 @@ export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
? value.view
: DEFAULT_SIDEBAR_STATE.view;
const density = view.density === "compact" ? "compact" : "comfortable";
const sort = ["updated_desc", "created_desc", "title_asc"].includes(view.sort)
const sort = ["updated_desc", "created_desc", "title_asc", "manual"].includes(view.sort)
? view.sort
: "updated_desc";
return {
schema_version: 1,
pinned_keys: uniqueStrings(value.pinned_keys),
archived_keys: uniqueStrings(value.archived_keys),
session_order: uniqueStrings(value.session_order),
title_overrides: stringMap(value.title_overrides),
project_name_overrides: stringMap(value.project_name_overrides),
tags_by_key: tagsMap(value.tags_by_key),
@@ -122,6 +121,7 @@ function pruneMissingSessions(
...state,
pinned_keys: filterKeys(state.pinned_keys),
archived_keys: filterKeys(state.archived_keys),
session_order: filterKeys(state.session_order),
title_overrides: filterMap(state.title_overrides),
tags_by_key: filterMap(state.tags_by_key),
};
@@ -141,10 +141,9 @@ export function useSidebarState(
updater: (state: SidebarStatePayload) => SidebarStatePayload,
) => Promise<void>;
} {
const { token } = useClient();
const { client, token } = useClient();
const tokenRef = useRef(token);
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
const persistVersionRef = useRef(0);
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
const [loading, setLoading] = useState(true);
tokenRef.current = token;
@@ -175,23 +174,11 @@ export function useSidebarState(
const update = useCallback(
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
const next = normalizeSidebarState(updater(stateRef.current));
const version = persistVersionRef.current + 1;
persistVersionRef.current = version;
stateRef.current = next;
setState(next);
try {
const persisted = normalizeSidebarState(
await persistSidebarState(tokenRef.current, next),
);
if (persistVersionRef.current !== version) return;
stateRef.current = persisted;
setState(persisted);
} catch {
// Keep the optimistic UI state. Older gateways or transient auth expiry
// should not break the chat list; the next refresh can try again.
}
client.setSidebarState(next);
},
[],
[client],
);
const pruned = useMemo(() => {
+4
View File
@@ -627,9 +627,13 @@ export async function pollChannelConnect(
channel: string,
sessionId: string,
base: string = "",
params: Readonly<Record<string, string>> = {},
): Promise<ChannelConnectPayload> {
const query = new URLSearchParams();
query.set("session_id", sessionId);
Object.entries(params).forEach(([key, value]) => {
if (key !== "session_id") query.set(key, value);
});
return request<ChannelConnectPayload>(
`${base}/api/settings/channels/${channel}/connect/poll?${query}`,
token,
+21 -3
View File
@@ -30,6 +30,7 @@ export interface ChatGroupingOptions {
archivedKeys: string[];
titleOverrides: Record<string, string>;
projectNameOverrides: Record<string, string>;
sessionOrder: string[];
showArchived: boolean;
sort: SidebarSortMode;
defaultWorkspacePath?: string | null;
@@ -64,7 +65,7 @@ export function groupSessions(
pinnedSessions.push(session);
continue;
}
if (options.sort === "title_asc") {
if (options.sort === "title_asc" || options.sort === "manual") {
normalSessions.push(session);
continue;
}
@@ -87,11 +88,12 @@ export function groupSessions(
buckets.get(label) ?? [],
options.sort,
options.titleOverrides,
options.sessionOrder,
),
}))
.filter((group) => group.sessions.length > 0);
if (options.sort === "title_asc" && normalSessions.length) {
if ((options.sort === "title_asc" || options.sort === "manual") && normalSessions.length) {
groups.push({
id: "date:all",
label: labels.all,
@@ -99,6 +101,7 @@ export function groupSessions(
normalSessions,
options.sort,
options.titleOverrides,
options.sessionOrder,
),
});
}
@@ -110,6 +113,7 @@ export function groupSessions(
pinnedSessions,
options.sort,
options.titleOverrides,
options.sessionOrder,
),
});
}
@@ -121,6 +125,7 @@ export function groupSessions(
archivedSessions,
options.sort,
options.titleOverrides,
options.sessionOrder,
),
});
}
@@ -276,6 +281,7 @@ function groupSessionsByProject(
bucket.sessions,
options.sort,
options.titleOverrides,
options.sessionOrder,
pinned,
archived,
),
@@ -297,6 +303,7 @@ function groupSessionsByProject(
conversations,
options.sort,
options.titleOverrides,
options.sessionOrder,
pinned,
archived,
),
@@ -319,10 +326,11 @@ function sortProjectSessions(
sessions: ChatSummary[],
sort: SidebarSortMode,
titleOverrides: Record<string, string>,
sessionOrder: string[],
pinned: Set<string>,
archived: Set<string>,
): ChatSummary[] {
return sortSessions(sessions, sort, titleOverrides).sort((a, b) => {
return sortSessions(sessions, sort, titleOverrides, sessionOrder).sort((a, b) => {
const pinOrder = Number(pinned.has(b.key)) - Number(pinned.has(a.key));
if (pinOrder !== 0) return pinOrder;
const archiveOrder = Number(archived.has(a.key)) - Number(archived.has(b.key));
@@ -335,9 +343,19 @@ function sortSessions(
sessions: ChatSummary[],
sort: SidebarSortMode,
titleOverrides: Record<string, string>,
sessionOrder: string[],
): ChatSummary[] {
const copy = [...sessions];
const order = new Map(sessionOrder.map((key, index) => [key, index]));
copy.sort((a, b) => {
if (sort === "manual") {
const aIndex = order.get(a.key);
const bIndex = order.get(b.key);
if (aIndex !== undefined && bIndex !== undefined) return aIndex - bIndex;
if (aIndex === undefined && bIndex !== undefined) return -1;
if (aIndex !== undefined && bIndex === undefined) return 1;
return sessionTime(b, "updatedAt") - sessionTime(a, "updatedAt");
}
if (sort === "title_asc") {
const titleOrder = titleForSort(a, titleOverrides).localeCompare(
titleForSort(b, titleOverrides),
+5
View File
@@ -6,6 +6,7 @@ import type {
OutboundMcpPresetMention,
OutboundMedia,
SessionMention,
SidebarStatePayload,
GoalStateWsPayload,
WorkspaceScopePayload,
} from "./types";
@@ -870,6 +871,10 @@ export class NanobotClient {
});
}
setSidebarState(state: SidebarStatePayload): void {
this.queueSend({ type: "set_sidebar_state", state });
}
// -- internals ---------------------------------------------------------
private setStatus(status: ConnectionStatus): void {
+25
View File
@@ -0,0 +1,25 @@
export const SESSION_DRAG_TYPE = "application/x-nanobot-session-key";
let activeSessionKey: string | null = null;
export function hasDraggedSession(dataTransfer: DataTransfer): boolean {
return Array.from(dataTransfer.types).includes(SESSION_DRAG_TYPE);
}
export function readDraggedSession(dataTransfer: DataTransfer): string | null {
const sessionKey = dataTransfer.getData(SESSION_DRAG_TYPE).trim();
return sessionKey || activeSessionKey;
}
export function clearDraggedSession(): void {
activeSessionKey = null;
}
export function writeDraggedSession(
dataTransfer: DataTransfer,
sessionKey: string,
): void {
activeSessionKey = sessionKey;
dataTransfer.effectAllowed = "copyMove";
dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey);
}
+3 -1
View File
@@ -367,7 +367,7 @@ export interface WorkspacesPayload {
}
export type SidebarDensity = "comfortable" | "compact";
export type SidebarSortMode = "updated_desc" | "created_desc" | "title_asc";
export type SidebarSortMode = "updated_desc" | "created_desc" | "title_asc" | "manual";
export interface SidebarViewState {
density: SidebarDensity;
@@ -381,6 +381,7 @@ export interface SidebarStatePayload {
schema_version: number;
pinned_keys: string[];
archived_keys: string[];
session_order: string[];
title_overrides: Record<string, string>;
project_name_overrides: Record<string, string>;
tags_by_key: Record<string, string[]>;
@@ -1339,6 +1340,7 @@ export type Outbound =
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
| { type: "attach"; chat_id: string }
| { type: "set_sidebar_state"; state: SidebarStatePayload }
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
| { type: "transcribe_audio"; request_id: string; data_url: string; duration_ms?: number }
| {
@@ -288,16 +288,24 @@ describe("AgentActivityCluster", () => {
});
expect(scrollport).toHaveAttribute("data-fade-top", "true");
expect(scrollport).toHaveAttribute("data-fade-bottom", "false");
const topFade = screen.getByTestId("activity-scroll-fade-top");
expect(scrollport).not.toContainElement(topFade);
expect(scrollport).not.toHaveClass("activity-scroll-fade");
expect(screen.queryByTestId("activity-scroll-fade-bottom")).not.toBeInTheDocument();
scrollport.scrollTop = 440;
fireEvent.scroll(scrollport);
expect(scrollport).toHaveAttribute("data-fade-top", "true");
expect(scrollport).toHaveAttribute("data-fade-bottom", "true");
expect(screen.getByTestId("activity-scroll-fade-top")).toBeInTheDocument();
expect(screen.getByTestId("activity-scroll-fade-bottom")).toBeInTheDocument();
scrollport.scrollTop = 0;
fireEvent.scroll(scrollport);
expect(scrollport).toHaveAttribute("data-fade-top", "false");
expect(scrollport).toHaveAttribute("data-fade-bottom", "true");
expect(screen.queryByTestId("activity-scroll-fade-top")).not.toBeInTheDocument();
expect(screen.getByTestId("activity-scroll-fade-bottom")).toBeInTheDocument();
setScrollGeometry(scrollport, {
scrollHeight: 100,
@@ -307,6 +315,8 @@ describe("AgentActivityCluster", () => {
fireEvent.scroll(scrollport);
expect(scrollport).toHaveAttribute("data-fade-top", "false");
expect(scrollport).toHaveAttribute("data-fade-bottom", "false");
expect(screen.queryByTestId("activity-scroll-fade-top")).not.toBeInTheDocument();
expect(screen.queryByTestId("activity-scroll-fade-bottom")).not.toBeInTheDocument();
} finally {
raf.restore();
}
@@ -385,7 +395,7 @@ describe("AgentActivityCluster", () => {
expect(screen.getByTestId("agent-activity-scroll")).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(901);
vi.advanceTimersByTime(301);
});
expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Thought" })).toHaveAttribute(
@@ -413,12 +423,13 @@ describe("AgentActivityCluster", () => {
);
const button = screen.getByRole("button", { name: "Thought" });
expect(button).toHaveAttribute("data-thread-disclosure");
const chevron = button.querySelector("svg");
expect(chevron).toBeInTheDocument();
expect(chevron).toHaveClass("transition-colors", "duration-200");
expect(chevron?.parentElement).toHaveClass(
"transition-transform",
"[transition-duration:600ms]",
"[transition-duration:220ms]",
);
});
+1
View File
@@ -964,6 +964,7 @@ describe("webui API helpers", () => {
schema_version: 1,
pinned_keys: ["websocket:chat-1"],
archived_keys: ["websocket:old"],
session_order: ["websocket:chat-1", "websocket:old"],
title_overrides: { "websocket:chat-1": "Release" },
project_name_overrides: { "/Users/me/nanobot": "Core" },
tags_by_key: {},
+25 -13
View File
@@ -13,6 +13,7 @@ const getSessionAutomationsSpy = vi.fn<(key: string) => Promise<SessionAutomatio
const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const setSidebarStateSpy = vi.fn();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
let mockSessions: ChatSummary[] = [];
@@ -218,6 +219,7 @@ vi.mock("@/lib/nanobot-client", () => {
sendMessage = vi.fn();
newChat = vi.fn();
attach = attachSpy;
setSidebarState = setSidebarStateSpy;
close = vi.fn();
updateUrl = updateUrlSpy;
updateMaxFrameBytes = vi.fn();
@@ -245,6 +247,7 @@ describe("App layout", () => {
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
toggleThemeSpy.mockReset();
attachSpy.mockReset();
setSidebarStateSpy.mockReset();
runStatusHandlers.clear();
sessionUpdateHandlers.clear();
window.history.replaceState(null, "", "/");
@@ -364,6 +367,23 @@ describe("App layout", () => {
);
});
it("keeps a just-created topic route while the session list catches up", async () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
target: { value: "/model" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(createChatSpy).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(window.location.hash).toBe(
`#/chat/${encodeURIComponent("websocket:chat-1")}`,
),
);
});
it("restores the Settings route after a restart fallback hash", async () => {
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
@@ -1403,13 +1423,6 @@ describe("App layout", () => {
if (href === "/api/webui/sidebar-state") {
return { ok: true, json: async () => initialState };
}
if (href.startsWith("/api/webui/sidebar-state/update?")) {
const encoded = new URLSearchParams(href.split("?", 2)[1]).get("state");
return {
ok: true,
json: async () => JSON.parse(encoded ?? "{}"),
};
}
return { ok: false, status: 404 };
}),
);
@@ -1429,12 +1442,11 @@ describe("App layout", () => {
expect(within(sidebar).getByText("Archived")).toBeInTheDocument(),
);
expect(within(sidebar).getByRole("button", { name: /^First chat$/ })).toBeInTheDocument();
const updateUrl = vi.mocked(fetch).mock.calls
.map(([url]) => String(url))
.find((url) => url.startsWith("/api/webui/sidebar-state/update?"));
expect(updateUrl).toBeTruthy();
const encoded = new URLSearchParams(updateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(encoded ?? "{}").view.show_archived).toBe(true);
expect(setSidebarStateSpy).toHaveBeenCalledWith(
expect.objectContaining({
view: expect.objectContaining({ show_archived: true }),
}),
);
expect(within(sidebar).queryByRole("button", { name: "View" })).not.toBeInTheDocument();
});
+105
View File
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ChatList } from "@/components/ChatList";
import { SESSION_DRAG_TYPE } from "@/lib/session-drag";
import type { ChatSummary } from "@/lib/types";
function session(overrides: Partial<ChatSummary>): ChatSummary {
@@ -47,6 +48,110 @@ describe("ChatList", () => {
vi.unstubAllGlobals();
});
it("exposes chats as drag sources", () => {
const dataTransfer = {
effectAllowed: "",
setData: vi.fn(),
};
render(
<ChatList
sessions={[
session({ chatId: "active", title: "Active chat" }),
session({ chatId: "reference", title: "Reference chat" }),
]}
activeKey="websocket:active"
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "Active chat" }))
.toHaveAttribute("draggable", "true");
const reference = screen.getByRole("button", { name: "Reference chat" });
expect(reference).toHaveAttribute("draggable", "true");
fireEvent.dragStart(reference, { dataTransfer });
expect(dataTransfer.setData).toHaveBeenCalledWith(
SESSION_DRAG_TYPE,
"websocket:reference",
);
fireEvent.dragEnd(reference, { dataTransfer });
});
it("reorders chats around a Codex-style insertion line", () => {
const onReorderSessions = vi.fn();
const sessions = [
session({ chatId: "alpha", title: "Alpha" }),
session({ chatId: "bravo", title: "Bravo" }),
session({ chatId: "charlie", title: "Charlie" }),
session({ chatId: "old-a", title: "Old A" }),
session({ chatId: "old-b", title: "Old B" }),
];
const { rerender } = render(
<ChatList
sessions={sessions}
activeKey={null}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
onReorderSessions={onReorderSessions}
archivedKeys={["websocket:old-a", "websocket:old-b"]}
sessionOrder={sessions.map((item) => item.key)}
/>,
);
const dataTransfer = {
effectAllowed: "",
dropEffect: "",
setData: vi.fn(),
};
fireEvent.dragStart(screen.getByRole("button", { name: "Alpha" }), { dataTransfer });
const charlieRow = screen.getByRole("button", { name: "Charlie" }).closest("li")!;
fireEvent.dragOver(charlieRow, { clientY: 1, dataTransfer });
expect(charlieRow.querySelector("[data-session-drop-edge='after']"))
.toBeInTheDocument();
fireEvent.drop(charlieRow, { clientY: 1, dataTransfer });
expect(onReorderSessions).toHaveBeenCalledWith([
"websocket:bravo",
"websocket:charlie",
"websocket:alpha",
"websocket:old-a",
"websocket:old-b",
]);
rerender(
<ChatList
sessions={sessions}
activeKey={null}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
onReorderSessions={onReorderSessions}
archivedKeys={["websocket:old-a", "websocket:old-b"]}
sessionOrder={[
"websocket:bravo",
"websocket:charlie",
"websocket:alpha",
"websocket:old-a",
"websocket:old-b",
]}
sort="manual"
/>,
);
const section = screen.getByRole("region", { name: "Topics" });
const text = section.textContent ?? "";
expect(text.indexOf("Bravo")).toBeLessThan(text.indexOf("Charlie"));
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha"));
});
it("orders chats by latest session activity by default", () => {
const sessions = [
session({
+39
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NanobotClient } from "@/lib/nanobot-client";
import type { SidebarStatePayload } from "@/lib/types";
/**
* Minimal fake WebSocket implementing the subset NanobotClient touches.
@@ -935,6 +936,44 @@ describe("NanobotClient", () => {
expect(client.hasUnsettledRun("chat-scope-control")).toBe(true);
});
it("sends large sidebar ordering state outside the HTTP request line", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const sessionOrder = Array.from(
{ length: 160 },
(_, index) => `websocket:${index.toString().padStart(4, "0")}-${"x".repeat(48)}`,
);
const state: SidebarStatePayload = {
schema_version: 1,
pinned_keys: [],
archived_keys: [],
session_order: sessionOrder,
title_overrides: {},
project_name_overrides: {},
tags_by_key: {},
collapsed_groups: {},
view: {
density: "comfortable",
show_previews: false,
show_timestamps: false,
show_archived: false,
sort: "manual",
},
updated_at: null,
};
client.connect();
lastSocket().fakeOpen();
client.setSidebarState(state);
const [serialized] = lastSocket().sent;
expect(new TextEncoder().encode(serialized).byteLength).toBeGreaterThan(8_192);
expect(JSON.parse(serialized)).toEqual({ type: "set_sidebar_state", state });
});
it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
const client = new NanobotClient({
url: "ws://test",
+62 -1
View File
@@ -2137,6 +2137,57 @@ describe("SettingsView Apps catalog", () => {
expect(reasoningEffort).toHaveValue("provider-native-mode");
});
it("expands the model preset editor directly below the selected row", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "models" });
const row = await screen.findByTestId("model-call-order-row-primary");
const trigger = within(row).getAllByRole("button")[0];
expect(screen.queryByTestId("model-preset-editor")).not.toBeInTheDocument();
expect(trigger).toHaveAttribute("aria-expanded", "false");
fireEvent.click(trigger);
const editor = screen.getByTestId("model-preset-editor");
expect(trigger).toHaveAttribute("aria-pressed", "true");
expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(trigger).toHaveAttribute("aria-controls", "model-preset-editor");
expect(row.parentElement).toHaveAttribute("role", "listitem");
expect(row.parentElement?.parentElement).toHaveAttribute("role", "list");
expect(row.nextElementSibling).toBe(editor);
expect(editor).toHaveClass(
"slide-in-from-top-1",
"lg:max-w-6xl",
"rounded-[18px]",
);
expect(within(editor).getByDisplayValue("Primary")).toBeInTheDocument();
const deleteButton = within(editor).getByRole("button", { name: "Delete" });
expect(deleteButton).toBeDisabled();
expect(deleteButton).toHaveAttribute("aria-describedby", "model-preset-delete-hint");
expect(
within(editor).getByText("Remove this preset from the call order before deleting it."),
).toBeInTheDocument();
fireEvent.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByTestId("model-preset-editor")).not.toBeInTheDocument();
});
it("drags model presets to reorder and saves the model call order immediately", async () => {
const { payload, backupPreset } = settingsPayloadWithBackup();
const updatedPayload: SettingsPayload = {
@@ -2268,7 +2319,17 @@ describe("SettingsView Apps catalog", () => {
renderSettingsView({ initialSection: "models", initialSettings: initialPayload });
const primaryRow = screen.getByTestId("model-call-order-row-primary");
const firstBackupRow = screen.getAllByTestId("model-call-order-row-backup")[0];
const backupRows = screen.getAllByTestId("model-call-order-row-backup");
const firstBackupRow = backupRows[0];
const secondBackupRow = backupRows[1];
const secondBackupTrigger = within(secondBackupRow).getAllByRole("button")[0];
fireEvent.click(secondBackupTrigger);
expect(screen.getAllByTestId("model-preset-editor")).toHaveLength(1);
expect(secondBackupRow.nextElementSibling).toBe(
screen.getByTestId("model-preset-editor"),
);
fireEvent.click(secondBackupTrigger);
const dataTransfer = {
dropEffect: "move",
effectAllowed: "move",
+84 -2
View File
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { SESSION_DRAG_TYPE } from "@/lib/session-drag";
import type { ChatSummary, CliAppInfo, McpPresetInfo, SlashCommand } from "@/lib/types";
vi.mock("@/lib/imageEncode", () => ({
@@ -1533,7 +1534,10 @@ describe("ThreadComposer", () => {
fireEvent.keyDown(input, { key: "Tab" });
expect(input).toHaveValue("use @browserbase ");
expect(screen.getByTestId("composer-mcp-mention-browserbase")).toHaveTextContent("@browserbase");
const mention = screen.getByTestId("composer-mcp-mention-browserbase");
expect(mention).toHaveTextContent("@browserbase");
expect(mention).toHaveClass("font-normal");
expect(mention).not.toHaveClass("font-[550]");
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
@@ -1580,6 +1584,8 @@ describe("ThreadComposer", () => {
expect(input).toHaveValue("参考 @收费设计 ");
const mention = screen.getByTestId("composer-session-mention-收费设计");
expect(mention).toHaveTextContent("@收费设计");
expect(mention).toHaveClass("font-normal");
expect(mention).not.toHaveClass("font-[550]");
expect(mention.closest("a")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
@@ -1593,6 +1599,81 @@ describe("ThreadComposer", () => {
});
});
it("turns a dropped sidebar session into the shared structured mention", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
sessions={[session("pricing", "收费设计", "讨论云存储")]}
/>,
);
const input = screen.getByLabelText("Message input") as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: "Compare notes" } });
input.setSelectionRange(7, 7);
const dataTransfer = {
types: [SESSION_DRAG_TYPE],
effectAllowed: "copy",
dropEffect: "none",
files: [],
getData: (type: string) => (
type === SESSION_DRAG_TYPE ? "websocket:pricing" : ""
),
};
fireEvent.dragEnter(input, { dataTransfer });
fireEvent.dragOver(input, { dataTransfer });
expect(input).toHaveValue("Compare notes");
expect(screen.getByTestId("composer-session-drag-preview"))
.toHaveTextContent("@收费设计");
fireEvent.dragEnd(document);
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
fireEvent.dragEnter(input, { dataTransfer });
fireEvent.dragOver(input, { dataTransfer });
fireEvent.drop(input, { dataTransfer });
expect(input).toHaveValue("Compare @收费设计 notes");
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
expect(screen.getByTestId("composer-session-mention-收费设计"))
.toHaveTextContent("@收费设计");
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith("Compare @收费设计 notes", undefined, {
sessionMentions: [{
name: "收费设计",
session_key: "websocket:pricing",
title: "收费设计",
}],
});
});
it("rejects session drops that are unavailable to the composer", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
sessions={[]}
/>,
);
const input = screen.getByLabelText("Message input");
const dataTransfer = {
types: [SESSION_DRAG_TYPE],
effectAllowed: "copyMove",
dropEffect: "copy",
files: [],
getData: () => "websocket:current",
};
expect(fireEvent.dragEnter(input, { dataTransfer })).toBe(true);
expect(fireEvent.dragOver(input, { dataTransfer })).toBe(true);
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
});
it("disambiguates duplicate and capability-colliding session names", () => {
render(
<ThreadComposer
@@ -1890,7 +1971,8 @@ describe("ThreadComposer", () => {
expect(input).toHaveValue("meeting in @gimp");
const token = screen.getByTestId("composer-cli-mention-gimp");
expect(token).toHaveTextContent("@gimp");
expect(token).toHaveClass("font-[550]");
expect(token).toHaveClass("font-normal");
expect(token).not.toHaveClass("font-[550]");
expect(token.className).not.toContain("zoom-in");
expect(token.className).not.toContain("px-");
expect(token.className).not.toContain("mx-");
+28
View File
@@ -215,6 +215,34 @@ function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
}
describe("ThreadViewport", () => {
it("keeps reasoning disclosure anchored for pointer and keyboard toggles", () => {
const takeUserControl = vi.spyOn(
ThreadMotionCoordinator.prototype,
"takeUserControl",
);
render(
<ThreadViewport
messages={[{
id: "reasoning-1",
role: "assistant",
content: "",
reasoning: "A completed thought",
createdAt: 1,
}]}
isStreaming={false}
composer={<div>composer</div>}
/>,
);
const disclosure = screen.getByRole("button", { name: "Thought" });
fireEvent.pointerDown(disclosure, { button: 0 });
expect(takeUserControl).toHaveBeenCalledTimes(1);
takeUserControl.mockClear();
fireEvent.keyDown(disclosure, { key: "Enter" });
expect(takeUserControl).toHaveBeenCalledTimes(1);
});
it("top-aligns short threads in the message rendering area", () => {
render(
<ThreadViewport