mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +03:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da8ffcd883 | ||
|
|
93fd48d327 | ||
|
|
2663bae186 |
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -140,3 +140,10 @@ class AutomationTurnCoordinator:
|
||||
if pending_id:
|
||||
pending_ids.add(pending_id)
|
||||
return pending_ids
|
||||
|
||||
async def publish_next_deferred(self, session_key: str) -> bool:
|
||||
return await publish_next_deferred_turn(
|
||||
deferred_queues=self.deferred_queues,
|
||||
publish_inbound=self._publish_inbound,
|
||||
session_key=session_key,
|
||||
)
|
||||
|
||||
@@ -13,11 +13,7 @@ from nanobot.agent.tools import mcp as mcp_tools
|
||||
from nanobot.agent.tools import sessions as session_tools
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.apps.cli import utils as cli_app_utils
|
||||
from nanobot.bus.events import (
|
||||
INBOUND_META_RUNTIME_CONTROL,
|
||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
||||
InboundMessage,
|
||||
)
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.runtime_context import (
|
||||
RUNTIME_CONTEXT_END,
|
||||
RUNTIME_CONTEXT_MESSAGE_META,
|
||||
@@ -51,9 +47,6 @@ async def close_mcp(state: Any) -> None:
|
||||
|
||||
|
||||
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
|
||||
if msg.metadata.get(INBOUND_META_RUNTIME_CONTROL) == RUNTIME_CONTROL_SESSION_DISCARD:
|
||||
await state.discard_session(msg.session_key)
|
||||
return True
|
||||
for handler in (
|
||||
image_generation_tools.handle_runtime_control,
|
||||
mcp_tools.handle_runtime_control,
|
||||
@@ -86,7 +79,6 @@ class ContextBuilder:
|
||||
channel: str | None = None,
|
||||
session_summary: str | None = None,
|
||||
workspace: Path | None = None,
|
||||
include_memory: bool = True,
|
||||
include_memory_recent_history: bool = True,
|
||||
session_key: str | None = None,
|
||||
unified_session: bool = False,
|
||||
@@ -101,10 +93,9 @@ class ContextBuilder:
|
||||
|
||||
parts.append(render_template("agent/tool_contract.md"))
|
||||
|
||||
if include_memory:
|
||||
memory = self.memory.read_memory()
|
||||
if memory and not self._is_template_content(memory, "memory/MEMORY.md"):
|
||||
parts.append(f"# Memory\n\n## Long-term Memory\n{memory}")
|
||||
memory = self.memory.read_memory()
|
||||
if memory and not self._is_template_content(memory, "memory/MEMORY.md"):
|
||||
parts.append(f"# Memory\n\n## Long-term Memory\n{memory}")
|
||||
|
||||
active_skills = self.skills.get_always_skills()
|
||||
active_skills.extend(
|
||||
@@ -228,7 +219,6 @@ class ContextBuilder:
|
||||
session_summary: str | None = None,
|
||||
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
|
||||
workspace: Path | None = None,
|
||||
include_memory: bool = True,
|
||||
include_memory_recent_history: bool = True,
|
||||
session_key: str | None = None,
|
||||
unified_session: bool = False,
|
||||
@@ -248,7 +238,6 @@ class ContextBuilder:
|
||||
channel=channel,
|
||||
session_summary=session_summary,
|
||||
workspace=root,
|
||||
include_memory=include_memory,
|
||||
include_memory_recent_history=include_memory_recent_history,
|
||||
session_key=session_key,
|
||||
unified_session=unified_session,
|
||||
|
||||
+12
-52
@@ -398,7 +398,6 @@ class AgentLoop:
|
||||
self._mcp_connecting = False
|
||||
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
||||
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
|
||||
self._discarding_sessions: set[str] = set()
|
||||
self._background_tasks: set[asyncio.Task[Any]] = set()
|
||||
self._close_mcp_lock = asyncio.Lock()
|
||||
self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
||||
@@ -722,7 +721,6 @@ class AgentLoop:
|
||||
session_summary=ctx.pending_summary,
|
||||
workspace=scope.project_path,
|
||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||
include_memory=ctx.session.policy.persist,
|
||||
include_memory_recent_history=not ctx.ephemeral,
|
||||
session_key=ctx.session.key,
|
||||
unified_session=self._unified_session,
|
||||
@@ -788,9 +786,9 @@ class AgentLoop:
|
||||
logger.warning("Command '{}' matched but dispatch returned None", raw)
|
||||
|
||||
async def _cancel_active_tasks(self, key: str) -> int:
|
||||
"""Cancel and await all active work for *key*.
|
||||
"""Cancel and await all active tasks and subagents for *key*.
|
||||
|
||||
Returns the total number of cancelled tasks, subagents, and exec sessions.
|
||||
Returns the total number of cancelled tasks + subagents.
|
||||
"""
|
||||
tasks = tuple(self._active_tasks.pop(key, set()))
|
||||
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
||||
@@ -798,17 +796,7 @@ class AgentLoop:
|
||||
with suppress(asyncio.CancelledError, Exception):
|
||||
await t
|
||||
sub_cancelled = await self.subagents.cancel_by_session(key)
|
||||
exec_cancelled = await self._exec_session_manager.terminate_by_owner(key)
|
||||
return cancelled + sub_cancelled + exec_cancelled
|
||||
|
||||
async def discard_session(self, key: str) -> None:
|
||||
"""Stop active work for *key* and forget its cached session."""
|
||||
self._discarding_sessions.add(key)
|
||||
try:
|
||||
self.sessions.invalidate(key)
|
||||
await self._cancel_active_tasks(key)
|
||||
finally:
|
||||
self._discarding_sessions.discard(key)
|
||||
return cancelled + sub_cancelled
|
||||
|
||||
def _effective_session_key(self, msg: InboundMessage) -> str:
|
||||
"""Return the session key used for task routing and mid-turn injections."""
|
||||
@@ -1173,11 +1161,6 @@ class AgentLoop:
|
||||
effective_key = self._effective_session_key(msg)
|
||||
if await agent_context.handle_runtime_control(self, msg, self.tools):
|
||||
continue
|
||||
if (
|
||||
msg.require_existing_session
|
||||
and self.sessions.get_cached(effective_key) is None
|
||||
):
|
||||
continue
|
||||
if self.commands.is_priority(raw):
|
||||
await self._dispatch_command_inline(
|
||||
msg, effective_key, raw,
|
||||
@@ -1296,8 +1279,6 @@ class AgentLoop:
|
||||
# _emit_checkpoint during tool execution; materializing
|
||||
# it into session history now makes it visible in the
|
||||
# next conversation turn.
|
||||
if session_key in self._discarding_sessions:
|
||||
raise
|
||||
try:
|
||||
key = self._effective_session_key(msg)
|
||||
session = self.sessions.get_or_create(key)
|
||||
@@ -1575,7 +1556,6 @@ class AgentLoop:
|
||||
had_injections: bool,
|
||||
streamed_content: bool,
|
||||
*,
|
||||
log_content: bool = True,
|
||||
turn_latency_ms: int | None = None,
|
||||
) -> OutboundMessage | None:
|
||||
"""Assemble the final outbound message from turn results."""
|
||||
@@ -1584,11 +1564,8 @@ class AgentLoop:
|
||||
if not had_injections or stop_reason == "empty_final_response":
|
||||
return None
|
||||
|
||||
if log_content:
|
||||
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
||||
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||
else:
|
||||
logger.info("Response to {}:{}: [content hidden]", msg.channel, msg.sender_id)
|
||||
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
||||
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||
|
||||
event = None
|
||||
meta = dict(msg.metadata or {})
|
||||
@@ -1617,33 +1594,17 @@ class AgentLoop:
|
||||
ctx.msg = dataclasses.replace(msg, content=new_content, media=image_paths)
|
||||
msg = ctx.msg
|
||||
|
||||
if ctx.session is None:
|
||||
if msg.require_existing_session:
|
||||
ctx.session = self.sessions.get_cached(ctx.session_key)
|
||||
if ctx.session is None:
|
||||
raise RuntimeError("required session is not active")
|
||||
else:
|
||||
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
||||
session = ctx.session
|
||||
ctx.ephemeral = ctx.ephemeral or not session.policy.persist
|
||||
tools = ctx.tools or self.tools
|
||||
if session.policy.disabled_tools:
|
||||
restricted = ToolRegistry()
|
||||
for name in tools.tool_names:
|
||||
tool = tools.get(name)
|
||||
if name not in session.policy.disabled_tools and tool:
|
||||
restricted.register(tool)
|
||||
tools = restricted
|
||||
ctx.tools = tools
|
||||
|
||||
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
|
||||
if ctx.kind is TurnKind.SYSTEM:
|
||||
logger.info("Processing system message from {}", msg.sender_id)
|
||||
elif session.policy.log_content:
|
||||
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
|
||||
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||
else:
|
||||
logger.info("Processing message from {}:{}: [content hidden]", msg.channel, msg.sender_id)
|
||||
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||
|
||||
# Session is already fetched by the caller (_process_message) but
|
||||
# ensure it exists in case this handler is invoked independently.
|
||||
if ctx.session is None:
|
||||
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
||||
session = ctx.session
|
||||
self._remember_unified_session_route(
|
||||
session,
|
||||
msg,
|
||||
@@ -1946,7 +1907,6 @@ class AgentLoop:
|
||||
ctx.stop_reason,
|
||||
ctx.had_injections,
|
||||
ctx.streamed_content,
|
||||
log_content=ctx.require_session().policy.log_content,
|
||||
turn_latency_ms=ctx.turn_latency_ms,
|
||||
)
|
||||
if ctx.ephemeral and ctx.outbound is not None:
|
||||
|
||||
+28
-8
@@ -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:
|
||||
|
||||
@@ -785,6 +785,22 @@ def _best_window(old_text: str, content: str) -> tuple[float, int, list[str], li
|
||||
return best_ratio, best_start, best_window_lines, hints
|
||||
|
||||
|
||||
def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
|
||||
"""Locate old_text in content with a multi-level fallback chain:
|
||||
|
||||
1. Exact substring match
|
||||
2. Line-trimmed sliding window (handles indentation differences)
|
||||
3. Smart quote normalization (curly ↔ straight quotes)
|
||||
|
||||
Both inputs should use LF line endings (caller normalises CRLF).
|
||||
Returns (matched_fragment, count) or (None, 0).
|
||||
"""
|
||||
matches = _find_matches(content, old_text)
|
||||
if not matches:
|
||||
return None, 0
|
||||
return matches[0].text, len(matches)
|
||||
|
||||
|
||||
@tool_parameters(
|
||||
tool_parameters_schema(
|
||||
path=StringSchema("The file path to edit"),
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.security.workspace_policy import resolve_allowed_path
|
||||
from nanobot.security.workspace_policy import (
|
||||
is_path_within,
|
||||
resolve_allowed_path,
|
||||
)
|
||||
|
||||
|
||||
def is_under(path: Path, directory: Path) -> bool:
|
||||
"""Return True when path resolves under directory."""
|
||||
return is_path_within(path, directory)
|
||||
|
||||
|
||||
def resolve_workspace_path(
|
||||
|
||||
+75
-12
@@ -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()
|
||||
|
||||
@@ -12,6 +12,15 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
return {"cli_apps": cli_apps} if isinstance(cli_apps, list) and cli_apps else {}
|
||||
|
||||
|
||||
def runtime_lines(message: Any, workspace: Path, *, skip: bool = False) -> list[str]:
|
||||
"""Return model-visible CLI app annotations for the current turn."""
|
||||
if skip:
|
||||
return []
|
||||
text = message.content if isinstance(getattr(message, "content", None), str) else ""
|
||||
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
|
||||
return runtime_lines_for_request(text, metadata, workspace)
|
||||
|
||||
|
||||
def runtime_lines_for_request(
|
||||
text: str,
|
||||
metadata: Mapping[str, Any] | None,
|
||||
@@ -20,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
|
||||
@@ -32,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()
|
||||
|
||||
@@ -18,7 +18,6 @@ INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
|
||||
RUNTIME_CONTROL_ACK = "_ack"
|
||||
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
|
||||
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
|
||||
RUNTIME_CONTROL_SESSION_DISCARD = "session_discard"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -33,7 +32,6 @@ class InboundMessage:
|
||||
media: list[str] = field(default_factory=list) # Media URLs
|
||||
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
|
||||
session_key_override: str | None = None # Optional override for thread-scoped sessions
|
||||
require_existing_session: bool = False
|
||||
|
||||
@property
|
||||
def session_key(self) -> str:
|
||||
|
||||
@@ -262,7 +262,6 @@ class BaseChannel(ABC):
|
||||
session_key: str | None = None,
|
||||
is_dm: bool = False,
|
||||
authorization_id: str | None = None,
|
||||
require_existing_session: bool = False,
|
||||
) -> None:
|
||||
"""Handle a message after checking its authorization subject.
|
||||
|
||||
@@ -315,7 +314,6 @@ class BaseChannel(ABC):
|
||||
media=media or [],
|
||||
metadata=meta,
|
||||
session_key_override=session_key,
|
||||
require_existing_session=require_existing_session,
|
||||
)
|
||||
|
||||
await self.bus.publish_inbound(msg)
|
||||
|
||||
@@ -470,6 +470,15 @@ def _extract_post_content(content_json: dict[str, Any]) -> tuple[str, list[str]]
|
||||
return "", []
|
||||
|
||||
|
||||
def _extract_post_text(content_json: dict[str, Any]) -> str: # pyright: ignore[reportUnusedFunction]
|
||||
"""Extract plain text from Feishu post (rich text) message content.
|
||||
|
||||
Legacy wrapper for _extract_post_content, returns only text.
|
||||
"""
|
||||
text, _ = _extract_post_content(content_json)
|
||||
return text
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# QR scan-to-create onboarding
|
||||
#
|
||||
|
||||
@@ -658,6 +658,11 @@ class MattermostChannel(BaseChannel):
|
||||
resp.raise_for_status()
|
||||
return cast(dict[str, Any], resp.json())
|
||||
|
||||
async def _api_put(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]:
|
||||
resp = await self._require_http_client().put(path, json=json_data)
|
||||
resp.raise_for_status()
|
||||
return cast(dict[str, Any], resp.json())
|
||||
|
||||
async def _create_post(
|
||||
self,
|
||||
channel_id: str,
|
||||
@@ -676,6 +681,9 @@ class MattermostChannel(BaseChannel):
|
||||
body["file_ids"] = file_ids
|
||||
return await self._api_post("/api/v4/posts", body)
|
||||
|
||||
async def _edit_post(self, post_id: str, message: str) -> dict[str, Any]:
|
||||
return await self._api_put(f"/api/v4/posts/{post_id}", {"id": post_id, "message": message})
|
||||
|
||||
async def _upload_file(self, channel_id: str, file_path: str) -> str | None:
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
|
||||
@@ -811,6 +811,11 @@ class MSTeamsChannel(BaseChannel):
|
||||
except Exception as e:
|
||||
self.logger.warning("Failed to save conversation refs: {}", e)
|
||||
|
||||
def _save_refs(self, *, prune: bool = True) -> None:
|
||||
"""Persist conversation references."""
|
||||
with self._refs_guard:
|
||||
self._save_refs_locked(prune=prune)
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
"""Fetch an access token for Bot Framework / Azure Bot auth."""
|
||||
|
||||
|
||||
@@ -228,8 +228,7 @@ def test_save_prunes_unsupported_conversation_refs(make_channel, tmp_path, monke
|
||||
),
|
||||
}
|
||||
|
||||
with ch._refs_guard:
|
||||
ch._save_refs_locked()
|
||||
ch._save_refs()
|
||||
|
||||
assert set(ch._conversation_refs.keys()) == {"conv-valid"}
|
||||
|
||||
@@ -379,8 +378,7 @@ def test_save_uses_atomic_replace_and_keeps_existing_file_on_replace_error(make_
|
||||
raise OSError("replace failed")
|
||||
|
||||
monkeypatch.setattr(msteams_module.os, "replace", _raise_replace)
|
||||
with ch._refs_guard:
|
||||
ch._save_refs_locked()
|
||||
ch._save_refs()
|
||||
|
||||
persisted = json.loads(refs_path.read_text(encoding="utf-8"))
|
||||
assert set(persisted.keys()) == {"conv-old"}
|
||||
@@ -936,8 +934,7 @@ def test_save_refs_prunes_webchat_and_stale_refs(make_channel):
|
||||
),
|
||||
}
|
||||
|
||||
with ch._refs_guard:
|
||||
ch._save_refs_locked()
|
||||
ch._save_refs()
|
||||
|
||||
assert set(ch._conversation_refs) == {"teams-good"}
|
||||
saved = json.loads(ch._refs_path.read_text(encoding="utf-8"))
|
||||
|
||||
@@ -431,7 +431,6 @@ class SignalChannel(BaseChannel):
|
||||
session_key: str | None = None,
|
||||
is_dm: bool = False,
|
||||
authorization_id: str | None = None,
|
||||
require_existing_session: bool = False,
|
||||
) -> None:
|
||||
"""Handle an inbound message whose policy has already been checked.
|
||||
|
||||
@@ -454,7 +453,6 @@ class SignalChannel(BaseChannel):
|
||||
media=media or [],
|
||||
metadata=meta,
|
||||
session_key_override=session_key,
|
||||
require_existing_session=require_existing_session,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -20,10 +20,7 @@ from websockets.asyncio.server import ServerConnection, serve, unix_serve
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
from websockets.http11 import Request as WsRequest
|
||||
|
||||
from nanobot.bus.events import (
|
||||
OUTBOUND_META_AGENT_UI,
|
||||
OutboundMessage,
|
||||
)
|
||||
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
|
||||
from nanobot.bus.outbound_events import (
|
||||
GoalStateSyncEvent,
|
||||
GoalStatusEvent,
|
||||
@@ -33,6 +30,7 @@ from nanobot.bus.outbound_events import (
|
||||
TurnEndEvent,
|
||||
TurnModelUpdatedEvent,
|
||||
outbound_event_from_message,
|
||||
outbound_message_for_event,
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
@@ -51,7 +49,6 @@ from nanobot.security.workspace_access import (
|
||||
from nanobot.session.goal_state import goal_state_ws_blob
|
||||
from nanobot.session.webui_turns import (
|
||||
clear_websocket_turn_if_current,
|
||||
clear_websocket_turns,
|
||||
mark_websocket_turn_transcript_persistence_failed,
|
||||
register_queued_websocket_turn_if_idle,
|
||||
websocket_turn_id,
|
||||
@@ -85,7 +82,6 @@ from nanobot.webui.session_access import (
|
||||
session_mentions_runtime_context,
|
||||
)
|
||||
from nanobot.webui.sidebar_state import write_webui_sidebar_state
|
||||
from nanobot.webui.temporary_chats import TemporaryChatError
|
||||
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
|
||||
@@ -284,6 +280,21 @@ class WebSocketConfig(Base):
|
||||
)
|
||||
|
||||
|
||||
def publish_runtime_model_update(
|
||||
bus: MessageBus,
|
||||
model: str,
|
||||
model_preset: str | None,
|
||||
) -> None:
|
||||
"""Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel)."""
|
||||
bus.outbound.put_nowait(
|
||||
outbound_message_for_event(
|
||||
channel="websocket",
|
||||
chat_id="*",
|
||||
event=RuntimeModelUpdatedEvent(model=model, model_preset=model_preset),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _parse_inbound_payload(raw: str) -> str | None:
|
||||
"""Parse a client frame into text; return None for empty or unrecognized content."""
|
||||
text = raw.strip()
|
||||
@@ -383,7 +394,6 @@ class WebSocketChannel(BaseChannel):
|
||||
self._ingress = gateway.ingress
|
||||
self._transcripts = gateway.transcripts
|
||||
self._workspaces = gateway.workspaces
|
||||
self._temporary_chats = gateway.temporary_chats
|
||||
self._session_access = (
|
||||
WebuiSessionAccess(gateway.session_manager)
|
||||
if gateway.session_manager is not None
|
||||
@@ -402,33 +412,6 @@ class WebSocketChannel(BaseChannel):
|
||||
self._subs.setdefault(chat_id, set()).add(connection)
|
||||
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
||||
|
||||
def _detach(self, connection: ServerConnection, chat_id: str) -> None:
|
||||
chats = self._conn_chats.get(connection)
|
||||
if chats is not None:
|
||||
chats.discard(chat_id)
|
||||
if not chats:
|
||||
self._conn_chats.pop(connection, None)
|
||||
subscribers = self._subs.get(chat_id)
|
||||
if subscribers is not None:
|
||||
subscribers.discard(connection)
|
||||
if not subscribers:
|
||||
self._subs.pop(chat_id, None)
|
||||
|
||||
def _clear_stream_buffers(self, chat_id: str) -> None:
|
||||
for key in tuple(self._stream_text_buffers):
|
||||
if key[0] == chat_id:
|
||||
self._stream_text_buffers.pop(key, None)
|
||||
|
||||
async def _discard_connection_owned_chat(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
chat_id: str,
|
||||
) -> None:
|
||||
await self._temporary_chats.discard(connection, chat_id)
|
||||
self._detach(connection, chat_id)
|
||||
clear_websocket_turns(chat_id)
|
||||
self._clear_stream_buffers(chat_id)
|
||||
|
||||
async def send_webui_protocol_error(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
@@ -457,16 +440,16 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
await self._hydrate_after_subscribe(fork_id)
|
||||
|
||||
async def _cleanup_connection(self, connection: ServerConnection) -> None:
|
||||
def _cleanup_connection(self, connection: ServerConnection) -> None:
|
||||
"""Remove *connection* from every subscription set; safe to call multiple times."""
|
||||
chat_ids = tuple(self._conn_chats.get(connection, ()))
|
||||
chat_ids = self._conn_chats.pop(connection, set())
|
||||
for cid in chat_ids:
|
||||
if self._temporary_chats.owns(connection, cid):
|
||||
await self._discard_connection_owned_chat(connection, cid)
|
||||
else:
|
||||
self._detach(connection, cid)
|
||||
for cid in self._temporary_chats.chat_ids_for_owner(connection):
|
||||
await self._discard_connection_owned_chat(connection, cid)
|
||||
subs = self._subs.get(cid)
|
||||
if subs is None:
|
||||
continue
|
||||
subs.discard(connection)
|
||||
if not subs:
|
||||
self._subs.pop(cid, None)
|
||||
self._conn_default.pop(connection, None)
|
||||
self._webui_connections.discard(connection)
|
||||
|
||||
@@ -519,7 +502,7 @@ class WebSocketChannel(BaseChannel):
|
||||
try:
|
||||
await connection.send(raw)
|
||||
except ConnectionClosed:
|
||||
await self._cleanup_connection(connection)
|
||||
self._cleanup_connection(connection)
|
||||
except Exception as e:
|
||||
self.logger.warning("failed to send {} event: {}", event, e)
|
||||
|
||||
@@ -746,7 +729,7 @@ class WebSocketChannel(BaseChannel):
|
||||
except Exception as e:
|
||||
self.logger.debug("connection ended: {}", e)
|
||||
finally:
|
||||
await self._cleanup_connection(connection)
|
||||
self._cleanup_connection(connection)
|
||||
|
||||
# -- Inbound WebSocket envelopes ---------------------------------------
|
||||
|
||||
@@ -781,46 +764,14 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
await self._hydrate_after_subscribe(new_id)
|
||||
return
|
||||
if t == "new_temporary_chat":
|
||||
try:
|
||||
new_id = self._temporary_chats.create(
|
||||
connection,
|
||||
trusted_webui=connection in self._webui_connections,
|
||||
)
|
||||
except TemporaryChatError as exc:
|
||||
await self._send_event(connection, "error", detail=exc.detail)
|
||||
return
|
||||
self._attach(connection, new_id)
|
||||
await self._send_event(
|
||||
connection,
|
||||
"attached",
|
||||
chat_id=new_id,
|
||||
temporary=True,
|
||||
)
|
||||
return
|
||||
if t == "fork_chat":
|
||||
await handle_webui_fork_chat(self, connection, envelope)
|
||||
return
|
||||
if t == "discard_temporary_chat":
|
||||
cid = envelope.get("chat_id")
|
||||
if not _is_valid_chat_id(cid):
|
||||
await self._send_event(connection, "error", detail="invalid temporary chat_id")
|
||||
return
|
||||
try:
|
||||
await self._discard_connection_owned_chat(connection, cid)
|
||||
except TemporaryChatError as exc:
|
||||
await self._send_event(connection, "error", detail=exc.detail, chat_id=cid)
|
||||
return
|
||||
if t == "attach":
|
||||
cid = envelope.get("chat_id")
|
||||
if not _is_valid_chat_id(cid):
|
||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||
return
|
||||
try:
|
||||
self._temporary_chats.validate_attach(cid)
|
||||
except TemporaryChatError as exc:
|
||||
await self._send_event(connection, "error", detail=exc.detail, chat_id=cid)
|
||||
return
|
||||
self._attach(connection, cid)
|
||||
await self._send_event(connection, "attached", chat_id=cid)
|
||||
await self._hydrate_after_subscribe(cid)
|
||||
@@ -854,11 +805,6 @@ class WebSocketChannel(BaseChannel):
|
||||
if not _is_valid_chat_id(cid):
|
||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||
return
|
||||
try:
|
||||
self._temporary_chats.validate_workspace_update(cid)
|
||||
except TemporaryChatError as exc:
|
||||
await self._send_event(connection, "error", detail=exc.detail, chat_id=cid)
|
||||
return
|
||||
scope = await self._workspace_scope_or_error(
|
||||
connection,
|
||||
lambda: self._workspaces.scope_for_set_request(
|
||||
@@ -927,21 +873,6 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
temporary_policy = self._temporary_chats.message_policy(
|
||||
connection,
|
||||
cid,
|
||||
content,
|
||||
)
|
||||
except TemporaryChatError as exc:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail=exc.detail,
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
|
||||
raw_media = envelope.get("media")
|
||||
media_paths: list[str] = []
|
||||
if raw_media is not None:
|
||||
@@ -964,8 +895,6 @@ class WebSocketChannel(BaseChannel):
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
if temporary_policy is not None:
|
||||
self._temporary_chats.register_media(connection, cid, media_paths)
|
||||
|
||||
# Allow media-only turns (content may be empty when attachments are present).
|
||||
if not content.strip() and not media_paths:
|
||||
@@ -978,21 +907,16 @@ class WebSocketChannel(BaseChannel):
|
||||
return
|
||||
# Auto-attach on first use so clients can one-shot without a separate attach.
|
||||
self._attach(connection, cid)
|
||||
if temporary_policy is None or temporary_policy.hydrate_transcript:
|
||||
await self._hydrate_after_subscribe(cid)
|
||||
await self._hydrate_after_subscribe(cid)
|
||||
|
||||
# Resolve after hydration so a concurrent downgrade cannot be overwritten.
|
||||
scope = await self._workspace_scope_or_error(
|
||||
connection,
|
||||
lambda: (
|
||||
temporary_policy.workspace_scope
|
||||
if temporary_policy is not None
|
||||
else self._workspaces.scope_for_message(
|
||||
envelope,
|
||||
chat_id=cid,
|
||||
chat_running=websocket_turn_wall_started_at(cid) is not None,
|
||||
controls_available=self._workspace_controls_available(connection),
|
||||
)
|
||||
lambda: self._workspaces.scope_for_message(
|
||||
envelope,
|
||||
chat_id=cid,
|
||||
chat_running=websocket_turn_wall_started_at(cid) is not None,
|
||||
controls_available=self._workspace_controls_available(connection),
|
||||
),
|
||||
chat_id=cid,
|
||||
turn_id=turn_id,
|
||||
@@ -1045,13 +969,7 @@ class WebSocketChannel(BaseChannel):
|
||||
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
|
||||
accepted = False
|
||||
try:
|
||||
if (
|
||||
is_webui
|
||||
and (
|
||||
temporary_policy is None
|
||||
or temporary_policy.persist_transcript
|
||||
)
|
||||
):
|
||||
if is_webui:
|
||||
self._transcripts.append_user_message(
|
||||
cid,
|
||||
content,
|
||||
@@ -1080,16 +998,6 @@ class WebSocketChannel(BaseChannel):
|
||||
media=media_paths or None,
|
||||
metadata=metadata,
|
||||
is_dm=False,
|
||||
session_key=(
|
||||
temporary_policy.session_key
|
||||
if temporary_policy is not None
|
||||
else None
|
||||
),
|
||||
require_existing_session=(
|
||||
temporary_policy.require_existing_session
|
||||
if temporary_policy is not None
|
||||
else False
|
||||
),
|
||||
)
|
||||
accepted = True
|
||||
finally:
|
||||
@@ -1150,7 +1058,6 @@ class WebSocketChannel(BaseChannel):
|
||||
self._conn_default.clear()
|
||||
self._webui_connections.clear()
|
||||
self._tokens.clear()
|
||||
self._temporary_chats.close()
|
||||
|
||||
async def _safe_send_to(
|
||||
self,
|
||||
@@ -1163,7 +1070,7 @@ class WebSocketChannel(BaseChannel):
|
||||
try:
|
||||
await connection.send(raw)
|
||||
except ConnectionClosed:
|
||||
await self._cleanup_connection(connection)
|
||||
self._cleanup_connection(connection)
|
||||
self.logger.warning("connection gone{}", label)
|
||||
except Exception:
|
||||
self.logger.exception("send failed{}", label)
|
||||
@@ -1180,8 +1087,6 @@ class WebSocketChannel(BaseChannel):
|
||||
transcript_overrides: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Persist one canonical turn event and retain unsafe owners on failure."""
|
||||
if not self._temporary_chats.should_persist_transcript(chat_id):
|
||||
return True
|
||||
persisted = self._transcripts.prepare_and_append(
|
||||
chat_id,
|
||||
event,
|
||||
|
||||
@@ -13,9 +13,7 @@ from websockets.exceptions import ConnectionClosed
|
||||
from websockets.frames import Close
|
||||
|
||||
from nanobot.bus.events import (
|
||||
INBOUND_META_RUNTIME_CONTROL,
|
||||
OUTBOUND_META_AGENT_UI,
|
||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
||||
OutboundMessage,
|
||||
)
|
||||
from nanobot.bus.outbound_events import (
|
||||
@@ -34,11 +32,11 @@ from nanobot.channels.websocket.runtime import (
|
||||
_is_valid_chat_id,
|
||||
_parse_envelope,
|
||||
_parse_inbound_payload,
|
||||
publish_runtime_model_update,
|
||||
)
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config, ModelPresetConfig
|
||||
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META, WEBUI_QUOTE_SOURCE
|
||||
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
||||
from nanobot.session import webui_turns as wth
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
||||
@@ -195,302 +193,6 @@ def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
|
||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||
|
||||
|
||||
async def _new_temporary_chat(
|
||||
channel: WebSocketChannel,
|
||||
connection: AsyncMock,
|
||||
) -> str:
|
||||
channel._webui_connections.add(connection)
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{"type": "new_temporary_chat"},
|
||||
)
|
||||
payload = json.loads(connection.send.await_args.args[0])
|
||||
assert payload["event"] == "attached"
|
||||
assert payload["temporary"] is True
|
||||
connection.send.reset_mock()
|
||||
return payload["chat_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
selected_project = tmp_path / "selected-project"
|
||||
selected_project.mkdir()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(
|
||||
bus,
|
||||
session_manager=sessions,
|
||||
workspace_path=tmp_path,
|
||||
),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
connection.remote_address = ("127.0.0.1", 5000)
|
||||
chat_id = await _new_temporary_chat(channel, connection)
|
||||
upload = tmp_path / "temporary-upload.txt"
|
||||
upload.write_text("private attachment", encoding="utf-8")
|
||||
channel.gateway.media.store_inbound_attachments = MagicMock(
|
||||
return_value=([str(upload)], None),
|
||||
)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": chat_id,
|
||||
"content": "read this",
|
||||
"media": [{"data_url": "data:text/plain;base64,cHJpdmF0ZQ=="}],
|
||||
"cli_apps": [{"name": "drawio"}],
|
||||
"workspace_scope": {
|
||||
"project_path": str(selected_project),
|
||||
"access_mode": "full",
|
||||
},
|
||||
"turn_id": "turn-1",
|
||||
"webui": True,
|
||||
},
|
||||
)
|
||||
|
||||
inbound = bus.publish_inbound.await_args_list[0].args[0]
|
||||
assert inbound.session_key == f"websocket:{chat_id}"
|
||||
assert inbound.session_key_override == f"websocket:{chat_id}"
|
||||
assert inbound.require_existing_session is True
|
||||
assert inbound.metadata["cli_apps"] == [{"name": "drawio"}]
|
||||
assert inbound.metadata[WORKSPACE_SCOPE_METADATA_KEY] == {
|
||||
"project_path": str(tmp_path.resolve()),
|
||||
"access_mode": "restricted",
|
||||
}
|
||||
session = sessions.get_cached(inbound.session_key)
|
||||
assert session is not None
|
||||
assert session.policy.persist is False
|
||||
assert upload.exists()
|
||||
assert read_transcript_lines(inbound.session_key) == []
|
||||
assert [payload["event"] for payload in _sent_ws_payloads(connection)] == [
|
||||
"message_accepted",
|
||||
]
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{"type": "discard_temporary_chat", "chat_id": chat_id},
|
||||
)
|
||||
|
||||
control = bus.publish_inbound.await_args_list[1].args[0]
|
||||
assert bus.publish_inbound.await_count == 2
|
||||
assert control.session_key == inbound.session_key
|
||||
assert control.metadata[INBOUND_META_RUNTIME_CONTROL] == (
|
||||
RUNTIME_CONTROL_SESSION_DISCARD
|
||||
)
|
||||
assert sessions.get_cached(inbound.session_key) is None
|
||||
assert chat_id not in channel._subs
|
||||
assert chat_id not in channel._conn_chats.get(connection, set())
|
||||
assert not upload.exists()
|
||||
assert read_transcript_lines(inbound.session_key) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("content", ["/goal private", "/trigger later", "/dream"])
|
||||
async def test_temporary_chat_rejects_persistent_commands(bus, tmp_path, content) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
connection.remote_address = ("127.0.0.1", 5000)
|
||||
chat_id = await _new_temporary_chat(channel, connection)
|
||||
|
||||
await channel._dispatch_envelope(connection, "webui-client", {
|
||||
"type": "message",
|
||||
"chat_id": chat_id,
|
||||
"content": content,
|
||||
"webui": True,
|
||||
})
|
||||
|
||||
assert bus.publish_inbound.await_count == 0
|
||||
assert sessions.get_cached(f"websocket:{chat_id}") is not None
|
||||
assert json.loads(connection.send.await_args.args[0])["detail"] == (
|
||||
"temporary_chat_command_rejected"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_discards_temporary_chat(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(
|
||||
bus,
|
||||
session_manager=sessions,
|
||||
workspace_path=tmp_path,
|
||||
),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
chat_id = await _new_temporary_chat(channel, connection)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": chat_id,
|
||||
"content": "hello",
|
||||
"webui": True,
|
||||
},
|
||||
)
|
||||
await channel._cleanup_connection(connection)
|
||||
|
||||
session_key = f"websocket:{chat_id}"
|
||||
control = bus.publish_inbound.await_args_list[-1].args[0]
|
||||
assert control.session_key == session_key
|
||||
assert control.metadata[INBOUND_META_RUNTIME_CONTROL] == (
|
||||
RUNTIME_CONTROL_SESSION_DISCARD
|
||||
)
|
||||
assert sessions.get_cached(session_key) is None
|
||||
assert chat_id not in channel._subs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_chat_creation_requires_authenticated_webui_connection(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"generic-websocket-client",
|
||||
{"type": "new_temporary_chat"},
|
||||
)
|
||||
|
||||
assert json.loads(connection.send.await_args.args[0])["detail"] == "access_denied"
|
||||
assert sessions.list_sessions() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_chat_cannot_be_claimed_by_another_connection(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
owner = AsyncMock()
|
||||
other = AsyncMock()
|
||||
channel._webui_connections.add(other)
|
||||
chat_id = await _new_temporary_chat(channel, owner)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
other,
|
||||
"other-webui-client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": chat_id,
|
||||
"content": "claim it",
|
||||
"webui": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert json.loads(other.send.await_args.args[0])["detail"] == (
|
||||
"temporary_chat_unavailable"
|
||||
)
|
||||
assert bus.publish_inbound.await_count == 0
|
||||
assert sessions.get_cached(f"websocket:{chat_id}") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_chat_cannot_persist_workspace_scope(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
chat_id = await _new_temporary_chat(channel, connection)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "set_workspace_scope",
|
||||
"chat_id": chat_id,
|
||||
"workspace_scope": {
|
||||
"project_path": str(tmp_path),
|
||||
"access_mode": "full",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
payload = json.loads(connection.send.await_args.args[0])
|
||||
assert payload["detail"] == "temporary_chat_workspace_rejected"
|
||||
session = sessions.get_cached(f"websocket:{chat_id}")
|
||||
assert session is not None
|
||||
assert WORKSPACE_SCOPE_METADATA_KEY not in session.metadata
|
||||
assert sessions.list_sessions() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_looking_id_does_not_define_session_policy(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
channel._webui_connections.add(connection)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": "temporary-looking-but-persistent",
|
||||
"content": "/goal ordinary chat",
|
||||
"webui": True,
|
||||
},
|
||||
)
|
||||
|
||||
inbound = bus.publish_inbound.await_args.args[0]
|
||||
assert inbound.require_existing_session is False
|
||||
assert inbound.session_key_override is None
|
||||
session = sessions.get_cached("websocket:temporary-looking-but-persistent")
|
||||
assert session is not None
|
||||
assert session.policy.persist is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_temporary_chat_does_not_detach_persistent_chat(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
channel._attach(connection, "ordinary-chat")
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{"type": "discard_temporary_chat", "chat_id": "ordinary-chat"},
|
||||
)
|
||||
|
||||
assert json.loads(connection.send.await_args.args[0])["detail"] == (
|
||||
"temporary_chat_unavailable"
|
||||
)
|
||||
assert connection in channel._subs["ordinary-chat"]
|
||||
assert "ordinary-chat" in channel._conn_chats[connection]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
|
||||
class Conn:
|
||||
@@ -1403,14 +1105,8 @@ async def test_send_broadcasts_runtime_model_updates() -> None:
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="*",
|
||||
content="",
|
||||
event=RuntimeModelUpdatedEvent(model="openai/gpt-4.1", model_preset="fast"),
|
||||
)
|
||||
)
|
||||
publish_runtime_model_update(bus, "openai/gpt-4.1", "fast")
|
||||
await channel.send(bus.outbound.get_nowait())
|
||||
|
||||
payload = json.loads(mock_ws.send.call_args[0][0])
|
||||
assert payload["event"] == "runtime_model_updated"
|
||||
@@ -1445,6 +1141,26 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
|
||||
chat_two.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_model_update_publisher_uses_websocket_outbound_event() -> None:
|
||||
bus = MessageBus()
|
||||
|
||||
publish_runtime_model_update(
|
||||
bus,
|
||||
"openai/gpt-4.1",
|
||||
"fast",
|
||||
)
|
||||
|
||||
event = bus.outbound.get_nowait()
|
||||
assert event.channel == "websocket"
|
||||
assert event.chat_id == "*"
|
||||
assert event.content == ""
|
||||
assert event.metadata == {}
|
||||
assert isinstance(event.event, RuntimeModelUpdatedEvent)
|
||||
assert event.event.model == "openai/gpt-4.1"
|
||||
assert event.event.model_preset == "fast"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None:
|
||||
bus = MagicMock()
|
||||
|
||||
@@ -22,7 +22,6 @@ from nanobot.webui.gateway_services import build_gateway_services
|
||||
from nanobot.webui.media_api import (
|
||||
b64url_decode,
|
||||
b64url_encode,
|
||||
sign_media_path,
|
||||
)
|
||||
|
||||
from .ws_test_client import InProcessHttpChannel
|
||||
@@ -85,16 +84,8 @@ def _fake_media_dir(root: Path):
|
||||
return inner
|
||||
|
||||
|
||||
def _sign_media_path(channel: WebSocketChannel, path: Path) -> str | None:
|
||||
return sign_media_path(
|
||||
path,
|
||||
secret=channel.gateway.media.secret,
|
||||
media_dir=channel.gateway.media._media_dir,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# media_api.sign_media_path: the URL minter
|
||||
# gateway.media.sign_media_path: the URL minter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -114,10 +105,10 @@ def test_sign_media_path_rejects_paths_outside_media_root(
|
||||
media.mkdir()
|
||||
channel = _ch(bus, port=0)
|
||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||
assert _sign_media_path(channel, outside) is None
|
||||
assert channel.gateway.media.sign_media_path(outside) is None
|
||||
# Traversal via the media root is also rejected — the resolve() step
|
||||
# normalises ``..`` out before the relative_to check.
|
||||
assert _sign_media_path(channel, media / ".." / "secrets" / "cred.txt") is None
|
||||
assert channel.gateway.media.sign_media_path(media / ".." / "secrets" / "cred.txt") is None
|
||||
|
||||
|
||||
def test_sign_media_path_round_trips_via_hmac(
|
||||
@@ -129,7 +120,7 @@ def test_sign_media_path_round_trips_via_hmac(
|
||||
(media / "a.png").write_bytes(_PNG_BYTES)
|
||||
channel = _ch(bus, port=0)
|
||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||
url = _sign_media_path(channel, media / "a.png")
|
||||
url = channel.gateway.media.sign_media_path(media / "a.png")
|
||||
assert url is not None
|
||||
assert url.startswith("/api/media/")
|
||||
sig, payload = url[len("/api/media/"):].split("/", 1)
|
||||
@@ -244,7 +235,7 @@ async def test_media_route_serves_signed_file(
|
||||
|
||||
channel = _ch(bus, port=29920)
|
||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||
url_path = _sign_media_path(channel, target)
|
||||
url_path = channel.gateway.media.sign_media_path(target)
|
||||
assert url_path is not None
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
try:
|
||||
@@ -276,7 +267,7 @@ async def test_media_route_serves_video_byte_ranges(
|
||||
|
||||
channel = _ch(bus, port=29927)
|
||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||
url_path = _sign_media_path(channel, target)
|
||||
url_path = channel.gateway.media.sign_media_path(target)
|
||||
assert url_path is not None
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
try:
|
||||
@@ -307,7 +298,7 @@ async def test_media_route_serves_suffix_video_byte_ranges(
|
||||
|
||||
channel = _ch(bus, port=29928)
|
||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||
url_path = _sign_media_path(channel, target)
|
||||
url_path = channel.gateway.media.sign_media_path(target)
|
||||
assert url_path is not None
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
try:
|
||||
@@ -335,7 +326,7 @@ async def test_media_route_rejects_unsatisfiable_byte_range(
|
||||
|
||||
channel = _ch(bus, port=29929)
|
||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||
url_path = _sign_media_path(channel, target)
|
||||
url_path = channel.gateway.media.sign_media_path(target)
|
||||
assert url_path is not None
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
try:
|
||||
@@ -367,7 +358,7 @@ async def test_media_route_rejects_bad_signature(
|
||||
|
||||
channel = _ch(bus, port=29921)
|
||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||
good = _sign_media_path(channel, media / "f.png")
|
||||
good = channel.gateway.media.sign_media_path(media / "f.png")
|
||||
assert good is not None
|
||||
_, payload = good[len("/api/media/"):].split("/", 1)
|
||||
# Forge a sig with a *different* secret.
|
||||
@@ -432,7 +423,7 @@ async def test_media_route_404s_missing_file(
|
||||
|
||||
channel = _ch(bus, port=29923)
|
||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||
url_path = _sign_media_path(channel, target)
|
||||
url_path = channel.gateway.media.sign_media_path(target)
|
||||
assert url_path is not None
|
||||
target.unlink() # the file vanishes between signing and fetching
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
@@ -489,7 +480,7 @@ async def test_media_route_serves_svg_with_strict_csp(
|
||||
|
||||
channel = _ch(bus, port=29928)
|
||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||
url_path = _sign_media_path(channel, target)
|
||||
url_path = channel.gateway.media.sign_media_path(target)
|
||||
assert url_path is not None
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
try:
|
||||
|
||||
@@ -25,6 +25,9 @@ from nanobot.cron.types import (
|
||||
CronSchedule,
|
||||
CronStore,
|
||||
)
|
||||
from nanobot.utils.run_records import (
|
||||
safe_run_record_name,
|
||||
)
|
||||
from nanobot.utils.run_records import (
|
||||
write_run_record as write_automation_run_record,
|
||||
)
|
||||
@@ -437,6 +440,10 @@ class CronService:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _safe_run_record_name(run_id: str) -> str:
|
||||
return safe_run_record_name(run_id)
|
||||
|
||||
def write_run_record(self, run_id: str, record: dict[str, Any]) -> None:
|
||||
"""Write an internal audit record for one cron execution."""
|
||||
write_automation_run_record(self._run_records_dir, run_id, record)
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any, Mapping
|
||||
from nanobot.cron.types import CronJob
|
||||
from nanobot.session.automation_turns import (
|
||||
AutomationTurnSpec,
|
||||
automation_history_overrides_for_spec,
|
||||
automation_trigger,
|
||||
)
|
||||
|
||||
@@ -62,6 +63,11 @@ def cron_run_id(metadata: Mapping[str, Any] | None) -> str | None:
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def cron_history_overrides(metadata: Mapping[str, Any] | None) -> tuple[str | None, dict[str, Any]]:
|
||||
"""Return session-history text/metadata overrides for a cron turn."""
|
||||
return automation_history_overrides_for_spec(metadata, CRON_AUTOMATION_SPEC)
|
||||
|
||||
|
||||
def is_bound_cron_job(job: CronJob) -> bool:
|
||||
"""True for session-bound cron jobs with complete delivery context."""
|
||||
payload = job.payload
|
||||
|
||||
@@ -11,7 +11,7 @@ from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Collection, Protocol, TypedDict, cast
|
||||
from typing import Any, Callable, Protocol, TypedDict, cast
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from loguru import logger
|
||||
@@ -147,15 +147,6 @@ class RetentionResult:
|
||||
already_consolidated_count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionPolicy:
|
||||
"""Runtime rules that do not belong in durable session data."""
|
||||
|
||||
persist: bool = True
|
||||
log_content: bool = True
|
||||
disabled_tools: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""A conversation session."""
|
||||
@@ -167,7 +158,6 @@ class Session:
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
last_consolidated: int = 0 # Number of messages already consolidated to files
|
||||
provider_state: ProviderConversationState | None = field(default=None, repr=False)
|
||||
policy: SessionPolicy = field(default_factory=SessionPolicy, repr=False, compare=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(cast(object, self.metadata), dict):
|
||||
@@ -1089,24 +1079,6 @@ class SessionManager:
|
||||
self._remember(session)
|
||||
return session
|
||||
|
||||
def get_or_create_transient(
|
||||
self,
|
||||
key: str,
|
||||
*,
|
||||
disabled_tools: Collection[str] = (),
|
||||
) -> Session:
|
||||
"""Return a fresh, non-persistent session without loading history."""
|
||||
policy = SessionPolicy(
|
||||
persist=False,
|
||||
log_content=False,
|
||||
disabled_tools=frozenset(disabled_tools),
|
||||
)
|
||||
session = self.get_cached(key)
|
||||
if session is None or session.policy != policy:
|
||||
session = Session(key=key, policy=policy)
|
||||
self._remember(session)
|
||||
return session
|
||||
|
||||
def _load(self, key: str) -> Session | None:
|
||||
return self._store.load(key)
|
||||
|
||||
@@ -1114,11 +1086,12 @@ class SessionManager:
|
||||
"""Attempt to recover a session from a corrupt JSONL file."""
|
||||
return self._jsonl_store.repair(key, path=path)
|
||||
|
||||
@staticmethod
|
||||
def _session_payload(session: Session) -> SessionPayload:
|
||||
return JsonlSessionStore.session_payload(session)
|
||||
|
||||
def save(self, session: Session, *, fsync: bool = False) -> None:
|
||||
"""Persist a session and retain it in the cache."""
|
||||
if not session.policy.persist:
|
||||
return
|
||||
|
||||
archiver = self._file_cap_archiver
|
||||
if archiver is not None:
|
||||
session.enforce_file_cap(
|
||||
|
||||
@@ -334,12 +334,6 @@ def clear_websocket_turn_if_current(
|
||||
return False
|
||||
|
||||
|
||||
def clear_websocket_turns(chat_id: str) -> None:
|
||||
"""Forget every in-process turn projection for a discarded chat."""
|
||||
_WEBSOCKET_ACTIVE_TURNS.pop(chat_id, None)
|
||||
_sync_websocket_turn_projection(chat_id)
|
||||
|
||||
|
||||
def build_bus_progress_callback(
|
||||
bus: MessageBus,
|
||||
msg: InboundMessage,
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, Mapping
|
||||
|
||||
from nanobot.session.automation_turns import (
|
||||
AutomationTurnSpec,
|
||||
automation_history_overrides_for_spec,
|
||||
automation_trigger,
|
||||
)
|
||||
|
||||
@@ -49,3 +50,13 @@ def local_trigger_delivery_id(metadata: Mapping[str, Any] | None) -> str | None:
|
||||
return None
|
||||
value = trigger.get("delivery_id")
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def local_trigger_history_overrides(
|
||||
metadata: Mapping[str, Any] | None,
|
||||
) -> tuple[str | None, dict[str, Any]]:
|
||||
"""Return session-history text/metadata overrides for a local trigger turn."""
|
||||
return automation_history_overrides_for_spec(
|
||||
metadata,
|
||||
LOCAL_TRIGGER_AUTOMATION_SPEC,
|
||||
)
|
||||
|
||||
@@ -11,6 +11,35 @@ from loguru import logger
|
||||
|
||||
from nanobot.utils.helpers import detect_image_mime
|
||||
|
||||
# Supported file extensions for text extraction
|
||||
SUPPORTED_EXTENSIONS: set[str] = {
|
||||
# Document formats
|
||||
".pdf",
|
||||
".docx",
|
||||
".xlsx",
|
||||
".pptx",
|
||||
# Text formats
|
||||
".txt",
|
||||
".md",
|
||||
".csv",
|
||||
".json",
|
||||
".xml",
|
||||
".html",
|
||||
".htm",
|
||||
".log",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".ini",
|
||||
".cfg",
|
||||
# Image formats (for future OCR support)
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
}
|
||||
|
||||
_MAX_TEXT_LENGTH = 200_000
|
||||
_MAX_EXTRACT_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||
_MAX_OFFICE_ARCHIVE_MEMBERS = 10_000
|
||||
|
||||
@@ -274,6 +274,24 @@ def _text_line_count(text: str) -> int:
|
||||
return line_count if last_was_newline else line_count + 1
|
||||
|
||||
|
||||
def prepare_file_edit_tracker(
|
||||
*,
|
||||
call_id: str,
|
||||
tool_name: str,
|
||||
tool: Any,
|
||||
workspace: Path | None,
|
||||
params: dict[str, Any] | None,
|
||||
) -> FileEditTracker | None:
|
||||
trackers = prepare_file_edit_trackers(
|
||||
call_id=call_id,
|
||||
tool_name=tool_name,
|
||||
tool=tool,
|
||||
workspace=workspace,
|
||||
params=params,
|
||||
)
|
||||
return trackers[0] if trackers else None
|
||||
|
||||
|
||||
def prepare_file_edit_trackers(
|
||||
*,
|
||||
call_id: str,
|
||||
|
||||
@@ -5,13 +5,14 @@ from __future__ import annotations
|
||||
import io
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from typing import TYPE_CHECKING, Iterable, cast
|
||||
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dulwich.objects import Blob, Commit, ObjectID, Tree
|
||||
from dulwich.objects import Blob, Commit, ObjectID, Tree, TreeEntry
|
||||
from dulwich.refs import Ref
|
||||
from dulwich.repo import Repo
|
||||
|
||||
@@ -44,6 +45,25 @@ class CommitInfo:
|
||||
return f"{header}\n(no file changes)"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LineAge:
|
||||
"""Age of a single line based on git blame."""
|
||||
|
||||
age_days: int # days since last modification
|
||||
|
||||
|
||||
def _compute_line_ages(
|
||||
annotated: Iterable[tuple[tuple["Commit", "TreeEntry"], bytes]],
|
||||
) -> list[LineAge]:
|
||||
"""Convert annotate results to per-line ages."""
|
||||
now = datetime.now(tz=timezone.utc).date()
|
||||
ages: list[LineAge] = []
|
||||
for (commit, _tree_entry), _line_bytes in annotated:
|
||||
dt = datetime.fromtimestamp(commit.commit_time, tz=timezone.utc).date()
|
||||
ages.append(LineAge(age_days=(now - dt).days))
|
||||
return ages
|
||||
|
||||
|
||||
class GitStore:
|
||||
"""Git-backed version control for memory files."""
|
||||
|
||||
@@ -273,6 +293,33 @@ class GitStore:
|
||||
except Exception as exc:
|
||||
raise GitStoreError("Git log failed") from exc
|
||||
|
||||
def line_ages(self, file_path: str) -> list[LineAge]:
|
||||
"""Compute the age of each line in a tracked file via git blame.
|
||||
|
||||
Returns one LineAge per line, in order.
|
||||
Returns an empty list if the repo is not initialized or the file is
|
||||
empty. Annotation failures raise :class:`GitStoreError`.
|
||||
"""
|
||||
|
||||
if not self.is_initialized():
|
||||
return []
|
||||
|
||||
target = self._workspace / file_path
|
||||
if not target.exists() or target.stat().st_size == 0:
|
||||
return []
|
||||
|
||||
try:
|
||||
from dulwich import porcelain
|
||||
|
||||
annotated = porcelain.annotate(str(self._workspace), file_path)
|
||||
except Exception as exc:
|
||||
raise GitStoreError(f"Git line annotation failed for {file_path}") from exc
|
||||
|
||||
if not annotated:
|
||||
return []
|
||||
|
||||
return _compute_line_ages(annotated)
|
||||
|
||||
def diff_commits(self, sha1: str, sha2: str) -> str:
|
||||
"""Show diff between two commits."""
|
||||
if not self.is_initialized():
|
||||
@@ -414,6 +461,13 @@ class GitStore:
|
||||
commit = cast("Commit", commit_obj)
|
||||
return cast("Tree", repo[commit.tree])
|
||||
|
||||
def find_commit(self, short_sha: str, max_entries: int = 20) -> CommitInfo | None:
|
||||
"""Find a commit by short SHA prefix match."""
|
||||
for c in self.log(max_entries=max_entries):
|
||||
if c.sha.startswith(short_sha):
|
||||
return c
|
||||
return None
|
||||
|
||||
def show_commit_diff(
|
||||
self,
|
||||
short_sha: str,
|
||||
|
||||
@@ -351,6 +351,18 @@ def timestamp() -> str:
|
||||
return datetime.now().isoformat()
|
||||
|
||||
|
||||
def current_time_str(timezone: str | None = None) -> str:
|
||||
"""Return the current time string."""
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
tz = ZoneInfo(timezone) if timezone else None
|
||||
now = datetime.now(tz=tz) if tz else datetime.now().astimezone()
|
||||
offset = now.strftime("%z")
|
||||
offset_fmt = f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else offset
|
||||
tz_name = timezone or (time.strftime("%Z") or "UTC")
|
||||
return f"{now.strftime('%Y-%m-%d %H:%M (%A)')} ({tz_name}, UTC{offset_fmt})"
|
||||
|
||||
|
||||
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
|
||||
_TOOL_RESULT_PREVIEW_CHARS = 1200
|
||||
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
|
||||
|
||||
@@ -11,7 +11,6 @@ from loguru import logger as default_logger
|
||||
from nanobot.webui.gateway_tokens import GatewayTokenStore
|
||||
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||
from nanobot.webui.temporary_chats import WebUITemporaryChats
|
||||
from nanobot.webui.transcript import WebUITranscriptRecorder
|
||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||
from nanobot.webui.ws_http import GatewayHTTPHandler
|
||||
@@ -34,7 +33,6 @@ class GatewayServices:
|
||||
ingress: WebUIIngressPolicy
|
||||
transcripts: WebUITranscriptRecorder
|
||||
workspaces: WebUIWorkspaceController
|
||||
temporary_chats: WebUITemporaryChats
|
||||
session_manager: SessionManager | None
|
||||
cron_service: CronService | None
|
||||
local_trigger_store: LocalTriggerStore | None
|
||||
@@ -84,12 +82,6 @@ def build_gateway_services(
|
||||
default_workspace=workspace_path,
|
||||
default_restrict_to_workspace=default_restrict_to_workspace,
|
||||
)
|
||||
temporary_chats = WebUITemporaryChats(
|
||||
bus=bus,
|
||||
session_manager=session_manager,
|
||||
workspaces=workspaces,
|
||||
logger=logger,
|
||||
)
|
||||
http = GatewayHTTPHandler(
|
||||
config=config,
|
||||
session_manager=session_manager,
|
||||
@@ -120,7 +112,6 @@ def build_gateway_services(
|
||||
ingress=ingress,
|
||||
transcripts=transcripts,
|
||||
workspaces=workspaces,
|
||||
temporary_chats=temporary_chats,
|
||||
session_manager=session_manager,
|
||||
cron_service=cron_service,
|
||||
local_trigger_store=local_trigger_store,
|
||||
|
||||
@@ -18,6 +18,7 @@ from nanobot.webui.attachment_ingress import (
|
||||
from nanobot.webui.ingress_policy import AttachmentIngressLimits
|
||||
from nanobot.webui.media_api import (
|
||||
serve_signed_media,
|
||||
sign_media_path,
|
||||
sign_or_stage_media_path,
|
||||
signed_media_attachments,
|
||||
)
|
||||
@@ -70,6 +71,13 @@ class WebUIMediaGateway:
|
||||
media_dir=self._media_dir,
|
||||
)
|
||||
|
||||
def sign_media_path(self, abs_path: Path) -> str | None:
|
||||
return sign_media_path(
|
||||
abs_path,
|
||||
secret=self.secret,
|
||||
media_dir=self._media_dir,
|
||||
)
|
||||
|
||||
def sign_or_stage_media_path(self, path: Path) -> dict[str, str] | None:
|
||||
return sign_or_stage_media_path(
|
||||
path,
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
"""Connection-owned Temporary Chat behavior for the WebUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.bus.events import (
|
||||
INBOUND_META_RUNTIME_CONTROL,
|
||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
||||
InboundMessage,
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.security.workspace_access import WorkspaceScope
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||
|
||||
_TEMPORARY_CHAT_DISABLED_TOOLS = frozenset({
|
||||
"create_goal",
|
||||
"update_goal",
|
||||
"spawn",
|
||||
"cron",
|
||||
})
|
||||
_TEMPORARY_CHAT_COMMANDS = frozenset({"/model", "/stop"})
|
||||
|
||||
|
||||
class TemporaryChatError(ValueError):
|
||||
"""A stable WebUI protocol error for a Temporary Chat operation."""
|
||||
|
||||
def __init__(self, detail: str) -> None:
|
||||
super().__init__(detail)
|
||||
self.detail = detail
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TemporaryChatMessagePolicy:
|
||||
"""Server-owned message rules for one active Temporary Chat."""
|
||||
|
||||
session_key: str
|
||||
workspace_scope: WorkspaceScope
|
||||
require_existing_session: bool = True
|
||||
hydrate_transcript: bool = False
|
||||
persist_transcript: bool = False
|
||||
|
||||
|
||||
class WebUITemporaryChats:
|
||||
"""Own Temporary Chat creation, policy, attachments, and disposal."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bus: MessageBus,
|
||||
session_manager: SessionManager | None,
|
||||
workspaces: WebUIWorkspaceController,
|
||||
logger: Any,
|
||||
channel_name: str = "websocket",
|
||||
) -> None:
|
||||
self._bus = bus
|
||||
self._sessions = session_manager
|
||||
self._workspaces = workspaces
|
||||
self._logger = logger
|
||||
self._channel_name = channel_name
|
||||
self._owners: dict[str, object] = {}
|
||||
self._owner_chat_ids: dict[object, set[str]] = {}
|
||||
# Keep active sessions alive if the bounded manager cache evicts them
|
||||
# between WebUI turns. SessionPolicy remains the authority below.
|
||||
self._active_sessions: dict[str, Session] = {}
|
||||
# Retain policy-derived tombstones until shutdown so late outbound
|
||||
# events cannot create a durable transcript after a chat is discarded.
|
||||
self._known_transient_chat_ids: set[str] = set()
|
||||
self._media_paths: dict[str, set[str]] = {}
|
||||
|
||||
def _session_key(self, chat_id: str) -> str:
|
||||
return f"{self._channel_name}:{chat_id}"
|
||||
|
||||
def _cached_session_is_transient(self, chat_id: str) -> bool:
|
||||
if self._sessions is None:
|
||||
return False
|
||||
session = self._sessions.get_cached(self._session_key(chat_id))
|
||||
return session is not None and not session.policy.persist
|
||||
|
||||
def create(self, owner: object, *, trusted_webui: bool) -> str:
|
||||
"""Create a server-identified chat owned by one authenticated WebUI connection."""
|
||||
if not trusted_webui:
|
||||
raise TemporaryChatError("access_denied")
|
||||
if self._sessions is None:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
chat_id = str(uuid.uuid4())
|
||||
session = self._sessions.get_or_create_transient(
|
||||
self._session_key(chat_id),
|
||||
disabled_tools=_TEMPORARY_CHAT_DISABLED_TOOLS,
|
||||
)
|
||||
if session.policy.persist:
|
||||
raise RuntimeError("Temporary Chat must use a non-persistent session policy")
|
||||
self._owners[chat_id] = owner
|
||||
self._owner_chat_ids.setdefault(owner, set()).add(chat_id)
|
||||
self._active_sessions[chat_id] = session
|
||||
self._known_transient_chat_ids.add(chat_id)
|
||||
return chat_id
|
||||
|
||||
def message_policy(
|
||||
self,
|
||||
owner: object,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
) -> TemporaryChatMessagePolicy | None:
|
||||
"""Return Temporary Chat rules, or ``None`` for an ordinary chat."""
|
||||
if not self._cached_session_is_transient(chat_id):
|
||||
if chat_id in self._known_transient_chat_ids:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
return None
|
||||
if self._owners.get(chat_id) is not owner or self._sessions is None:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
session = self._sessions.get_cached(self._session_key(chat_id))
|
||||
if session is None:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
command = content.strip().split(maxsplit=1)[0].lower() if content.strip() else ""
|
||||
if command.startswith("/") and command not in _TEMPORARY_CHAT_COMMANDS:
|
||||
raise TemporaryChatError("temporary_chat_command_rejected")
|
||||
|
||||
return TemporaryChatMessagePolicy(
|
||||
session_key=self._session_key(chat_id),
|
||||
workspace_scope=self._workspaces.restricted_default_scope(),
|
||||
)
|
||||
|
||||
def validate_attach(self, chat_id: str) -> None:
|
||||
"""Reject attempts to recover a non-persistent session."""
|
||||
if not self._cached_session_is_transient(chat_id):
|
||||
if chat_id in self._known_transient_chat_ids:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
return
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
def validate_workspace_update(self, chat_id: str) -> None:
|
||||
"""Prevent non-persistent sessions from acquiring durable workspace state."""
|
||||
if self._cached_session_is_transient(chat_id):
|
||||
raise TemporaryChatError("temporary_chat_workspace_rejected")
|
||||
if chat_id in self._known_transient_chat_ids:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
def register_media(self, owner: object, chat_id: str, paths: list[str]) -> None:
|
||||
if not paths:
|
||||
return
|
||||
if self._owners.get(chat_id) is not owner:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
self._media_paths.setdefault(chat_id, set()).update(paths)
|
||||
|
||||
def chat_ids_for_owner(self, owner: object) -> tuple[str, ...]:
|
||||
return tuple(self._owner_chat_ids.get(owner, ()))
|
||||
|
||||
def owns(self, owner: object, chat_id: str) -> bool:
|
||||
return self._owners.get(chat_id) is owner
|
||||
|
||||
def should_persist_transcript(self, chat_id: str) -> bool:
|
||||
"""Apply the session policy and retain it for late events after disposal."""
|
||||
return (
|
||||
not self._cached_session_is_transient(chat_id)
|
||||
and chat_id not in self._known_transient_chat_ids
|
||||
)
|
||||
|
||||
def _discard_media(self, chat_id: str) -> None:
|
||||
for raw_path in self._media_paths.pop(chat_id, set()):
|
||||
try:
|
||||
Path(raw_path).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
self._logger.warning("failed to remove a temporary WebUI attachment")
|
||||
|
||||
def _forget_owner(self, owner: object, chat_id: str) -> None:
|
||||
self._owners.pop(chat_id, None)
|
||||
chat_ids = self._owner_chat_ids.get(owner)
|
||||
if chat_ids is None:
|
||||
return
|
||||
chat_ids.discard(chat_id)
|
||||
if not chat_ids:
|
||||
self._owner_chat_ids.pop(owner, None)
|
||||
|
||||
async def discard(self, owner: object, chat_id: str) -> None:
|
||||
"""Forget one owned chat and cancel any active work through the message bus."""
|
||||
if (
|
||||
not self._cached_session_is_transient(chat_id)
|
||||
or self._owners.get(chat_id) is not owner
|
||||
):
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
session_key = self._session_key(chat_id)
|
||||
self._forget_owner(owner, chat_id)
|
||||
self._active_sessions.pop(chat_id, None)
|
||||
self._discard_media(chat_id)
|
||||
if self._sessions is not None:
|
||||
self._sessions.invalidate(session_key)
|
||||
await self._bus.publish_inbound(
|
||||
InboundMessage(
|
||||
channel=self._channel_name,
|
||||
sender_id="webui",
|
||||
chat_id=chat_id,
|
||||
content="",
|
||||
metadata={
|
||||
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_SESSION_DISCARD,
|
||||
},
|
||||
session_key_override=session_key,
|
||||
)
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release process-local resources during gateway shutdown."""
|
||||
for chat_id in tuple(self._owners):
|
||||
self._discard_media(chat_id)
|
||||
if self._sessions is not None:
|
||||
self._sessions.invalidate(self._session_key(chat_id))
|
||||
self._owners.clear()
|
||||
self._owner_chat_ids.clear()
|
||||
self._active_sessions.clear()
|
||||
self._known_transient_chat_ids.clear()
|
||||
@@ -1313,6 +1313,21 @@ def _recover_incomplete_turns(
|
||||
return recovered
|
||||
|
||||
|
||||
def recover_incomplete_turns_from_session(
|
||||
lines: list[dict[str, Any]],
|
||||
session_messages: list[dict[str, Any]] | None,
|
||||
*,
|
||||
session_key: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Recover marked transcript answers only when one durable session turn matches."""
|
||||
if not lines or not session_messages or not _needs_incomplete_turn_recovery(lines):
|
||||
return lines
|
||||
session_turns = _session_backfill_turns(session_key, session_messages)
|
||||
if not session_turns:
|
||||
return lines
|
||||
return _recover_incomplete_turns(lines, session_turns)
|
||||
|
||||
|
||||
def _with_backfilled_user(
|
||||
records: list[dict[str, Any]],
|
||||
user_event: dict[str, Any],
|
||||
@@ -1350,6 +1365,20 @@ def _inject_missing_user_events(
|
||||
return out
|
||||
|
||||
|
||||
def inject_missing_user_events_from_session(
|
||||
session_key: str,
|
||||
lines: list[dict[str, Any]],
|
||||
session_messages: list[dict[str, Any]] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Backfill user rows for legacy WebUI transcripts that only stored assistant streams."""
|
||||
if not lines or not session_messages or not _needs_user_event_backfill(lines):
|
||||
return lines
|
||||
session_turns = _session_backfill_turns(session_key, session_messages)
|
||||
if not session_turns:
|
||||
return lines
|
||||
return _inject_missing_user_events(lines, session_turns)
|
||||
|
||||
|
||||
def _format_tool_call_trace(call: Any) -> str | None:
|
||||
if not call or not isinstance(call, dict):
|
||||
return None
|
||||
|
||||
@@ -191,14 +191,6 @@ class WebUIWorkspaceController:
|
||||
self._default_restrict_to_workspace,
|
||||
)
|
||||
|
||||
def restricted_default_scope(self) -> WorkspaceScope:
|
||||
"""Return the default workspace with access restricted for this request."""
|
||||
return build_workspace_scope(
|
||||
self._default_workspace,
|
||||
"restricted",
|
||||
source_channel=_WEBUI_SCOPE_CHANNEL,
|
||||
)
|
||||
|
||||
def _scope_from_metadata_value(
|
||||
self,
|
||||
raw_scope: object,
|
||||
|
||||
@@ -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) == []
|
||||
@@ -169,6 +169,19 @@ class TestDiffCommits:
|
||||
assert git_ready.diff_commits("deadbeef", "cafebabe") == ""
|
||||
|
||||
|
||||
class TestFindCommit:
|
||||
def test_finds_by_prefix(self, git_ready):
|
||||
ws = git_ready._workspace
|
||||
(ws / "SOUL.md").write_text("v2", encoding="utf-8")
|
||||
sha = git_ready.auto_commit("v2")
|
||||
found = git_ready.find_commit(sha[:4])
|
||||
assert found is not None
|
||||
assert found.sha == sha
|
||||
|
||||
def test_returns_none_for_unknown(self, git_ready):
|
||||
assert git_ready.find_commit("deadbeef") is None
|
||||
|
||||
|
||||
class TestShowCommitDiff:
|
||||
def test_returns_commit_with_diff(self, git_ready):
|
||||
ws = git_ready._workspace
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import (
|
||||
INBOUND_META_RUNTIME_CONTROL,
|
||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
||||
InboundMessage,
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import GenerationSettings, LLMResponse
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||
|
||||
|
||||
def _message(key: str, content: str) -> InboundMessage:
|
||||
return InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id=key.removeprefix("websocket:"),
|
||||
content=content,
|
||||
session_key_override=key,
|
||||
require_existing_session=True,
|
||||
)
|
||||
|
||||
|
||||
def _loop(tmp_path, responses: list[str], **kwargs) -> AgentLoop:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation = GenerationSettings()
|
||||
provider.chat_with_retry = AsyncMock(
|
||||
side_effect=[LLMResponse(content=response, usage={}) for response in responses]
|
||||
)
|
||||
return AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
cron_service=MagicMock(),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transient_session_keeps_history_without_persisting_or_durable_tools(tmp_path) -> None:
|
||||
loop = _loop(tmp_path, ["first answer", "second answer"])
|
||||
loop.context.memory.write_memory("private durable memory")
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock()
|
||||
key = "websocket:transient-test"
|
||||
loop.sessions.get_or_create_transient(
|
||||
key,
|
||||
disabled_tools={"create_goal", "update_goal", "spawn", "cron"},
|
||||
)
|
||||
|
||||
await loop._process_message(_message(key, "first question"))
|
||||
await loop._process_message(_message(key, "second question"))
|
||||
|
||||
calls = loop.provider.chat_with_retry.await_args_list
|
||||
assert "private durable memory" not in str(calls[0].kwargs["messages"])
|
||||
tool_names = {item["function"]["name"] for item in calls[0].kwargs["tools"]}
|
||||
assert "read_session" in tool_names
|
||||
assert {"create_goal", "update_goal", "spawn", "cron"}.isdisjoint(tool_names)
|
||||
assert "first answer" in str(calls[1].kwargs["messages"])
|
||||
session = loop.sessions.get_cached(key)
|
||||
assert session is not None
|
||||
assert [message["role"] for message in session.messages] == [
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
]
|
||||
assert loop.sessions.read_session_file(key) is None
|
||||
loop.consolidator.maybe_consolidate_by_tokens.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transient_session_stays_outside_unified_session(tmp_path) -> None:
|
||||
loop = _loop(tmp_path, ["private answer"], unified_session=True)
|
||||
durable = loop.sessions.get_or_create(UNIFIED_SESSION_KEY)
|
||||
durable.add_message("user", "durable question")
|
||||
loop.sessions.save(durable)
|
||||
key = "websocket:transient-unified"
|
||||
transient = loop.sessions.get_or_create_transient(key)
|
||||
|
||||
await loop._dispatch(_message(key, "private question"))
|
||||
|
||||
assert [message["content"] for message in transient.messages] == [
|
||||
"private question",
|
||||
"private answer",
|
||||
]
|
||||
assert [message["content"] for message in durable.messages] == ["durable question"]
|
||||
assert loop.sessions.read_session_file(key) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_required_session_cannot_fall_back_to_disk(tmp_path) -> None:
|
||||
loop = _loop(tmp_path, [])
|
||||
key = "websocket:transient-stale"
|
||||
loop.sessions.get_or_create_transient(key)
|
||||
loop.sessions.invalidate(key)
|
||||
|
||||
with pytest.raises(RuntimeError, match="required session is not active"):
|
||||
await loop._process_message(_message(key, "stale private message"))
|
||||
|
||||
loop.provider.chat_with_retry.assert_not_awaited()
|
||||
assert loop.sessions.read_session_file(key) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_discard_control_cancels_active_turn(tmp_path, monkeypatch) -> None:
|
||||
provider_started = asyncio.Event()
|
||||
|
||||
async def block_provider(**_kwargs: object) -> LLMResponse:
|
||||
provider_started.set()
|
||||
await asyncio.Event().wait()
|
||||
raise AssertionError("provider blocker unexpectedly released")
|
||||
|
||||
loop = _loop(tmp_path, [])
|
||||
|
||||
async def wait_for_discard(key: str) -> None:
|
||||
while loop.sessions.get_cached(key) is not None or key in loop._discarding_sessions:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
loop.provider.chat_with_retry = AsyncMock(side_effect=block_provider)
|
||||
monkeypatch.setattr(loop, "_connect_mcp", AsyncMock())
|
||||
monkeypatch.setattr(loop, "close_mcp", AsyncMock())
|
||||
terminate_exec_sessions = AsyncMock(return_value=1)
|
||||
monkeypatch.setattr(
|
||||
loop._exec_session_manager,
|
||||
"terminate_by_owner",
|
||||
terminate_exec_sessions,
|
||||
)
|
||||
key = "websocket:transient-cancelled"
|
||||
loop.sessions.get_or_create_transient(
|
||||
key,
|
||||
disabled_tools={"create_goal", "update_goal", "spawn", "cron"},
|
||||
)
|
||||
run_task = asyncio.create_task(loop.run())
|
||||
await loop.bus.publish_inbound(_message(key, "private"))
|
||||
await asyncio.wait_for(provider_started.wait(), timeout=2)
|
||||
active_task = next(iter(loop._active_tasks[key]))
|
||||
|
||||
await loop.bus.publish_inbound(
|
||||
InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="webui",
|
||||
chat_id="transient-cancelled",
|
||||
content="",
|
||||
metadata={
|
||||
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_SESSION_DISCARD,
|
||||
},
|
||||
session_key_override=key,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await asyncio.wait_for(active_task, timeout=2)
|
||||
await asyncio.wait_for(wait_for_discard(key), timeout=2)
|
||||
assert loop.sessions.get_cached(key) is None
|
||||
terminate_exec_sessions.assert_awaited_once_with(key)
|
||||
|
||||
loop.stop()
|
||||
await loop.bus.publish_inbound(_message(key, "wake"))
|
||||
await asyncio.wait_for(run_task, timeout=2)
|
||||
@@ -9,7 +9,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from agent.runner_helpers import make_run_spec
|
||||
from nanobot.agent.automation_turns import publish_next_deferred_turn
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
@@ -1048,11 +1047,7 @@ async def test_cron_turn_deferred_while_session_active(tmp_path):
|
||||
assert loop._cron_turns.deferred_queues[session_key] == [msg]
|
||||
assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"}
|
||||
|
||||
await publish_next_deferred_turn(
|
||||
deferred_queues=loop._cron_turns.deferred_queues,
|
||||
publish_inbound=loop.bus.publish_inbound,
|
||||
session_key=session_key,
|
||||
)
|
||||
await loop._cron_turns.publish_next_deferred(session_key)
|
||||
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
||||
assert queued is msg
|
||||
assert session_key not in loop._cron_turns.deferred_queues
|
||||
@@ -1102,11 +1097,7 @@ async def test_local_trigger_turn_deferred_while_session_active(tmp_path):
|
||||
assert loop._local_trigger_turns.deferred_queues[session_key] == [msg]
|
||||
assert loop.pending_local_trigger_ids_for_session(session_key) == {"trg_123"}
|
||||
|
||||
assert await publish_next_deferred_turn(
|
||||
deferred_queues=loop._local_trigger_turns.deferred_queues,
|
||||
publish_inbound=loop.bus.publish_inbound,
|
||||
session_key=session_key,
|
||||
) is True
|
||||
assert await loop._local_trigger_turns.publish_next_deferred(session_key) is True
|
||||
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
||||
assert queued is msg
|
||||
assert session_key not in loop._local_trigger_turns.deferred_queues
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Tests for CLI Apps loop helpers."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nanobot.apps.cli.service import CliAppManager
|
||||
from nanobot.apps.cli.utils import runtime_lines_for_request, session_extra
|
||||
from nanobot.apps.cli.utils import runtime_lines, session_extra
|
||||
|
||||
|
||||
def test_session_extra_returns_cli_apps_only_when_present() -> None:
|
||||
@@ -28,9 +30,8 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
|
||||
}
|
||||
)
|
||||
|
||||
lines = runtime_lines_for_request(
|
||||
"please use @zoom tonight; ignore @krita?",
|
||||
{},
|
||||
lines = runtime_lines(
|
||||
SimpleNamespace(content="please use @zoom tonight; ignore @krita?", metadata={}),
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
@@ -38,19 +39,21 @@ 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):
|
||||
lines = runtime_lines_for_request(
|
||||
"please use @zoom tonight",
|
||||
{
|
||||
"cli_apps": [{
|
||||
"name": "zoom",
|
||||
"entry_point": "cli-anything-zoom",
|
||||
"display_name": "Zoom",
|
||||
}],
|
||||
},
|
||||
lines = runtime_lines(
|
||||
SimpleNamespace(
|
||||
content="please use @zoom tonight",
|
||||
metadata={
|
||||
"cli_apps": [{
|
||||
"name": "zoom",
|
||||
"entry_point": "cli-anything-zoom",
|
||||
"display_name": "Zoom",
|
||||
}],
|
||||
},
|
||||
),
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
@@ -58,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)
|
||||
|
||||
@@ -73,17 +73,3 @@ def test_flush_all_includes_live_sessions_outside_strong_cache(tmp_path, monkeyp
|
||||
|
||||
assert manager.flush_all() == 2
|
||||
assert set(saved) == {("test:active", True), ("test:other", True)}
|
||||
|
||||
|
||||
def test_transient_session_never_reaches_storage(tmp_path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create_transient("websocket:temporary-test")
|
||||
session.add_message("user", "secret")
|
||||
|
||||
manager.save(session, fsync=True)
|
||||
|
||||
assert manager.get_cached(session.key) is session
|
||||
assert manager.read_session_file(session.key) is None
|
||||
assert list(manager.sessions_dir.glob("*.jsonl")) == []
|
||||
manager.invalidate(session.key)
|
||||
assert manager.get_cached(session.key) is None
|
||||
|
||||
@@ -6,6 +6,7 @@ from zipfile import ZipFile
|
||||
import pytest
|
||||
|
||||
from nanobot.utils.document import (
|
||||
SUPPORTED_EXTENSIONS,
|
||||
PdfSafetyError,
|
||||
_is_text_extension,
|
||||
extract_pdf_pages,
|
||||
@@ -13,6 +14,31 @@ from nanobot.utils.document import (
|
||||
)
|
||||
|
||||
|
||||
class TestSupportedExtensions:
|
||||
"""Test the SUPPORTED_EXTENSIONS constant."""
|
||||
|
||||
def test_supported_extensions_include_common_formats(self):
|
||||
"""Test that common document formats are included."""
|
||||
# Document formats
|
||||
assert ".pdf" in SUPPORTED_EXTENSIONS
|
||||
assert ".docx" in SUPPORTED_EXTENSIONS
|
||||
assert ".xlsx" in SUPPORTED_EXTENSIONS
|
||||
assert ".pptx" in SUPPORTED_EXTENSIONS
|
||||
|
||||
# Text formats
|
||||
assert ".txt" in SUPPORTED_EXTENSIONS
|
||||
assert ".md" in SUPPORTED_EXTENSIONS
|
||||
assert ".csv" in SUPPORTED_EXTENSIONS
|
||||
assert ".json" in SUPPORTED_EXTENSIONS
|
||||
assert ".yaml" in SUPPORTED_EXTENSIONS
|
||||
assert ".yml" in SUPPORTED_EXTENSIONS
|
||||
|
||||
# Image formats
|
||||
assert ".png" in SUPPORTED_EXTENSIONS
|
||||
assert ".jpg" in SUPPORTED_EXTENSIONS
|
||||
assert ".jpeg" in SUPPORTED_EXTENSIONS
|
||||
|
||||
|
||||
class TestExtractText:
|
||||
"""Test the extract_text function."""
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import os
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools import file_state
|
||||
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool
|
||||
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, _find_match
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -68,6 +68,41 @@ class TestDeleteLineCleanup:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSmartQuoteNormalization:
|
||||
"""_find_match should handle curly ↔ straight quote fallback."""
|
||||
|
||||
def test_curly_double_quotes_match_straight(self):
|
||||
content = 'She said \u201chello\u201d to him'
|
||||
old_text = 'She said "hello" to him'
|
||||
match, count = _find_match(content, old_text)
|
||||
assert match is not None
|
||||
assert count == 1
|
||||
# Returned match should be the ORIGINAL content with curly quotes
|
||||
assert "\u201c" in match
|
||||
|
||||
def test_curly_single_quotes_match_straight(self):
|
||||
content = "it\u2019s a test"
|
||||
old_text = "it's a test"
|
||||
match, count = _find_match(content, old_text)
|
||||
assert match is not None
|
||||
assert count == 1
|
||||
assert "\u2019" in match
|
||||
|
||||
def test_straight_matches_curly_in_old_text(self):
|
||||
content = 'x = "hello"'
|
||||
old_text = 'x = \u201chello\u201d'
|
||||
match, count = _find_match(content, old_text)
|
||||
assert match is not None
|
||||
assert count == 1
|
||||
|
||||
def test_exact_match_still_preferred_over_quote_normalization(self):
|
||||
content = 'x = "hello"'
|
||||
old_text = 'x = "hello"'
|
||||
match, count = _find_match(content, old_text)
|
||||
assert match == old_text
|
||||
assert count == 1
|
||||
|
||||
|
||||
class TestQuoteStylePreservation:
|
||||
"""When quote-normalized matching occurs, replacement should preserve actual quote style."""
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from nanobot.agent.tools.filesystem import (
|
||||
ListDirTool,
|
||||
ReadFileTool,
|
||||
WriteFileTool,
|
||||
_find_match,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -115,6 +116,52 @@ class TestReadFileTool:
|
||||
assert "Maximum is 100 MiB" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_match (unit tests for the helper)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFindMatch:
|
||||
|
||||
def test_exact_match(self):
|
||||
match, count = _find_match("hello world", "world")
|
||||
assert match == "world"
|
||||
assert count == 1
|
||||
|
||||
def test_exact_no_match(self):
|
||||
match, count = _find_match("hello world", "xyz")
|
||||
assert match is None
|
||||
assert count == 0
|
||||
|
||||
def test_crlf_normalisation(self):
|
||||
# Caller normalises CRLF before calling _find_match, so test with
|
||||
# pre-normalised content to verify exact match still works.
|
||||
content = "line1\nline2\nline3"
|
||||
old_text = "line1\nline2\nline3"
|
||||
match, count = _find_match(content, old_text)
|
||||
assert match is not None
|
||||
assert count == 1
|
||||
|
||||
def test_line_trim_fallback(self):
|
||||
content = " def foo():\n pass\n"
|
||||
old_text = "def foo():\n pass"
|
||||
match, count = _find_match(content, old_text)
|
||||
assert match is not None
|
||||
assert count == 1
|
||||
# The returned match should be the *original* indented text
|
||||
assert " def foo():" in match
|
||||
|
||||
def test_line_trim_multiple_candidates(self):
|
||||
content = " a\n b\n a\n b\n"
|
||||
old_text = "a\nb"
|
||||
match, count = _find_match(content, old_text)
|
||||
assert count == 2
|
||||
|
||||
def test_empty_old_text(self):
|
||||
match, count = _find_match("hello", "")
|
||||
# Empty string is always "in" any string via exact match
|
||||
assert match == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EditFileTool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -9,6 +9,7 @@ from nanobot.utils.file_edit_events import (
|
||||
build_file_edit_start_event,
|
||||
build_unified_diff_payload,
|
||||
line_diff_stats,
|
||||
prepare_file_edit_tracker,
|
||||
prepare_file_edit_trackers,
|
||||
read_file_snapshot,
|
||||
)
|
||||
@@ -43,14 +44,15 @@ def test_write_file_start_tracks_snapshot_and_end_emits_exact_diff(tmp_path: Pat
|
||||
target = tmp_path / "notes.txt"
|
||||
target.write_text("old\nkeep\n", encoding="utf-8")
|
||||
params = {"path": "notes.txt", "content": "new\nkeep\nextra\n"}
|
||||
trackers = prepare_file_edit_trackers(
|
||||
tracker = prepare_file_edit_tracker(
|
||||
call_id="call-write",
|
||||
tool_name="write_file",
|
||||
tool=_write_tool(tmp_path),
|
||||
workspace=tmp_path,
|
||||
params=params,
|
||||
)
|
||||
[tracker] = trackers
|
||||
|
||||
assert tracker is not None
|
||||
start = build_file_edit_start_event(tracker)
|
||||
assert start == {
|
||||
"version": 1,
|
||||
@@ -101,14 +103,15 @@ def test_unified_diff_payload_truncates_large_diffs() -> None:
|
||||
def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
|
||||
target = tmp_path / "data.bin"
|
||||
target.write_bytes(b"\x00\x01before")
|
||||
trackers = prepare_file_edit_trackers(
|
||||
tracker = prepare_file_edit_tracker(
|
||||
call_id="call-bin",
|
||||
tool_name="edit_file",
|
||||
tool=_edit_tool(tmp_path),
|
||||
workspace=tmp_path,
|
||||
params={"path": "data.bin", "old_text": "before", "new_text": "after"},
|
||||
)
|
||||
[tracker] = trackers
|
||||
|
||||
assert tracker is not None
|
||||
assert not read_file_snapshot(target).countable
|
||||
target.write_bytes(b"\x00\x01after")
|
||||
event = build_file_edit_end_event(tracker)
|
||||
@@ -120,14 +123,15 @@ def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
|
||||
def test_binary_before_file_is_reported_but_not_counted(tmp_path: Path) -> None:
|
||||
target = tmp_path / "data.bin"
|
||||
target.write_bytes(b"\x00\x01before")
|
||||
trackers = prepare_file_edit_trackers(
|
||||
tracker = prepare_file_edit_tracker(
|
||||
call_id="call-bin",
|
||||
tool_name="write_file",
|
||||
tool=_write_tool(tmp_path),
|
||||
workspace=tmp_path,
|
||||
params={"path": "data.bin", "content": "after\n"},
|
||||
)
|
||||
[tracker] = trackers
|
||||
|
||||
assert tracker is not None
|
||||
target.write_text("after\n", encoding="utf-8")
|
||||
event = build_file_edit_end_event(tracker)
|
||||
assert event["binary"] is True
|
||||
@@ -211,14 +215,15 @@ def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path)
|
||||
def test_oversized_file_is_reported_but_not_counted(tmp_path: Path) -> None:
|
||||
target = tmp_path / "large.txt"
|
||||
params = {"path": "large.txt", "content": "x"}
|
||||
trackers = prepare_file_edit_trackers(
|
||||
tracker = prepare_file_edit_tracker(
|
||||
call_id="call-large",
|
||||
tool_name="write_file",
|
||||
tool=_write_tool(tmp_path),
|
||||
workspace=tmp_path,
|
||||
params=params,
|
||||
)
|
||||
[tracker] = trackers
|
||||
|
||||
assert tracker is not None
|
||||
target.write_text("x" * (2 * 1024 * 1024 + 1), encoding="utf-8")
|
||||
event = build_file_edit_end_event(tracker)
|
||||
assert event["binary"] is True
|
||||
@@ -227,11 +232,11 @@ def test_oversized_file_is_reported_but_not_counted(tmp_path: Path) -> None:
|
||||
assert "diff" not in event
|
||||
|
||||
|
||||
def test_untracked_tools_do_not_prepare_file_edit_trackers(tmp_path: Path) -> None:
|
||||
assert prepare_file_edit_trackers(
|
||||
def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None:
|
||||
assert prepare_file_edit_tracker(
|
||||
call_id="call-exec",
|
||||
tool_name="exec",
|
||||
tool=None,
|
||||
workspace=tmp_path,
|
||||
params={"path": "created-by-shell.txt"},
|
||||
) == []
|
||||
) is None
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Tests for GitStore core operations."""
|
||||
"""Tests for GitStore — line_ages() and core git operations."""
|
||||
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.utils.gitstore import GitStore
|
||||
from nanobot.utils.gitstore import GitStore, GitStoreError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -17,6 +18,89 @@ def git(tmp_path):
|
||||
return g
|
||||
|
||||
|
||||
class TestLineAges:
|
||||
def test_returns_empty_when_not_initialized(self, tmp_path):
|
||||
"""line_ages should return [] if the git repo is not initialized."""
|
||||
git = GitStore(tmp_path, tracked_files=["MEMORY.md"])
|
||||
assert git.line_ages("MEMORY.md") == []
|
||||
|
||||
def test_returns_empty_for_missing_file(self, git):
|
||||
"""line_ages should return [] for a file that doesn't exist."""
|
||||
assert git.line_ages("SOUL.md") == []
|
||||
|
||||
def test_returns_empty_for_empty_file(self, git, tmp_path):
|
||||
"""line_ages should return [] for an empty tracked file."""
|
||||
(tmp_path / "SOUL.md").write_text("", encoding="utf-8")
|
||||
git.auto_commit("empty soul")
|
||||
assert git.line_ages("SOUL.md") == []
|
||||
|
||||
def test_one_age_per_line(self, git, tmp_path):
|
||||
"""line_ages should return one entry per line in the file."""
|
||||
content = "# Memory\n\n## Section A\n- item 1\n"
|
||||
(tmp_path / "MEMORY.md").write_text(content, encoding="utf-8")
|
||||
git.auto_commit("initial")
|
||||
ages = git.line_ages("MEMORY.md")
|
||||
assert len(ages) == len(content.splitlines())
|
||||
|
||||
def test_fresh_lines_have_age_zero(self, git, tmp_path):
|
||||
"""Lines committed today should have age_days=0."""
|
||||
(tmp_path / "MEMORY.md").write_text("## A\n- x\n", encoding="utf-8")
|
||||
git.auto_commit("initial")
|
||||
ages = git.line_ages("MEMORY.md")
|
||||
assert all(a.age_days == 0 for a in ages)
|
||||
|
||||
def test_age_differentiates_across_days(self, git, tmp_path):
|
||||
"""Lines committed today should show correct age when 'now' is mocked forward."""
|
||||
(tmp_path / "MEMORY.md").write_text("## A\n- x\n", encoding="utf-8")
|
||||
git.auto_commit("initial")
|
||||
|
||||
future_now = datetime.now(tz=timezone.utc) + timedelta(days=30)
|
||||
with patch("nanobot.utils.gitstore.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = future_now
|
||||
mock_dt.fromtimestamp = datetime.fromtimestamp
|
||||
ages = git.line_ages("MEMORY.md")
|
||||
|
||||
assert len(ages) == 2
|
||||
assert all(a.age_days == 30 for a in ages)
|
||||
|
||||
def test_annotate_failure_is_explicit(self, git, tmp_path):
|
||||
(tmp_path / "MEMORY.md").write_text("important\n", encoding="utf-8")
|
||||
git.auto_commit("initial")
|
||||
|
||||
with patch("dulwich.porcelain.annotate", side_effect=OSError("broken repo")):
|
||||
with pytest.raises(GitStoreError, match="annotation failed"):
|
||||
git.line_ages("MEMORY.md")
|
||||
|
||||
def test_partial_edit_only_updates_changed_lines(self, git, tmp_path):
|
||||
"""Only modified lines should reflect the new commit's timestamp."""
|
||||
now = datetime(2026, 5, 1, tzinfo=timezone.utc)
|
||||
old = now - timedelta(days=30)
|
||||
|
||||
(tmp_path / "MEMORY.md").write_text(
|
||||
"# Memory\n\n## A\n- old\n\n## B\n- keep\n", encoding="utf-8"
|
||||
)
|
||||
with patch("dulwich.worktree.time.time", return_value=old.timestamp()):
|
||||
git.auto_commit("commit1")
|
||||
|
||||
# Only modify section A
|
||||
(tmp_path / "MEMORY.md").write_text(
|
||||
"# Memory\n\n## A\n- new\n\n## B\n- keep\n", encoding="utf-8"
|
||||
)
|
||||
with patch("dulwich.worktree.time.time", return_value=now.timestamp()):
|
||||
git.auto_commit("commit2")
|
||||
|
||||
with patch("nanobot.utils.gitstore.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = now
|
||||
mock_dt.fromtimestamp = datetime.fromtimestamp
|
||||
ages = git.line_ages("MEMORY.md")
|
||||
|
||||
lines = (tmp_path / "MEMORY.md").read_text(encoding="utf-8").splitlines()
|
||||
assert len(ages) == len(lines)
|
||||
age_by_line = {line: age.age_days for line, age in zip(lines, ages, strict=True)}
|
||||
assert age_by_line["- new"] == 0
|
||||
assert age_by_line["- keep"] == 30
|
||||
|
||||
|
||||
class TestSummarizeWorkingTree:
|
||||
"""Ground-truth diff summary used to keep Dream audit records honest."""
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfoNotFoundError
|
||||
|
||||
import pytest
|
||||
import tiktoken
|
||||
|
||||
from nanobot.utils import helpers
|
||||
from nanobot.utils.helpers import (
|
||||
_write_text_atomic,
|
||||
content_with_media_breadcrumbs,
|
||||
current_time_str,
|
||||
split_message,
|
||||
truncate_text_to_tokens,
|
||||
)
|
||||
@@ -48,6 +51,11 @@ def test_truncate_text_to_tokens_non_positive_budget_returns_text():
|
||||
assert truncate_text_to_tokens(text, 0) == text
|
||||
|
||||
|
||||
def test_current_time_str_rejects_unknown_timezone():
|
||||
with pytest.raises(ZoneInfoNotFoundError):
|
||||
current_time_str("Not/AZone")
|
||||
|
||||
|
||||
def test_content_with_media_breadcrumbs_preserves_valid_paths():
|
||||
assert content_with_media_breadcrumbs(
|
||||
"user",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -249,6 +250,8 @@
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||
|
||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
|
||||
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
|
||||
@@ -1329,6 +1332,8 @@
|
||||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
+22
-213
@@ -61,11 +61,7 @@ import {
|
||||
createRuntimeHost,
|
||||
toRuntimeSurface,
|
||||
} from "@/lib/runtime";
|
||||
import { projectNameFromPath, scopeWithAccessMode } from "@/lib/workspace";
|
||||
import {
|
||||
createTemporaryChatSession,
|
||||
deriveTemporaryChatTitle,
|
||||
} from "@/lib/temporary-chat";
|
||||
import { projectNameFromPath } from "@/lib/workspace";
|
||||
|
||||
type BootState =
|
||||
| { status: "loading" }
|
||||
@@ -100,8 +96,8 @@ type ShellRoute = {
|
||||
view: ShellView;
|
||||
activeKey: string | null;
|
||||
settingsSection: SettingsSectionKey;
|
||||
temporary?: boolean;
|
||||
};
|
||||
|
||||
const loadSettingsView = () => import("@/components/settings/SettingsView");
|
||||
const SettingsView = lazy(async () => {
|
||||
const module = await loadSettingsView();
|
||||
@@ -231,22 +227,6 @@ function readShellRoute(): ShellRoute {
|
||||
if (path === "/skills") {
|
||||
return { view: "skills", activeKey, settingsSection: "skills" };
|
||||
}
|
||||
if (path.startsWith("/temporary/")) {
|
||||
const encoded = path.slice("/temporary/".length);
|
||||
try {
|
||||
const chatId = decodeURIComponent(encoded).trim();
|
||||
return chatId
|
||||
? {
|
||||
view: "chat",
|
||||
activeKey: `websocket:${chatId}`,
|
||||
settingsSection: "overview",
|
||||
temporary: true,
|
||||
}
|
||||
: defaultShellRoute();
|
||||
} catch {
|
||||
return defaultShellRoute();
|
||||
}
|
||||
}
|
||||
if (path.startsWith("/chat/")) {
|
||||
const encoded = path.slice("/chat/".length);
|
||||
try {
|
||||
@@ -263,10 +243,6 @@ function readShellRoute(): ShellRoute {
|
||||
|
||||
function shellRouteHash(route: ShellRoute): string {
|
||||
if (route.view === "chat") {
|
||||
if (route.temporary && route.activeKey?.startsWith("websocket:")) {
|
||||
const chatId = route.activeKey.slice("websocket:".length);
|
||||
return `#/temporary/${encodeURIComponent(chatId)}`;
|
||||
}
|
||||
return route.activeKey
|
||||
? `#/chat/${encodeURIComponent(route.activeKey)}`
|
||||
: "#/new";
|
||||
@@ -985,8 +961,6 @@ function Shell({
|
||||
initialRouteRef.current.activeKey,
|
||||
);
|
||||
const [view, setView] = useState<ShellView>(initialRouteRef.current.view);
|
||||
const [temporarySessions, setTemporarySessions] = useState<Record<string, ChatSummary>>({});
|
||||
const [temporaryChatEnabled, setTemporaryChatEnabled] = useState(false);
|
||||
const [settingsInitialSection, setSettingsInitialSection] =
|
||||
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
|
||||
const [hostSidebarOpen, setHostSidebarOpen] =
|
||||
@@ -1031,26 +1005,11 @@ function Shell({
|
||||
const runningChatIdsRef = useRef<Set<string>>(new Set());
|
||||
const activeChatIdRef = useRef<string | null>(null);
|
||||
const pendingCreatedSessionKeyRef = useRef<string | null>(null);
|
||||
const temporarySessionsRef = useRef<Record<string, ChatSummary>>({});
|
||||
const hostSidebarPreviewCloseTimerRef = useRef<number | null>(null);
|
||||
const effectiveRuntimeSurface =
|
||||
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
|
||||
const showHostChrome = effectiveRuntimeSurface === "native";
|
||||
const showMainSidebar = view !== "settings";
|
||||
const activeTemporarySession = activeKey ? temporarySessions[activeKey] ?? null : null;
|
||||
const temporaryChatId = activeTemporarySession?.chatId ?? null;
|
||||
const temporaryChatActive = view === "chat" && temporaryChatId !== null;
|
||||
const temporaryChatRequested = temporaryChatActive || temporaryChatEnabled;
|
||||
const temporarySessionList = useMemo(
|
||||
() => Object.values(temporarySessions).sort((a, b) => (
|
||||
Date.parse(b.createdAt ?? "") - Date.parse(a.createdAt ?? "")
|
||||
)),
|
||||
[temporarySessions],
|
||||
);
|
||||
const temporaryChatIds = useMemo(
|
||||
() => temporarySessionList.map((session) => session.chatId),
|
||||
[temporarySessionList],
|
||||
);
|
||||
|
||||
const navigate = useCallback(
|
||||
(route: ShellRoute, options?: { replace?: boolean }) => {
|
||||
@@ -1077,21 +1036,6 @@ function Shell({
|
||||
return () => window.removeEventListener("hashchange", applyRoute);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
temporarySessionsRef.current = temporarySessions;
|
||||
}, [temporarySessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "chat" && !activeKey) return;
|
||||
setTemporaryChatEnabled(false);
|
||||
}, [activeKey, view]);
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const session of Object.values(temporarySessionsRef.current)) {
|
||||
client.discardTemporaryChat(session.chatId);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchSettings(getToken())
|
||||
@@ -1177,9 +1121,8 @@ function Shell({
|
||||
|
||||
const activeSession = useMemo<ChatSummary | null>(() => {
|
||||
if (!activeKey) return null;
|
||||
if (temporarySessions[activeKey]) return temporarySessions[activeKey];
|
||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||
}, [sessions, activeKey, temporarySessions]);
|
||||
}, [sessions, activeKey]);
|
||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||
const activeChatId = activeSession?.chatId ?? null;
|
||||
@@ -1194,11 +1137,6 @@ function Shell({
|
||||
});
|
||||
}, [activeChatId]);
|
||||
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
|
||||
if (temporaryChatRequested) {
|
||||
return workspaces?.default_scope
|
||||
? normalizeWorkspaceScope(scopeWithAccessMode(workspaces.default_scope, "restricted"))
|
||||
: null;
|
||||
}
|
||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||
return workspaceOverrides[activeChatId];
|
||||
}
|
||||
@@ -1210,7 +1148,6 @@ function Shell({
|
||||
activeChatId,
|
||||
activeSession?.workspaceScope,
|
||||
draftWorkspaceScope,
|
||||
temporaryChatRequested,
|
||||
workspaceOverrides,
|
||||
workspaces?.default_scope,
|
||||
]);
|
||||
@@ -1250,17 +1187,11 @@ function Shell({
|
||||
if (pendingCreatedKey && sessions.some((session) => session.key === pendingCreatedKey)) {
|
||||
pendingCreatedSessionKeyRef.current = null;
|
||||
}
|
||||
if (!activeKey) return;
|
||||
const currentRoute = readShellRoute();
|
||||
if (currentRoute.temporary) {
|
||||
if (temporarySessions[activeKey]) return;
|
||||
navigate(defaultShellRoute(), { replace: true });
|
||||
return;
|
||||
}
|
||||
if (sessions.some((session) => session.key === activeKey)) return;
|
||||
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"
|
||||
? defaultShellRoute()
|
||||
@@ -1270,7 +1201,7 @@ function Shell({
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [activeKey, loading, navigate, sessions, temporarySessions]);
|
||||
}, [activeKey, loading, navigate, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onSessionUpdate((chatId, scope, workspaceScope) => {
|
||||
@@ -1429,22 +1360,14 @@ function Shell({
|
||||
const next = normalizeWorkspaceScope(scope);
|
||||
setWorkspaceError(null);
|
||||
if (activeChatId) {
|
||||
if (temporaryChatActive) {
|
||||
setTemporarySessions((current) => {
|
||||
if (!activeKey || !current[activeKey]) return current;
|
||||
return {
|
||||
...current,
|
||||
[activeKey]: { ...current[activeKey], workspaceScope: next },
|
||||
};
|
||||
});
|
||||
} else if (!activeChatRunning) {
|
||||
if (!activeChatRunning) {
|
||||
client.setWorkspaceScope(activeChatId, next);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setDraftWorkspaceScope(next);
|
||||
},
|
||||
[activeChatId, activeChatRunning, activeKey, client, temporaryChatActive],
|
||||
[activeChatId, activeChatRunning, client],
|
||||
);
|
||||
|
||||
const onCreateChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null) => {
|
||||
@@ -1475,45 +1398,6 @@ function Shell({
|
||||
}
|
||||
}, [activeWorkspaceScope, createChat, navigate, t]);
|
||||
|
||||
const onCreateTemporaryChat = useCallback(
|
||||
async (
|
||||
workspaceScope?: WorkspaceScopePayload | null,
|
||||
initialMessage?: string,
|
||||
) => {
|
||||
try {
|
||||
const chatId = await client.newTemporaryChat();
|
||||
const session = createTemporaryChatSession(chatId);
|
||||
const restrictedScope = workspaceScope
|
||||
? normalizeWorkspaceScope(scopeWithAccessMode(workspaceScope, "restricted"))
|
||||
: null;
|
||||
const nextSession: ChatSummary = {
|
||||
...session,
|
||||
preview: initialMessage ?? "",
|
||||
...(restrictedScope ? { workspaceScope: restrictedScope } : {}),
|
||||
};
|
||||
setTemporarySessions((current) => ({
|
||||
...current,
|
||||
[nextSession.key]: nextSession,
|
||||
}));
|
||||
setTemporaryChatEnabled(false);
|
||||
setWorkspaceError(null);
|
||||
setSessionSearchOpen(false);
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: nextSession.key,
|
||||
settingsSection: "overview",
|
||||
temporary: true,
|
||||
});
|
||||
setMobileSidebarOpen(false);
|
||||
return nextSession.chatId;
|
||||
} catch (error) {
|
||||
console.error("Failed to create temporary chat", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[client, navigate],
|
||||
);
|
||||
|
||||
const onForkChat = useCallback(async (
|
||||
sourceChatId: string,
|
||||
beforeUserIndex: number,
|
||||
@@ -1543,20 +1427,12 @@ function Shell({
|
||||
|
||||
const onNewChat = useCallback(() => {
|
||||
navigate(defaultShellRoute());
|
||||
setTemporaryChatEnabled(false);
|
||||
setDraftWorkspaceScope(null);
|
||||
setWorkspaceError(null);
|
||||
setSessionSearchOpen(false);
|
||||
setMobileSidebarOpen(false);
|
||||
}, [navigate]);
|
||||
|
||||
const onTemporaryChatEnabledChange = useCallback((enabled: boolean) => {
|
||||
if (view !== "chat" || activeKey) return;
|
||||
setTemporaryChatEnabled(enabled);
|
||||
setDraftWorkspaceScope(null);
|
||||
setWorkspaceError(null);
|
||||
}, [activeKey, view]);
|
||||
|
||||
const onNewChatInProject = useCallback(
|
||||
(projectPath: string, projectName: string) => {
|
||||
const base = workspaces?.default_scope ?? activeWorkspaceScope;
|
||||
@@ -1565,7 +1441,6 @@ function Shell({
|
||||
onNewChat();
|
||||
return;
|
||||
}
|
||||
setTemporaryChatEnabled(false);
|
||||
navigate(defaultShellRoute());
|
||||
setDraftWorkspaceScope(normalizeWorkspaceScope({
|
||||
project_path: trimmed,
|
||||
@@ -1581,9 +1456,7 @@ function Shell({
|
||||
|
||||
const onSelectChat = useCallback(
|
||||
(key: string) => {
|
||||
const selectedTemporary = temporarySessionsRef.current[key];
|
||||
const selected = selectedTemporary
|
||||
?? sessions.find((session) => session.key === key);
|
||||
const selected = sessions.find((session) => session.key === key);
|
||||
const selectedChatId = selected?.chatId;
|
||||
if (selectedChatId) {
|
||||
setUpdatedChatIds((current) => {
|
||||
@@ -1599,38 +1472,12 @@ function Shell({
|
||||
setDraftWorkspaceScope(null);
|
||||
}
|
||||
setWorkspaceError(null);
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: key,
|
||||
settingsSection: "overview",
|
||||
...(selectedTemporary ? { temporary: true } : {}),
|
||||
});
|
||||
navigate({ view: "chat", activeKey: key, settingsSection: "overview" });
|
||||
setMobileSidebarOpen(false);
|
||||
},
|
||||
[navigate, sessions],
|
||||
);
|
||||
|
||||
const onCloseTemporaryChat = useCallback((key: string) => {
|
||||
const session = temporarySessionsRef.current[key];
|
||||
if (!session) return;
|
||||
const remaining = temporarySessionList.filter((item) => item.key !== key);
|
||||
const nextSessions = Object.fromEntries(remaining.map((item) => [item.key, item]));
|
||||
temporarySessionsRef.current = nextSessions;
|
||||
setTemporarySessions(nextSessions);
|
||||
client.discardTemporaryChat(session.chatId);
|
||||
if (activeKey === key) {
|
||||
if (remaining.length === 0) setDraftWorkspaceScope(null);
|
||||
setWorkspaceError(null);
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: remaining[0]?.key ?? null,
|
||||
settingsSection: "overview",
|
||||
...(remaining[0] ? { temporary: true } : {}),
|
||||
}, { replace: true });
|
||||
}
|
||||
setMobileSidebarOpen(false);
|
||||
}, [activeKey, client, navigate, temporarySessionList]);
|
||||
|
||||
const onTogglePin = useCallback(
|
||||
(key: string) => {
|
||||
void updateSidebarState((current) => {
|
||||
@@ -1913,11 +1760,6 @@ function Shell({
|
||||
nextRunning.delete(chatId);
|
||||
runningChatIdsRef.current = nextRunning;
|
||||
setRunningChatIds(nextRunning);
|
||||
if (
|
||||
Object.values(temporarySessionsRef.current).some(
|
||||
(session) => session.chatId === chatId,
|
||||
)
|
||||
) return;
|
||||
setUpdatedChatIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (activeChatIdRef.current === chatId) {
|
||||
@@ -1930,24 +1772,6 @@ function Shell({
|
||||
});
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
let wasOpen = client.status === "open";
|
||||
return client.onStatus((status) => {
|
||||
if (status === "open") {
|
||||
wasOpen = true;
|
||||
return;
|
||||
}
|
||||
if (!wasOpen) return;
|
||||
wasOpen = false;
|
||||
if (Object.keys(temporarySessionsRef.current).length === 0) return;
|
||||
temporarySessionsRef.current = {};
|
||||
setTemporarySessions({});
|
||||
if (readShellRoute().temporary) {
|
||||
navigate(defaultShellRoute(), { replace: true });
|
||||
}
|
||||
});
|
||||
}, [client, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onStatus((status) => {
|
||||
const startedAt = (() => {
|
||||
@@ -1976,10 +1800,7 @@ function Shell({
|
||||
});
|
||||
}, [client, t]);
|
||||
|
||||
const onTurnEnd = useDeferredTitleRefresh(
|
||||
temporaryChatActive ? null : activeSession,
|
||||
refresh,
|
||||
);
|
||||
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
if (!pendingDelete) return;
|
||||
@@ -2069,9 +1890,7 @@ function Shell({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const headerTitle = temporaryChatActive
|
||||
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
|
||||
: activeSession
|
||||
const headerTitle = activeSession
|
||||
? sidebarState.title_overrides[activeSession.key] ||
|
||||
activeSession.title ||
|
||||
deriveTitle(activeSession.preview, t("chat.newChat"))
|
||||
@@ -2109,13 +1928,11 @@ function Shell({
|
||||
|
||||
const sidebarProps = {
|
||||
sessions,
|
||||
temporarySessions: temporarySessionList,
|
||||
activeKey: view === "chat" ? activeKey : null,
|
||||
loading,
|
||||
newChatActive: view === "chat" && activeKey === null,
|
||||
onNewChat,
|
||||
onSelect: onSelectChat,
|
||||
onCloseTemporaryChat,
|
||||
onRequestDelete,
|
||||
onTogglePin,
|
||||
onRequestRename,
|
||||
@@ -2301,16 +2118,10 @@ function Shell({
|
||||
session={activeSession}
|
||||
sessions={sessions}
|
||||
title={headerTitle}
|
||||
temporary={temporaryChatRequested}
|
||||
temporaryChatIds={temporaryChatIds}
|
||||
temporaryChatEnabled={temporaryChatEnabled}
|
||||
onTemporaryChatEnabledChange={
|
||||
!activeKey ? onTemporaryChatEnabledChange : undefined
|
||||
}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onNewChat={onNewChat}
|
||||
onCreateChat={temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat}
|
||||
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
||||
onCreateChat={onCreateChat}
|
||||
onForkChat={onForkChat}
|
||||
onTurnEnd={onTurnEnd}
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
@@ -2389,16 +2200,14 @@ function Shell({
|
||||
</Suspense>
|
||||
) : null}
|
||||
{restartToast ? (
|
||||
<div className="fixed left-1/2 top-[calc(0.75rem+env(safe-area-inset-top))] z-50 flex w-[min(32rem,calc(100vw-1rem))] -translate-x-1/2 flex-col items-center gap-2">
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
floatingSurfaceElevationClassName,
|
||||
"max-w-full rounded-full px-4 py-2 text-sm font-medium",
|
||||
)}
|
||||
>
|
||||
{restartToast}
|
||||
</div>
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
floatingSurfaceElevationClassName,
|
||||
"fixed left-1/2 top-[calc(0.75rem+env(safe-area-inset-top))] z-50 max-w-[calc(100vw-1rem)] -translate-x-1/2 rounded-full px-4 py-2 text-sm font-medium",
|
||||
)}
|
||||
>
|
||||
{restartToast}
|
||||
</div>
|
||||
) : null}
|
||||
<PairingCodePopup
|
||||
|
||||
@@ -4,20 +4,17 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
Folder,
|
||||
MessageCircleDashed,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -44,7 +41,6 @@ import {
|
||||
type ChatGroupLabels,
|
||||
} from "@/lib/chat-groups";
|
||||
import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag";
|
||||
import { deriveTemporaryChatTitle } from "@/lib/temporary-chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
||||
|
||||
@@ -54,10 +50,8 @@ const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]";
|
||||
|
||||
interface ChatListProps {
|
||||
sessions: ChatSummary[];
|
||||
temporarySessions?: ChatSummary[];
|
||||
activeKey: string | null;
|
||||
onSelect: (key: string) => void;
|
||||
onCloseTemporaryChat?: (key: string) => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
onTogglePin: (key: string) => void;
|
||||
onRequestRename: (key: string, label: string) => void;
|
||||
@@ -87,10 +81,8 @@ interface ChatListProps {
|
||||
|
||||
export const ChatList = memo(function ChatList({
|
||||
sessions,
|
||||
temporarySessions = [],
|
||||
activeKey,
|
||||
onSelect,
|
||||
onCloseTemporaryChat,
|
||||
onRequestDelete,
|
||||
onTogglePin,
|
||||
onRequestRename,
|
||||
@@ -196,7 +188,7 @@ export const ChatList = memo(function ChatList({
|
||||
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
|
||||
}, [showArchived, sort]);
|
||||
|
||||
if (loading && sessions.length === 0 && temporarySessions.length === 0) {
|
||||
if (loading && sessions.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
||||
{t("chat.loading")}
|
||||
@@ -204,7 +196,7 @@ export const ChatList = memo(function ChatList({
|
||||
);
|
||||
}
|
||||
|
||||
if (sessions.length === 0 && temporarySessions.length === 0) {
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-6 text-[12px] leading-5 text-muted-foreground/80">
|
||||
{emptyLabel ?? t("chat.noSessions")}
|
||||
@@ -245,16 +237,6 @@ export const ChatList = memo(function ChatList({
|
||||
data-chat-list-content
|
||||
className="relative min-w-0 space-y-3 px-2 py-1.5"
|
||||
>
|
||||
{temporarySessions.length > 0 ? (
|
||||
<TemporaryChatSection
|
||||
sessions={temporarySessions}
|
||||
activeKey={activeKey}
|
||||
activeRowRef={activeRowRef}
|
||||
running={running}
|
||||
onSelect={onSelect}
|
||||
onClose={onCloseTemporaryChat}
|
||||
/>
|
||||
) : null}
|
||||
{limitedGroups.map((group, index) => {
|
||||
const foldableChatsGroup = isFoldableChatsGroup(group);
|
||||
const foldedChatsGroup = isFoldedChatsGroup(group, collapsedGroups);
|
||||
@@ -515,78 +497,6 @@ export const ChatList = memo(function ChatList({
|
||||
);
|
||||
});
|
||||
|
||||
function TemporaryChatSection({
|
||||
sessions,
|
||||
activeKey,
|
||||
activeRowRef,
|
||||
running,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
sessions: ChatSummary[];
|
||||
activeKey: string | null;
|
||||
activeRowRef: RefObject<HTMLDivElement>;
|
||||
running: ReadonlySet<string>;
|
||||
onSelect: (key: string) => void;
|
||||
onClose?: (key: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<section aria-label={t("temporaryChat.sectionTitle")} className="relative z-[1]">
|
||||
<ChatsGroupHeader label={t("temporaryChat.sectionTitle")} />
|
||||
<ul className="space-y-0.5">
|
||||
{sessions.map((session) => {
|
||||
const active = session.key === activeKey;
|
||||
const title = deriveTemporaryChatTitle(session.preview, t("temporaryChat.title"));
|
||||
return (
|
||||
<li key={session.key} className="min-w-0">
|
||||
<div
|
||||
ref={active ? activeRowRef : undefined}
|
||||
data-temporary-chat-row={session.key}
|
||||
className={cn(
|
||||
"group flex min-h-8 min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]",
|
||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
||||
active
|
||||
? "text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(session.key)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
title={title}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden py-1.5 text-left"
|
||||
>
|
||||
<MessageCircleDashed
|
||||
className="h-3.5 w-3.5 shrink-0 text-[hsl(var(--temporary-foreground))]"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
<SessionActivityIndicator state={running.has(session.chatId) ? "running" : null} />
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("temporaryChat.closeAction", { title })}
|
||||
onClick={() => onClose(session.key)}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectGroupHeader({
|
||||
label,
|
||||
path,
|
||||
|
||||
@@ -52,8 +52,6 @@ import type {
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: UIMessage;
|
||||
/** Give temporary-chat user turns the dashed private-mode treatment. */
|
||||
temporary?: boolean;
|
||||
/** When false, hide this message's copy button. Default true. */
|
||||
showCopyAction?: boolean;
|
||||
cliApps?: CliAppInfo[];
|
||||
@@ -260,7 +258,6 @@ function UserDeliveryStatus({
|
||||
/** Render user turns as compact bubbles and assistant turns as document-like prose. */
|
||||
export function MessageBubble({
|
||||
message,
|
||||
temporary = false,
|
||||
showCopyAction = true,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
@@ -329,13 +326,9 @@ export function MessageBubble({
|
||||
) : null}
|
||||
{hasText ? (
|
||||
<p
|
||||
data-temporary-message={temporary ? "true" : undefined}
|
||||
className={cn(
|
||||
"ml-auto w-fit max-w-full min-w-0 rounded-[18px] px-4 py-2",
|
||||
"ml-auto w-fit max-w-full min-w-0 rounded-[18px] bg-secondary/70 px-4 py-2",
|
||||
"text-left text-[16px]/[1.75] whitespace-pre-wrap [overflow-wrap:anywhere]",
|
||||
temporary
|
||||
? "border border-dashed border-muted-foreground/40 bg-transparent"
|
||||
: "bg-secondary/70",
|
||||
)}
|
||||
>
|
||||
{messageText}
|
||||
@@ -731,7 +724,8 @@ function UserImageCell({
|
||||
aria-label={image.name ? `${openLabel}: ${image.name}` : openLabel}
|
||||
className={cn(
|
||||
tileClasses,
|
||||
"block cursor-zoom-in p-0",
|
||||
"block cursor-zoom-in p-0 transition-transform duration-150 motion-reduce:transition-none",
|
||||
"hover:scale-[1.01] hover:ring-2 hover:ring-primary/25",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -31,13 +31,11 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface SidebarProps {
|
||||
sessions: ChatSummary[];
|
||||
temporarySessions?: ChatSummary[];
|
||||
activeKey: string | null;
|
||||
loading: boolean;
|
||||
newChatActive: boolean;
|
||||
onNewChat: () => void;
|
||||
onSelect: (key: string) => void;
|
||||
onCloseTemporaryChat?: (key: string) => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
onTogglePin: (key: string) => void;
|
||||
onRequestRename: (key: string, label: string) => void;
|
||||
@@ -223,12 +221,10 @@ export function Sidebar(props: SidebarProps) {
|
||||
{!collapsed && (
|
||||
<ChatList
|
||||
sessions={props.sessions}
|
||||
temporarySessions={props.temporarySessions}
|
||||
activeKey={props.activeKey}
|
||||
loading={props.loading}
|
||||
emptyLabel={t("chat.noSessions")}
|
||||
onSelect={props.onSelect}
|
||||
onCloseTemporaryChat={props.onCloseTemporaryChat}
|
||||
onRequestDelete={props.onRequestDelete}
|
||||
onTogglePin={props.onTogglePin}
|
||||
onRequestRename={props.onRequestRename}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type Ref,
|
||||
} from "react";
|
||||
|
||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||
@@ -200,14 +199,12 @@ interface ThreadComposerProps {
|
||||
sessions?: ChatSummary[];
|
||||
skills?: SkillSummary[];
|
||||
onStop?: () => void;
|
||||
surfaceRef?: Ref<HTMLDivElement>;
|
||||
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
|
||||
/** Unix seconds from server; turn elapsed timer above input while set. */
|
||||
runStartedAt?: number | null;
|
||||
/** Sustained objective for this chat (WebSocket ``goal_state``). */
|
||||
goalState?: GoalStateWsPayload;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
workspaceControlsHidden?: boolean;
|
||||
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||
workspaceScopeDisabled?: boolean;
|
||||
@@ -954,12 +951,10 @@ export function ThreadComposer({
|
||||
sessions = [],
|
||||
skills = [],
|
||||
onStop,
|
||||
surfaceRef,
|
||||
onTranscribeAudio,
|
||||
runStartedAt = null,
|
||||
goalState,
|
||||
workspaceScope = null,
|
||||
workspaceControlsHidden = false,
|
||||
workspaceDefaultScope = null,
|
||||
workspaceControls = null,
|
||||
workspaceScopeDisabled = false,
|
||||
@@ -1010,18 +1005,17 @@ export function ThreadComposer({
|
||||
() => queuedPromptsStorageKey(pendingQueueKey),
|
||||
[pendingQueueKey],
|
||||
);
|
||||
const projectPickerAvailable =
|
||||
const showProjectPicker =
|
||||
isHero
|
||||
&& !!workspaceDefaultScope
|
||||
&& !!onWorkspaceScopeChange
|
||||
&& workspaceControls?.can_change_project !== false;
|
||||
const showProjectPicker = projectPickerAvailable && !workspaceControlsHidden;
|
||||
|
||||
useEffect(() => {
|
||||
secondEnterPromptIdRef.current = null;
|
||||
skipQueuedPromptPersistRef.current = true;
|
||||
setQueuedPrompts(queuedPromptStorageKey ? readQueuedPrompts(queuedPromptStorageKey) : []);
|
||||
}, [pendingQueueKey, queuedPromptStorageKey]);
|
||||
}, [queuedPromptStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queuedPromptStorageKey) return;
|
||||
@@ -2247,7 +2241,6 @@ export function ThreadComposer({
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
ref={surfaceRef}
|
||||
className={cn(
|
||||
"thread-composer-surface group/composer relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
|
||||
isHero
|
||||
@@ -2393,7 +2386,7 @@ export function ThreadComposer({
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"thread-composer-footer flex flex-nowrap items-center motion-safe:transition-[padding-bottom] motion-safe:[transition-duration:220ms] motion-safe:ease-in-out",
|
||||
"thread-composer-footer flex flex-nowrap items-center",
|
||||
isHero
|
||||
? cn(
|
||||
"gap-x-1.5 px-3 sm:px-4",
|
||||
@@ -2440,7 +2433,7 @@ export function ThreadComposer({
|
||||
isHero={isHero}
|
||||
levels={voiceRecorder.levels}
|
||||
/>
|
||||
) : workspaceScope && !workspaceControlsHidden ? (
|
||||
) : workspaceScope ? (
|
||||
<WorkspaceAccessMenu
|
||||
scope={workspaceScope}
|
||||
disabled={disabled || workspaceScopeDisabled}
|
||||
@@ -2551,28 +2544,15 @@ export function ThreadComposer({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{projectPickerAvailable ? (
|
||||
<div
|
||||
className="composer-workspace-drawer"
|
||||
data-composer-workspace-drawer=""
|
||||
data-state={showProjectPicker ? "open" : "closed"}
|
||||
aria-hidden={showProjectPicker ? undefined : true}
|
||||
>
|
||||
<div className="composer-workspace-drawer-clip">
|
||||
<div className="composer-workspace-drawer-content">
|
||||
<WorkspaceProjectPicker
|
||||
isHero={isHero}
|
||||
disabled={disabled || workspaceScopeDisabled || !showProjectPicker}
|
||||
scope={workspaceScope}
|
||||
defaultScope={workspaceDefaultScope}
|
||||
controls={workspaceControls}
|
||||
error={workspaceError}
|
||||
onChange={onWorkspaceScopeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<WorkspaceProjectPicker
|
||||
isHero={isHero}
|
||||
disabled={disabled || workspaceScopeDisabled}
|
||||
scope={workspaceScope}
|
||||
defaultScope={workspaceDefaultScope}
|
||||
controls={workspaceControls}
|
||||
error={workspaceError}
|
||||
onChange={onWorkspaceScopeChange}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { Menu, MessageCircleDashed, Moon, Sun } from "lucide-react";
|
||||
import { type ReactNode } from "react";
|
||||
import { Menu, Moon, Sun } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ThreadHeaderProps {
|
||||
@@ -22,9 +16,6 @@ interface ThreadHeaderProps {
|
||||
minimal?: boolean;
|
||||
promptNavigatorAction?: ReactNode;
|
||||
sessionInfoAction?: ReactNode;
|
||||
temporaryChatEnabled?: boolean;
|
||||
temporaryChatDisabled?: boolean;
|
||||
onTemporaryChatEnabledChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function ThreadHeader({
|
||||
@@ -38,17 +29,13 @@ export function ThreadHeader({
|
||||
minimal = false,
|
||||
promptNavigatorAction,
|
||||
sessionInfoAction,
|
||||
temporaryChatEnabled = false,
|
||||
temporaryChatDisabled = false,
|
||||
onTemporaryChatEnabledChange,
|
||||
}: ThreadHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="thread-header"
|
||||
className={cn(
|
||||
"relative z-30 flex items-center justify-between gap-3 px-3 py-2",
|
||||
"relative z-10 flex items-center justify-between gap-3 px-3 py-2",
|
||||
minimal && "h-11",
|
||||
!minimal && hostChromeTitleInset && "lg:pl-[128px]",
|
||||
)}
|
||||
@@ -76,54 +63,6 @@ export function ThreadHeader({
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{sessionInfoAction}
|
||||
{promptNavigatorAction}
|
||||
{onTemporaryChatEnabledChange ? (
|
||||
<TooltipProvider delayDuration={700} skipDelayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={temporaryChatDisabled}
|
||||
aria-label={t("temporaryChat.title")}
|
||||
aria-pressed={temporaryChatEnabled}
|
||||
onClick={() => onTemporaryChatEnabledChange(!temporaryChatEnabled)}
|
||||
className={cn(
|
||||
"host-no-drag h-8 w-8 shrink-0 rounded-full bg-transparent text-muted-foreground shadow-none transition-none hover:text-foreground",
|
||||
temporaryChatEnabled ? "hover:bg-transparent" : "hover:bg-accent/45",
|
||||
)}
|
||||
>
|
||||
<MessageCircleDashed
|
||||
data-testid="temporary-chat-icon"
|
||||
className={cn(
|
||||
"h-4 w-4 motion-safe:transition-colors",
|
||||
temporaryChatEnabled
|
||||
? "text-[var(--temporary-control-active)] motion-safe:duration-150"
|
||||
: "text-current motion-safe:duration-75",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
className="max-w-72 rounded-xl border border-border/70 bg-popover px-3 py-2 text-[12px]/[1.4] text-popover-foreground shadow-[0_8px_24px_rgba(15,23,42,0.13)] dark:border-white/10"
|
||||
>
|
||||
<div className="font-medium">{t("temporaryChat.title")}</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
{t("temporaryChat.retention")}
|
||||
</div>
|
||||
<div className="mt-1 font-medium">
|
||||
{t("temporaryChat.expiration")}
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
{t("temporaryChat.externalEffects")}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
{!hideThemeButton ? (
|
||||
<ThemeButton
|
||||
theme={theme}
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/t
|
||||
|
||||
interface ThreadMessagesProps {
|
||||
messages: UIMessage[];
|
||||
temporary?: boolean;
|
||||
/** When true, agent turn still in flight — keeps activity timeline expanded. */
|
||||
isStreaming?: boolean;
|
||||
hiddenUserMessageCount?: number;
|
||||
@@ -51,7 +50,6 @@ export function assistantForkFlags(units: DisplayUnit[]): boolean[] {
|
||||
|
||||
export function ThreadMessages({
|
||||
messages,
|
||||
temporary = false,
|
||||
isStreaming = false,
|
||||
hiddenUserMessageCount = 0,
|
||||
cliApps = [],
|
||||
@@ -127,7 +125,6 @@ export function ThreadMessages({
|
||||
forkIndex={forkIndex}
|
||||
showForkBoundary={index === forkBoundaryAfterUnitIndex}
|
||||
forkBoundaryLabel={t("thread.forkedFromHistory")}
|
||||
temporary={temporary}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
@@ -150,7 +147,6 @@ interface ThreadDisplayUnitProps {
|
||||
forkIndex?: number;
|
||||
showForkBoundary: boolean;
|
||||
forkBoundaryLabel: string;
|
||||
temporary: boolean;
|
||||
cliApps: CliAppInfo[];
|
||||
mcpPresets: McpPresetInfo[];
|
||||
slashCommands: SlashCommand[];
|
||||
@@ -168,7 +164,6 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
||||
forkIndex,
|
||||
showForkBoundary,
|
||||
forkBoundaryLabel,
|
||||
temporary,
|
||||
cliApps,
|
||||
mcpPresets,
|
||||
slashCommands,
|
||||
@@ -205,7 +200,6 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
||||
) : (
|
||||
<MessageBubble
|
||||
message={unit.message}
|
||||
temporary={temporary}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
@@ -233,7 +227,6 @@ function threadDisplayUnitPropsEqual(
|
||||
&& previous.forkIndex === next.forkIndex
|
||||
&& previous.showForkBoundary === next.showForkBoundary
|
||||
&& previous.forkBoundaryLabel === next.forkBoundaryLabel
|
||||
&& previous.temporary === next.temporary
|
||||
&& previous.cliApps === next.cliApps
|
||||
&& previous.mcpPresets === next.mcpPresets
|
||||
&& previous.slashCommands === next.slashCommands
|
||||
|
||||
@@ -295,17 +295,10 @@ interface ThreadShellProps {
|
||||
session: ChatSummary | null;
|
||||
sessions?: ChatSummary[];
|
||||
title: string;
|
||||
temporary?: boolean;
|
||||
temporaryChatIds?: readonly string[];
|
||||
temporaryChatEnabled?: boolean;
|
||||
onTemporaryChatEnabledChange?: (enabled: boolean) => void;
|
||||
onToggleSidebar: () => void;
|
||||
onGoHome?: () => void;
|
||||
onNewChat?: () => void;
|
||||
onCreateChat?: (
|
||||
workspaceScope?: WorkspaceScopePayload | null,
|
||||
initialMessage?: string,
|
||||
) => Promise<string | null>;
|
||||
onCreateChat?: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string | null>;
|
||||
onForkChat?: (sourceChatId: string, beforeUserIndex: number) => Promise<string | null>;
|
||||
onTurnEnd?: () => void;
|
||||
theme?: "light" | "dark";
|
||||
@@ -484,7 +477,7 @@ function HeroGreeting({ text }: { text: string }) {
|
||||
<h1
|
||||
ref={headingRef}
|
||||
data-testid="hero-greeting"
|
||||
className="select-none whitespace-nowrap text-[34px] font-normal leading-[1.08] tracking-normal text-foreground sm:text-[48px] sm:leading-tight"
|
||||
className="whitespace-nowrap text-[34px] font-normal leading-[1.08] tracking-normal text-foreground sm:text-[48px] sm:leading-tight"
|
||||
>
|
||||
{text}
|
||||
</h1>
|
||||
@@ -587,10 +580,6 @@ export function ThreadShell({
|
||||
session,
|
||||
sessions = [],
|
||||
title,
|
||||
temporary = false,
|
||||
temporaryChatIds = [],
|
||||
temporaryChatEnabled = false,
|
||||
onTemporaryChatEnabledChange,
|
||||
onToggleSidebar,
|
||||
onCreateChat,
|
||||
onForkChat,
|
||||
@@ -613,7 +602,7 @@ export function ThreadShell({
|
||||
}: ThreadShellProps) {
|
||||
const { t } = useTranslation();
|
||||
const chatId = session?.chatId ?? null;
|
||||
const historyKey = temporary ? null : session?.key ?? null;
|
||||
const historyKey = session?.key ?? null;
|
||||
const mentionSessions = useMemo(
|
||||
() => sessions.filter((candidate) => (
|
||||
candidate.key !== historyKey
|
||||
@@ -668,7 +657,6 @@ export function ThreadShell({
|
||||
const [quotedContext, setQuotedContext] = useState<string | null>(null);
|
||||
const [composerFocusSignal, setComposerFocusSignal] = useState(0);
|
||||
const shellRef = useRef<HTMLElement | null>(null);
|
||||
const composerSurfaceRef = useRef<HTMLDivElement | null>(null);
|
||||
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
|
||||
const filePreviewCloseTimerRef = useRef<number | null>(null);
|
||||
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
||||
@@ -676,7 +664,6 @@ export function ThreadShell({
|
||||
const viewportRef = useRef<ThreadViewportHandle | null>(null);
|
||||
const activeViewportTurnByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
||||
const knownTemporaryChatIdsRef = useRef(new Set<string>());
|
||||
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
|
||||
const prevChatIdForCacheRef = useRef<string | null>(null);
|
||||
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
|
||||
@@ -691,8 +678,6 @@ export function ThreadShell({
|
||||
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||
const currentUiMessagesRef = useRef<UIMessage[] | null>(null);
|
||||
const uiRevisionRef = useRef(0);
|
||||
const showTemporaryChatControl =
|
||||
!hideHeader && !session && !loading && !!onTemporaryChatEnabledChange;
|
||||
|
||||
const initial = useMemo(() => {
|
||||
if (!chatId) return historical;
|
||||
@@ -751,18 +736,6 @@ export function ThreadShell({
|
||||
setSubmittedViewportTurnId(null);
|
||||
}, [historyKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const retained = new Set(temporaryChatIds);
|
||||
for (const chatId of retained) knownTemporaryChatIdsRef.current.add(chatId);
|
||||
for (const cachedChatId of knownTemporaryChatIdsRef.current) {
|
||||
if (!retained.has(cachedChatId)) {
|
||||
messageCacheRef.current.delete(cachedChatId);
|
||||
activeViewportTurnByChatIdRef.current.delete(cachedChatId);
|
||||
knownTemporaryChatIdsRef.current.delete(cachedChatId);
|
||||
}
|
||||
}
|
||||
}, [temporaryChatIds]);
|
||||
|
||||
const handleQuoteSelection = useCallback((text: string) => {
|
||||
setQuotedContext(text);
|
||||
setComposerFocusSignal((value) => value + 1);
|
||||
@@ -865,12 +838,6 @@ export function ThreadShell({
|
||||
() => modelPresetOptionsFromSettings(settings),
|
||||
[settings],
|
||||
);
|
||||
const availableSlashCommands = useMemo(
|
||||
() => temporary
|
||||
? slashCommands.filter(({ command }) => command === "/model" || command === "/stop")
|
||||
: slashCommands,
|
||||
[slashCommands, temporary],
|
||||
);
|
||||
const modelBadge = useMemo(
|
||||
() => toModelBadgeInfo(modelName, settings, activeModelPreset),
|
||||
[activeModelPreset, modelName, settings],
|
||||
@@ -931,7 +898,7 @@ export function ThreadShell({
|
||||
}, [chatId, client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyKey || !chatId || loading) return;
|
||||
if (!chatId || loading) return;
|
||||
const cached = messageCacheRef.current.get(chatId);
|
||||
const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId);
|
||||
const hasNewCanonicalHistory = (
|
||||
@@ -1061,11 +1028,10 @@ export function ThreadShell({
|
||||
historyLineage,
|
||||
historyActiveTurnId,
|
||||
hasPendingToolCalls,
|
||||
historyKey,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!historyKey || !chatId) return;
|
||||
if (!chatId) return;
|
||||
const commit = pendingCanonicalCommitRef.current.get(chatId);
|
||||
if (!commit) return;
|
||||
if (
|
||||
@@ -1103,17 +1069,17 @@ export function ThreadShell({
|
||||
pendingCanonicalCommitRef.current.delete(chatId);
|
||||
committedHistoryLineageRef.current.set(chatId, historyLineage);
|
||||
completedCanonicalHydrateVersionRef.current.set(chatId, historyVersion);
|
||||
}, [chatId, client, historyKey, historyLineage, historyVersion, messages, setMessages]);
|
||||
}, [chatId, client, historyLineage, historyVersion, messages, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyKey || !chatId || hasPendingToolCalls) return;
|
||||
if (!chatId || hasPendingToolCalls) return;
|
||||
if (completedCanonicalHydrateVersionRef.current.get(chatId) !== historyVersion) return;
|
||||
completedCanonicalHydrateVersionRef.current.delete(chatId);
|
||||
reconcileTurnComplete();
|
||||
}, [chatId, hasPendingToolCalls, historyKey, historyVersion, messages, reconcileTurnComplete]);
|
||||
}, [chatId, hasPendingToolCalls, historyVersion, messages, reconcileTurnComplete]);
|
||||
|
||||
const refreshCanonicalHistory = useCallback(() => {
|
||||
if (!historyKey || !chatId) return;
|
||||
if (!chatId) return;
|
||||
pendingCanonicalHydrateRef.current.set(chatId, {
|
||||
historyLineage,
|
||||
historyVersion,
|
||||
@@ -1123,10 +1089,10 @@ export function ThreadShell({
|
||||
uiRevision: uiRevisionRef.current,
|
||||
});
|
||||
refreshHistory();
|
||||
}, [chatId, client, historyKey, historyLineage, historyVersion, refreshHistory]);
|
||||
}, [chatId, client, historyLineage, historyVersion, refreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyKey || !chatId) return;
|
||||
if (!chatId) return;
|
||||
return client.onSessionUpdate((updatedChatId, scope) => {
|
||||
if (updatedChatId !== chatId) return;
|
||||
if (scope === "metadata") return;
|
||||
@@ -1135,7 +1101,7 @@ export function ThreadShell({
|
||||
// so keep an active programmatic follow alive across canonical hydration.
|
||||
refreshCanonicalHistory();
|
||||
});
|
||||
}, [chatId, client, historyKey, refreshCanonicalHistory]);
|
||||
}, [chatId, client, refreshCanonicalHistory]);
|
||||
|
||||
const wasPageHiddenRef = useRef(document.visibilityState === "hidden");
|
||||
useEffect(() => {
|
||||
@@ -1146,7 +1112,7 @@ export function ThreadShell({
|
||||
}
|
||||
if (!wasPageHiddenRef.current) return;
|
||||
wasPageHiddenRef.current = false;
|
||||
if (!historyKey || !chatId || client.status !== "open" || loading) return;
|
||||
if (!chatId || client.status !== "open" || loading) return;
|
||||
if (
|
||||
!turnActive
|
||||
&& !hasPendingToolCalls
|
||||
@@ -1163,7 +1129,6 @@ export function ThreadShell({
|
||||
chatId,
|
||||
client,
|
||||
hasPendingToolCalls,
|
||||
historyKey,
|
||||
historyError,
|
||||
loading,
|
||||
refreshCanonicalHistory,
|
||||
@@ -1265,7 +1230,7 @@ export function ThreadShell({
|
||||
setBooting(true);
|
||||
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
|
||||
setPendingFirstTargetChatId(null);
|
||||
const newId = await onCreateChat?.(workspaceScope, content);
|
||||
const newId = await onCreateChat?.(workspaceScope);
|
||||
if (!newId) {
|
||||
pendingFirstRef.current = null;
|
||||
setPendingFirstTargetChatId(null);
|
||||
@@ -1421,7 +1386,7 @@ export function ThreadShell({
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
slashCommands={availableSlashCommands}
|
||||
slashCommands={slashCommands}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
sessions={mentionSessions}
|
||||
@@ -1431,13 +1396,12 @@ export function ThreadShell({
|
||||
runStartedAt={currentRunStartedAt}
|
||||
goalState={currentGoalState}
|
||||
workspaceScope={workspaceScope}
|
||||
workspaceControlsHidden={temporary}
|
||||
workspaceDefaultScope={workspaceDefaultScope}
|
||||
workspaceControls={workspaceControls}
|
||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||
workspaceError={workspaceError}
|
||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||
pendingQueueKey={temporary ? null : chatId}
|
||||
pendingQueueKey={chatId}
|
||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||
ingressLimits={ingressLimits}
|
||||
quotedContext={quotedContext}
|
||||
@@ -1465,17 +1429,15 @@ export function ThreadShell({
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant="hero"
|
||||
slashCommands={availableSlashCommands}
|
||||
slashCommands={slashCommands}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
sessions={mentionSessions}
|
||||
skills={skills}
|
||||
surfaceRef={composerSurfaceRef}
|
||||
runStartedAt={currentRunStartedAt}
|
||||
onTranscribeAudio={transcribeAudio}
|
||||
goalState={currentGoalState}
|
||||
workspaceScope={workspaceScope}
|
||||
workspaceControlsHidden={temporary}
|
||||
workspaceDefaultScope={workspaceDefaultScope}
|
||||
workspaceControls={workspaceControls}
|
||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||
@@ -1522,11 +1484,6 @@ export function ThreadShell({
|
||||
minimal={!session && !loading}
|
||||
promptNavigatorAction={promptNavigatorAction}
|
||||
sessionInfoAction={sessionInfoAction}
|
||||
temporaryChatEnabled={temporaryChatEnabled}
|
||||
temporaryChatDisabled={booting || turnActive}
|
||||
onTemporaryChatEnabledChange={
|
||||
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<FilePreviewAvailabilityProvider
|
||||
@@ -1535,7 +1492,6 @@ export function ThreadShell({
|
||||
<ThreadViewport
|
||||
ref={viewportRef}
|
||||
messages={displayMessages}
|
||||
temporary={temporary}
|
||||
isStreaming={turnActive}
|
||||
emptyState={emptyState}
|
||||
composer={composer}
|
||||
@@ -1546,7 +1502,7 @@ export function ThreadShell({
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={availableSlashCommands}
|
||||
slashCommands={slashCommands}
|
||||
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
||||
hasMoreBefore={hasMoreBefore}
|
||||
loadingOlder={loadingOlder}
|
||||
|
||||
@@ -35,7 +35,6 @@ export interface ThreadViewportHandle {
|
||||
|
||||
interface ThreadViewportProps {
|
||||
messages: UIMessage[];
|
||||
temporary?: boolean;
|
||||
isStreaming: boolean;
|
||||
composer: ReactNode;
|
||||
emptyState?: ReactNode;
|
||||
@@ -158,7 +157,6 @@ function readSoftKeyboardInsetBottom(container: HTMLElement | null): number {
|
||||
|
||||
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
|
||||
messages,
|
||||
temporary = false,
|
||||
isStreaming,
|
||||
composer,
|
||||
emptyState,
|
||||
@@ -684,7 +682,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
<div ref={messageContentRef} className="mx-auto w-full max-w-[49.5rem]">
|
||||
<ThreadMessages
|
||||
messages={visibleMessages}
|
||||
temporary={temporary}
|
||||
isStreaming={isStreaming}
|
||||
hiddenUserMessageCount={hiddenUserMessageCount}
|
||||
cliApps={cliApps}
|
||||
|
||||
@@ -36,8 +36,6 @@ import {
|
||||
|
||||
export function WorkspaceProjectPicker({
|
||||
isHero,
|
||||
compact = false,
|
||||
connected = false,
|
||||
disabled,
|
||||
scope,
|
||||
defaultScope,
|
||||
@@ -46,8 +44,6 @@ export function WorkspaceProjectPicker({
|
||||
onChange,
|
||||
}: {
|
||||
isHero: boolean;
|
||||
compact?: boolean;
|
||||
connected?: boolean;
|
||||
disabled?: boolean;
|
||||
scope: WorkspaceScopePayload | null;
|
||||
defaultScope: WorkspaceScopePayload | null;
|
||||
@@ -78,12 +74,8 @@ export function WorkspaceProjectPicker({
|
||||
}, [currentProjectScope?.project_path, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) setOpen(false);
|
||||
}, [disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error && visible && !disabled) setOpen(true);
|
||||
}, [disabled, error, visible]);
|
||||
if (error && visible) setOpen(true);
|
||||
}, [error, visible]);
|
||||
|
||||
const applyProjectPath = useCallback(
|
||||
(projectPath: string, projectName?: string) => {
|
||||
@@ -123,11 +115,7 @@ export function WorkspaceProjectPicker({
|
||||
|
||||
if (nativeProjectPicker) {
|
||||
return (
|
||||
<div className={cn(
|
||||
compact
|
||||
? "inline-flex"
|
||||
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
|
||||
)}>
|
||||
<div className="flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || pickingFolder}
|
||||
@@ -135,18 +123,16 @@ export function WorkspaceProjectPicker({
|
||||
title={currentProjectScope?.project_path}
|
||||
onClick={() => void pickNativeFolder()}
|
||||
className={cn(
|
||||
compact
|
||||
? "thread-composer-action touch-target inline-flex h-8 w-8 items-center justify-center rounded-full border border-transparent"
|
||||
: "inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
compact ? "hover:bg-muted/65" : "hover:bg-background/70",
|
||||
(connected || currentProjectScope) && "text-primary",
|
||||
"inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
|
||||
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
currentProjectScope && "text-foreground/82",
|
||||
)}
|
||||
>
|
||||
<Folder className={cn("shrink-0", compact ? "h-4 w-4" : "h-3.5 w-3.5")} />
|
||||
<span className={compact ? "sr-only" : "truncate"}>{projectLabel}</span>
|
||||
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
|
||||
<span className="truncate">{projectLabel}</span>
|
||||
</button>
|
||||
{!compact && (pathError || error) ? (
|
||||
{pathError || error ? (
|
||||
<span role="alert" className="ml-2 min-w-0 truncate text-[11.5px] font-medium text-destructive">
|
||||
{pathError ?? error}
|
||||
</span>
|
||||
@@ -156,11 +142,7 @@ export function WorkspaceProjectPicker({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
compact
|
||||
? "inline-flex"
|
||||
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
|
||||
)}>
|
||||
<div className="flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
@@ -168,19 +150,15 @@ export function WorkspaceProjectPicker({
|
||||
disabled={disabled}
|
||||
aria-label={t("thread.composer.workspace.projectAria")}
|
||||
className={cn(
|
||||
compact
|
||||
? "thread-composer-action touch-target inline-flex h-8 w-8 items-center justify-center rounded-full border border-transparent"
|
||||
: "inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
compact ? "hover:bg-muted/65" : "hover:bg-background/70",
|
||||
(connected || currentProjectScope) && "text-primary",
|
||||
"inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
|
||||
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
currentProjectScope && "text-foreground/82",
|
||||
)}
|
||||
>
|
||||
<Folder className={cn("shrink-0", compact ? "h-4 w-4" : "h-3.5 w-3.5")} />
|
||||
<span className={compact ? "sr-only" : "truncate"}>{projectLabel}</span>
|
||||
{!compact ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
|
||||
<span className="truncate">{projectLabel}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
@@ -59,6 +59,16 @@ function truncatePreview(text: string, maxLength: number): string {
|
||||
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
|
||||
}
|
||||
|
||||
export function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
|
||||
if (!scrollEl || !promptId) return;
|
||||
const target = findPromptElement(scrollEl, promptId);
|
||||
if (!target) return;
|
||||
scrollEl.scrollTo({
|
||||
top: Math.max(0, promptTop(scrollEl, target) - 16),
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
export function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
|
||||
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
|
||||
return Array.from(candidates).find(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Circle } from "lucide-react";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import {
|
||||
floatingItemClassName,
|
||||
@@ -12,11 +12,52 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const menuItemClassName =
|
||||
`${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
menuItemClassName,
|
||||
"data-[state=open]:bg-foreground/[0.055] dark:data-[state=open]:bg-white/[0.08]",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-3.5 w-3.5 text-muted-foreground" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
floatingSurfaceClassName,
|
||||
"max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
interface DropdownMenuContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> {
|
||||
portalContainer?: HTMLElement | null;
|
||||
@@ -62,6 +103,31 @@ const DropdownMenuItem = React.forwardRef<
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
menuItemClassName,
|
||||
"pl-8 pr-2.5",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2.5 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
@@ -117,11 +183,17 @@ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
+51
-43
@@ -33,10 +33,6 @@
|
||||
--input: 40 8% 90.5%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--inline-token-highlight: #ef8e30;
|
||||
--temporary-control-active: #ef8e30;
|
||||
--temporary-accent: 24 95% 53%;
|
||||
--temporary-foreground: 17 88% 32%;
|
||||
--temporary-border: 17 88% 40%;
|
||||
--radius: 0.4375rem;
|
||||
--sidebar: 40 8% 96.8%;
|
||||
--sidebar-foreground: 0 0% 3.9%;
|
||||
@@ -71,10 +67,6 @@
|
||||
--input: var(--border);
|
||||
--ring: 0 0% 83.1%;
|
||||
--inline-token-highlight: #ef8e30;
|
||||
--temporary-control-active: #ef8e30;
|
||||
--temporary-accent: 24 95% 53%;
|
||||
--temporary-foreground: 32 98% 73%;
|
||||
--temporary-border: 27 96% 61%;
|
||||
--sidebar: var(--card);
|
||||
--sidebar-foreground: 0 0% 98%;
|
||||
--sidebar-accent: var(--background);
|
||||
@@ -243,6 +235,10 @@
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.shadow-inner-right {
|
||||
box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02);
|
||||
}
|
||||
|
||||
/* Keep the outer document rhythm clean at message boundaries. */
|
||||
.markdown-content > :first-child {
|
||||
@apply mt-0;
|
||||
@@ -342,6 +338,7 @@
|
||||
animation: none;
|
||||
content: "";
|
||||
}
|
||||
.markdown-content-streaming > :last-child::after,
|
||||
.streaming-text-fallback::after {
|
||||
animation: none;
|
||||
}
|
||||
@@ -438,41 +435,6 @@
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.composer-workspace-drawer {
|
||||
--composer-workspace-drawer-duration: 220ms;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.composer-workspace-drawer[data-state="open"] {
|
||||
--composer-workspace-drawer-duration: 240ms;
|
||||
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.composer-workspace-drawer-clip {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.composer-workspace-drawer {
|
||||
transition:
|
||||
grid-template-rows var(--composer-workspace-drawer-duration)
|
||||
cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity var(--composer-workspace-drawer-duration) ease-in-out;
|
||||
}
|
||||
.composer-workspace-drawer-content {
|
||||
transform: translateY(-6px);
|
||||
transition: transform var(--composer-workspace-drawer-duration)
|
||||
cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.composer-workspace-drawer[data-state="open"] .composer-workspace-drawer-content {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes run-pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
@@ -603,6 +565,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cli-app-linked-sheen {
|
||||
0% {
|
||||
transform: translateX(-140%) skewX(-14deg);
|
||||
opacity: 0;
|
||||
}
|
||||
18% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
72%,
|
||||
100% {
|
||||
transform: translateX(140%) skewX(-14deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.cli-app-linked-chip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
hsl(var(--foreground) / 0.14) 46%,
|
||||
hsl(var(--background) / 0.7) 50%,
|
||||
hsl(var(--foreground) / 0.12) 54%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: cli-app-linked-sheen 1.25s ease-out 1;
|
||||
}
|
||||
.dark .cli-app-linked-chip::after {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
hsl(var(--foreground) / 0.12) 46%,
|
||||
hsl(var(--background) / 0.5) 50%,
|
||||
hsl(var(--foreground) / 0.1) 54%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cli-app-linked-chip::after {
|
||||
animation: none;
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Subtle scrollbar that doesn't fight the dark background. */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
|
||||
@@ -55,6 +55,7 @@ export type AttachmentError =
|
||||
| "io"; // file read failed at the browser layer
|
||||
|
||||
export const MAX_ATTACHMENTS_PER_MESSAGE = 4;
|
||||
export const MAX_IMAGES_PER_MESSAGE = MAX_ATTACHMENTS_PER_MESSAGE;
|
||||
export const MAX_ATTACHMENT_BYTES = 6 * 1024 * 1024;
|
||||
export const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024;
|
||||
|
||||
|
||||
@@ -1461,8 +1461,6 @@ export function useNanobotStream(
|
||||
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
||||
});
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
setRunStartedAt(null);
|
||||
client.finishRunLocally(chatId);
|
||||
client.sendMessage(chatId, "/stop");
|
||||
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
|
||||
|
||||
|
||||
@@ -49,15 +49,6 @@
|
||||
"noMatch": "No pending request matches this code."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "Temporary chat",
|
||||
"retention": "Not saved to history or memory.",
|
||||
"expiration": "Reloading, closing, or losing the connection ends these chats.",
|
||||
"externalEffects": "Requests still go to your model provider, and tool actions may leave changes.",
|
||||
"clear": "Clear temporary chat",
|
||||
"sectionTitle": "Temporary chats",
|
||||
"closeAction": "Close temporary chat: {{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Sidebar navigation",
|
||||
"collapse": "Collapse sidebar",
|
||||
|
||||
@@ -49,15 +49,6 @@
|
||||
"noMatch": "No hay ninguna solicitud pendiente que coincida con este código."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "Chat temporal",
|
||||
"retention": "No se guarda en el historial ni en la memoria.",
|
||||
"expiration": "Recargar, cerrar o perder la conexión finaliza estos chats.",
|
||||
"externalEffects": "Las solicitudes siguen llegando al proveedor del modelo y las herramientas pueden dejar cambios.",
|
||||
"clear": "Borrar chat temporal",
|
||||
"sectionTitle": "Chats temporales",
|
||||
"closeAction": "Cerrar chat temporal: {{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Navegación de la barra lateral",
|
||||
"collapse": "Contraer barra lateral",
|
||||
|
||||
@@ -49,15 +49,6 @@
|
||||
"noMatch": "Aucune demande en attente ne correspond à ce code."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "Discussion temporaire",
|
||||
"retention": "Elle n’est enregistrée ni dans l’historique ni dans la mémoire.",
|
||||
"expiration": "Recharger, fermer ou perdre la connexion met fin à ces discussions.",
|
||||
"externalEffects": "Les requêtes sont tout de même envoyées au fournisseur du modèle et les outils peuvent laisser des modifications.",
|
||||
"clear": "Effacer la discussion temporaire",
|
||||
"sectionTitle": "Discussions temporaires",
|
||||
"closeAction": "Fermer la discussion temporaire : {{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Navigation de la barre latérale",
|
||||
"collapse": "Réduire la barre latérale",
|
||||
|
||||
@@ -49,15 +49,6 @@
|
||||
"noMatch": "Tidak ada permintaan tertunda yang cocok dengan kode ini."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "Obrolan sementara",
|
||||
"retention": "Tidak disimpan ke riwayat atau memori.",
|
||||
"expiration": "Memuat ulang, menutup, atau kehilangan koneksi akan mengakhiri obrolan ini.",
|
||||
"externalEffects": "Permintaan tetap dikirim ke penyedia model dan tindakan alat dapat meninggalkan perubahan.",
|
||||
"clear": "Hapus obrolan sementara",
|
||||
"sectionTitle": "Obrolan sementara",
|
||||
"closeAction": "Tutup obrolan sementara: {{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Navigasi bilah samping",
|
||||
"collapse": "Ciutkan sidebar",
|
||||
|
||||
@@ -49,15 +49,6 @@
|
||||
"noMatch": "このコードに一致する保留中のリクエストはありません。"
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "一時チャット",
|
||||
"retention": "履歴やメモリには保存されません。",
|
||||
"expiration": "再読み込み、ページを閉じる操作、接続切断で一時チャットは終了します。",
|
||||
"externalEffects": "リクエストは引き続きモデルプロバイダーに送信され、ツール操作による変更は残る場合があります。",
|
||||
"clear": "一時チャットを消去",
|
||||
"sectionTitle": "一時チャット",
|
||||
"closeAction": "一時チャット「{{title}}」を閉じる"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "サイドバーのナビゲーション",
|
||||
"collapse": "サイドバーを閉じる",
|
||||
|
||||
@@ -49,15 +49,6 @@
|
||||
"noMatch": "이 코드와 일치하는 대기 중인 요청이 없습니다."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "임시 채팅",
|
||||
"retention": "기록이나 메모리에 저장되지 않습니다.",
|
||||
"expiration": "새로고침, 페이지 닫기 또는 연결 끊김 시 임시 채팅이 종료됩니다.",
|
||||
"externalEffects": "요청은 계속 모델 제공업체로 전송되며 도구 작업의 변경 사항은 남을 수 있습니다.",
|
||||
"clear": "임시 채팅 지우기",
|
||||
"sectionTitle": "임시 채팅",
|
||||
"closeAction": "임시 채팅 닫기: {{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "사이드바 탐색",
|
||||
"collapse": "사이드바 접기",
|
||||
|
||||
@@ -49,15 +49,6 @@
|
||||
"noMatch": "Nenhuma solicitação pendente corresponde a este código."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "Chat temporário",
|
||||
"retention": "Não é salvo no histórico nem na memória.",
|
||||
"expiration": "Recarregar, fechar ou perder a conexão encerra estes chats.",
|
||||
"externalEffects": "As solicitações ainda são enviadas ao provedor do modelo, e as ações das ferramentas podem deixar alterações.",
|
||||
"clear": "Limpar chat temporário",
|
||||
"sectionTitle": "Chats temporários",
|
||||
"closeAction": "Fechar chat temporário: {{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Navegação da barra lateral",
|
||||
"collapse": "Recolher barra lateral",
|
||||
|
||||
@@ -49,15 +49,6 @@
|
||||
"noMatch": "Không có yêu cầu đang chờ nào khớp với mã này."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "Trò chuyện tạm thời",
|
||||
"retention": "Không được lưu vào lịch sử hoặc bộ nhớ.",
|
||||
"expiration": "Tải lại, đóng trang hoặc mất kết nối sẽ kết thúc các cuộc trò chuyện này.",
|
||||
"externalEffects": "Yêu cầu vẫn được gửi đến nhà cung cấp mô hình và thao tác công cụ có thể để lại thay đổi.",
|
||||
"clear": "Xóa trò chuyện tạm thời",
|
||||
"sectionTitle": "Trò chuyện tạm thời",
|
||||
"closeAction": "Đóng trò chuyện tạm thời: {{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Điều hướng thanh bên",
|
||||
"collapse": "Thu gọn thanh bên",
|
||||
|
||||
@@ -49,15 +49,6 @@
|
||||
"noMatch": "没有待处理请求与此配对码匹配。"
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "临时聊天",
|
||||
"retention": "不会保存到历史记录或记忆。",
|
||||
"expiration": "刷新、关闭页面或连接中断后,临时聊天会结束。",
|
||||
"externalEffects": "请求仍会发送给模型提供商,工具操作也可能留下更改。",
|
||||
"clear": "清空临时聊天",
|
||||
"sectionTitle": "临时聊天",
|
||||
"closeAction": "关闭临时聊天:{{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "侧边栏导航",
|
||||
"collapse": "收起侧边栏",
|
||||
|
||||
@@ -49,15 +49,6 @@
|
||||
"noMatch": "沒有待處理請求符合此配對碼。"
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "臨時聊天",
|
||||
"retention": "不會儲存至歷史記錄或記憶。",
|
||||
"expiration": "重新載入、關閉頁面或連線中斷後,臨時聊天會結束。",
|
||||
"externalEffects": "請求仍會傳送給模型供應商,工具操作也可能留下變更。",
|
||||
"clear": "清空臨時聊天",
|
||||
"sectionTitle": "臨時聊天",
|
||||
"closeAction": "關閉臨時聊天:{{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "側邊欄導覽",
|
||||
"collapse": "收合側邊欄",
|
||||
|
||||
@@ -108,10 +108,6 @@ interface PendingRequest<T> {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
interface PendingChatRequest extends PendingRequest<string> {
|
||||
temporary: boolean;
|
||||
}
|
||||
|
||||
const SYSTEM_COMMAND_TURN_PREFIX = "webui-system:";
|
||||
const TURN_REJECTION_DETAILS = new Set([
|
||||
"access_denied",
|
||||
@@ -177,8 +173,6 @@ export class NanobotClient {
|
||||
private static readonly PENDING_INBOUND_MAX = 2000;
|
||||
// chat_ids we've attached to since connect; re-attached after reconnects
|
||||
private knownChats = new Set<string>();
|
||||
/** Temporary chats are connection-owned and intentionally not reattached. */
|
||||
private temporaryChatIds = new Set<string>();
|
||||
/** Wall-clock run strip: updated from ``goal_status`` even with no ``onChat`` subscriber. */
|
||||
private runStartedAtByChatId = new Map<string, number>();
|
||||
/** Per-turn clocks let a rejected newer turn fall back without borrowing its timer. */
|
||||
@@ -200,7 +194,7 @@ export class NanobotClient {
|
||||
private static readonly COMPLETED_TURN_FENCE_MAX = 256;
|
||||
/** Latest ``goal_state`` snapshot per ``chat_id`` (multi-session isolation). */
|
||||
private goalStateByChatId = new Map<string, GoalStateWsPayload>();
|
||||
private pendingNewChat: PendingChatRequest | null = null;
|
||||
private pendingNewChat: PendingRequest<string> | null = null;
|
||||
private pendingTranscriptions = new Map<string, PendingRequest<string>>();
|
||||
private pendingSystemCommands = new Map<string, PendingRequest<void>>();
|
||||
// Frames queued while the socket is not yet OPEN
|
||||
@@ -288,16 +282,6 @@ export class NanobotClient {
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
/** Clear the optimistic run state immediately after the user stops a turn. */
|
||||
finishRunLocally(chatId: string): void {
|
||||
const unsettled = [...(this.unsettledRunTurnIdsByChatId.get(chatId) ?? [])];
|
||||
for (const turnId of unsettled) this.settleRunTurn(chatId, turnId);
|
||||
this.latestRunTurnIdByChatId.delete(chatId);
|
||||
if (this.runStartedAtByChatId.delete(chatId)) {
|
||||
this.emitRunStatus(chatId, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh transport policy after bootstrap token renewal. */
|
||||
updateMaxFrameBytes(maxFrameBytes?: number): void {
|
||||
this.maxFrameBytes = this.normalizeMaxFrameBytes(maxFrameBytes);
|
||||
@@ -741,18 +725,9 @@ export class NanobotClient {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.clearTemporaryChats();
|
||||
this.setStatus("closed");
|
||||
}
|
||||
|
||||
discardTemporaryChat(chatId: string): void {
|
||||
if (!this.temporaryChatIds.has(chatId)) return;
|
||||
if (this.socket?.readyState === WS_OPEN) {
|
||||
this.rawSend({ type: "discard_temporary_chat", chat_id: chatId });
|
||||
}
|
||||
this.forgetTemporaryChat(chatId);
|
||||
}
|
||||
|
||||
/** Ask the server to provision a new chat_id; resolves with the assigned id. */
|
||||
newChat(timeoutMs: number = 5_000, workspaceScope?: WorkspaceScopePayload | null): Promise<string> {
|
||||
if (this.pendingNewChat) {
|
||||
@@ -763,7 +738,7 @@ export class NanobotClient {
|
||||
this.pendingNewChat = null;
|
||||
reject(new Error("newChat timed out"));
|
||||
}, timeoutMs);
|
||||
this.pendingNewChat = { resolve, reject, timer, temporary: false };
|
||||
this.pendingNewChat = { resolve, reject, timer };
|
||||
this.queueSend({
|
||||
type: "new_chat",
|
||||
...(workspaceScope ? { workspace_scope: workspaceScope } : {}),
|
||||
@@ -771,21 +746,6 @@ export class NanobotClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Ask the WebUI gateway to create a connection-owned non-persistent chat. */
|
||||
newTemporaryChat(timeoutMs: number = 5_000): Promise<string> {
|
||||
if (this.pendingNewChat) {
|
||||
return Promise.reject(new Error("newChat already in flight"));
|
||||
}
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingNewChat = null;
|
||||
reject(new Error("newTemporaryChat timed out"));
|
||||
}, timeoutMs);
|
||||
this.pendingNewChat = { resolve, reject, timer, temporary: true };
|
||||
this.queueSend({ type: "new_temporary_chat" });
|
||||
});
|
||||
}
|
||||
|
||||
transcribeAudio(
|
||||
dataUrl: string,
|
||||
options?: { durationMs?: number; timeoutMs?: number },
|
||||
@@ -822,7 +782,7 @@ export class NanobotClient {
|
||||
this.pendingNewChat = null;
|
||||
reject(new Error("forkChat timed out"));
|
||||
}, timeoutMs);
|
||||
this.pendingNewChat = { resolve, reject, timer, temporary: false };
|
||||
this.pendingNewChat = { resolve, reject, timer };
|
||||
this.queueSend({
|
||||
type: "fork_chat",
|
||||
source_chat_id: sourceChatId,
|
||||
@@ -833,7 +793,6 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
attach(chatId: string): void {
|
||||
if (this.temporaryChatIds.has(chatId)) return;
|
||||
this.knownChats.add(chatId);
|
||||
if (this.socket?.readyState === WS_OPEN) {
|
||||
this.queueSend({ type: "attach", chat_id: chatId });
|
||||
@@ -855,8 +814,7 @@ export class NanobotClient {
|
||||
startsNewRun?: boolean;
|
||||
},
|
||||
): void {
|
||||
const temporary = this.temporaryChatIds.has(chatId);
|
||||
if (!temporary) this.knownChats.add(chatId);
|
||||
this.knownChats.add(chatId);
|
||||
const frame: Outbound = {
|
||||
type: "message",
|
||||
chat_id: chatId,
|
||||
@@ -905,7 +863,6 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
|
||||
if (this.temporaryChatIds.has(chatId)) return;
|
||||
this.knownChats.add(chatId);
|
||||
this.queueSend({
|
||||
type: "set_workspace_scope",
|
||||
@@ -1030,15 +987,8 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
if (parsed.event === "attached") {
|
||||
if (parsed.temporary === true) {
|
||||
this.temporaryChatIds.add(parsed.chat_id);
|
||||
} else {
|
||||
this.knownChats.add(parsed.chat_id);
|
||||
}
|
||||
if (
|
||||
this.pendingNewChat
|
||||
&& this.pendingNewChat.temporary === (parsed.temporary === true)
|
||||
) {
|
||||
this.knownChats.add(parsed.chat_id);
|
||||
if (this.pendingNewChat) {
|
||||
clearTimeout(this.pendingNewChat.timer);
|
||||
this.pendingNewChat.resolve(parsed.chat_id);
|
||||
this.pendingNewChat = null;
|
||||
@@ -1144,7 +1094,6 @@ export class NanobotClient {
|
||||
|
||||
private handleClose(event?: { code?: number }): void {
|
||||
this.socket = null;
|
||||
this.clearTemporaryChats();
|
||||
if (this.pendingNewChat) {
|
||||
clearTimeout(this.pendingNewChat.timer);
|
||||
this.pendingNewChat.reject(new Error("socket closed"));
|
||||
@@ -1291,40 +1240,6 @@ export class NanobotClient {
|
||||
}
|
||||
}
|
||||
|
||||
private clearTemporaryChats(): void {
|
||||
for (const chatId of [...this.temporaryChatIds]) {
|
||||
this.forgetTemporaryChat(chatId);
|
||||
}
|
||||
}
|
||||
|
||||
private forgetTemporaryChat(chatId: string): void {
|
||||
this.temporaryChatIds.delete(chatId);
|
||||
this.knownChats.delete(chatId);
|
||||
this.chatHandlers.delete(chatId);
|
||||
this.pendingInboundByChat.delete(chatId);
|
||||
const wasRunning = this.runStartedAtByChatId.delete(chatId);
|
||||
this.runGenerationByChatId.delete(chatId);
|
||||
this.latestRunTurnIdByChatId.delete(chatId);
|
||||
this.unsettledRunTurnIdsByChatId.delete(chatId);
|
||||
this.canonicalCompletedTurnIdsByChatId.delete(chatId);
|
||||
this.goalStateByChatId.delete(chatId);
|
||||
for (const key of [...this.runStartedAtByTurnKey.keys()]) {
|
||||
if (key.startsWith(`${chatId}\u0000`)) this.runStartedAtByTurnKey.delete(key);
|
||||
}
|
||||
for (const [key, pending] of [...this.pendingMessageSends]) {
|
||||
if (pending.chatId !== chatId) continue;
|
||||
this.pendingMessageSends.delete(key);
|
||||
this.socketPendingMessageSendKeys.delete(key);
|
||||
}
|
||||
this.sendQueue = this.sendQueue.filter((frame) => (
|
||||
!("chat_id" in frame) || frame.chat_id !== chatId
|
||||
));
|
||||
if (this.lastSocketMessageSendKey?.startsWith(`${chatId}\u0000`)) {
|
||||
this.lastSocketMessageSendKey = null;
|
||||
}
|
||||
if (wasRunning) this.emitRunStatus(chatId, null);
|
||||
}
|
||||
|
||||
private frameFitsTransport(frame: Outbound): boolean {
|
||||
if (this.maxFrameBytes === undefined) return true;
|
||||
return new TextEncoder().encode(JSON.stringify(frame)).byteLength <= this.maxFrameBytes;
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { ChatSummary } from "./types";
|
||||
|
||||
const WEBSOCKET_SESSION_KEY_PREFIX = "websocket:";
|
||||
|
||||
export function deriveTemporaryChatTitle(
|
||||
firstMessage: string | undefined,
|
||||
fallback: string,
|
||||
): string {
|
||||
const oneLine = firstMessage?.replace(/\s+/g, " ").trim() ?? "";
|
||||
if (!oneLine) return fallback;
|
||||
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
|
||||
}
|
||||
|
||||
export function createTemporaryChatSession(chatId: string): ChatSummary {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
key: `${WEBSOCKET_SESSION_KEY_PREFIX}${chatId}`,
|
||||
channel: "websocket",
|
||||
chatId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
preview: "",
|
||||
};
|
||||
}
|
||||
@@ -1162,7 +1162,7 @@ export interface InboundTurnMetadata {
|
||||
|
||||
export type InboundEvent =
|
||||
| { event: "ready"; chat_id: string; client_id: string }
|
||||
| { event: "attached"; chat_id: string; temporary?: boolean }
|
||||
| { event: "attached"; chat_id: string }
|
||||
| { event: "message_accepted"; chat_id: string; turn_id: string }
|
||||
| ({
|
||||
event: "message";
|
||||
@@ -1338,11 +1338,9 @@ export interface FilePreviewPayload {
|
||||
|
||||
export type Outbound =
|
||||
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
||||
| { type: "new_temporary_chat" }
|
||||
| { 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: "discard_temporary_chat"; chat_id: string }
|
||||
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
|
||||
| { type: "transcribe_audio"; request_id: string; data_url: string; duration_ms?: number }
|
||||
| {
|
||||
|
||||
@@ -3,12 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import i18n from "@/i18n";
|
||||
import type {
|
||||
ChatSummary,
|
||||
ConnectionStatus,
|
||||
SessionAutomationJob,
|
||||
WorkspaceScopePayload,
|
||||
} from "@/lib/types";
|
||||
import type { ChatSummary, SessionAutomationJob } from "@/lib/types";
|
||||
|
||||
const connectSpy = vi.fn();
|
||||
const refreshSpy = vi.fn();
|
||||
@@ -19,16 +14,8 @@ const toggleThemeSpy = vi.fn();
|
||||
const updateUrlSpy = vi.fn();
|
||||
const attachSpy = vi.fn();
|
||||
const setSidebarStateSpy = vi.fn();
|
||||
const discardTemporaryChatSpy = vi.fn();
|
||||
const newTemporaryChatSpy = vi.fn<() => Promise<string>>();
|
||||
const sendMessageSpy = vi.fn();
|
||||
const statusHandlers = new Set<(status: ConnectionStatus) => void>();
|
||||
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
||||
const sessionUpdateHandlers = new Set<(
|
||||
chatId: string,
|
||||
scope?: string,
|
||||
workspaceScope?: WorkspaceScopePayload,
|
||||
) => void>();
|
||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||
let mockSessions: ChatSummary[] = [];
|
||||
const HERO_GREETING_PATTERN =
|
||||
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
|
||||
@@ -210,24 +197,16 @@ vi.mock("@/lib/bootstrap", () => ({
|
||||
clearSavedSecret: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/nanobot-client", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/nanobot-client")>();
|
||||
vi.mock("@/lib/nanobot-client", () => {
|
||||
class MockClient {
|
||||
status = "idle" as const;
|
||||
defaultChatId: string | null = null;
|
||||
connect = connectSpy;
|
||||
onStatus = (handler: (status: ConnectionStatus) => void) => {
|
||||
statusHandlers.add(handler);
|
||||
return () => statusHandlers.delete(handler);
|
||||
};
|
||||
onStatus = () => () => {};
|
||||
onRuntimeModelUpdate = () => () => {};
|
||||
onError = () => () => {};
|
||||
onChat = () => () => {};
|
||||
onSessionUpdate = (handler: (
|
||||
chatId: string,
|
||||
scope?: string,
|
||||
workspaceScope?: WorkspaceScopePayload,
|
||||
) => void) => {
|
||||
onSessionUpdate = (handler: (chatId: string, scope?: string) => void) => {
|
||||
sessionUpdateHandlers.add(handler);
|
||||
return () => sessionUpdateHandlers.delete(handler);
|
||||
};
|
||||
@@ -237,18 +216,16 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
|
||||
};
|
||||
getRunStartedAt = () => null;
|
||||
getGoalState = () => undefined;
|
||||
sendMessage = sendMessageSpy;
|
||||
sendMessage = vi.fn();
|
||||
newChat = vi.fn();
|
||||
newTemporaryChat = newTemporaryChatSpy;
|
||||
attach = attachSpy;
|
||||
setSidebarState = setSidebarStateSpy;
|
||||
discardTemporaryChat = discardTemporaryChatSpy;
|
||||
close = vi.fn();
|
||||
updateUrl = updateUrlSpy;
|
||||
updateMaxFrameBytes = vi.fn();
|
||||
}
|
||||
|
||||
return { ...actual, NanobotClient: MockClient };
|
||||
return { NanobotClient: MockClient };
|
||||
});
|
||||
|
||||
import {
|
||||
@@ -271,13 +248,6 @@ describe("App layout", () => {
|
||||
toggleThemeSpy.mockReset();
|
||||
attachSpy.mockReset();
|
||||
setSidebarStateSpy.mockReset();
|
||||
discardTemporaryChatSpy.mockReset();
|
||||
let temporaryChatCounter = 0;
|
||||
newTemporaryChatSpy.mockImplementation(async () => (
|
||||
`00000000-0000-4000-8000-${String(++temporaryChatCounter).padStart(12, "0")}`
|
||||
));
|
||||
sendMessageSpy.mockReset();
|
||||
statusHandlers.clear();
|
||||
runStatusHandlers.clear();
|
||||
sessionUpdateHandlers.clear();
|
||||
window.history.replaceState(null, "", "/");
|
||||
@@ -414,243 +384,6 @@ describe("App layout", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a new temporary chat from the hero each time", async () => {
|
||||
const { unmount } = render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
expect(within(sidebar).queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||
const firstToggle = screen.getByRole("button", { name: "Temporary chat" });
|
||||
expect(firstToggle).toHaveAttribute("aria-pressed", "false");
|
||||
fireEvent.click(firstToggle);
|
||||
expect(firstToggle).toHaveAttribute("aria-pressed", "true");
|
||||
expect(window.location.hash).toBe("");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "first private message" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
|
||||
const firstHash = window.location.hash;
|
||||
expect(firstHash).toMatch(/^#\/temporary\/[0-9a-f-]+$/);
|
||||
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||
expect(createChatSpy).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "New topic" }));
|
||||
expect(discardTemporaryChatSpy).not.toHaveBeenCalled();
|
||||
const secondToggle = screen.getByRole("button", { name: "Temporary chat" });
|
||||
expect(secondToggle).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
fireEvent.click(secondToggle);
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "second private message" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
|
||||
const secondHash = window.location.hash;
|
||||
expect(secondHash).toMatch(/^#\/temporary\/[0-9a-f-]+$/);
|
||||
expect(secondHash).not.toBe(firstHash);
|
||||
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||
expect(discardTemporaryChatSpy).not.toHaveBeenCalled();
|
||||
|
||||
expect(within(sidebar).getByText("Temporary chats")).toBeInTheDocument();
|
||||
expect(within(sidebar).getByRole("button", {
|
||||
name: "first private message",
|
||||
})).toBeInTheDocument();
|
||||
expect(within(sidebar).getByRole("button", {
|
||||
name: "second private message",
|
||||
})).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", {
|
||||
name: "first private message",
|
||||
}));
|
||||
await waitFor(() => expect(window.location.hash).toBe(firstHash));
|
||||
expect(within(screen.getByTestId("thread-header")).getByText(
|
||||
"first private message",
|
||||
)).toBeInTheDocument();
|
||||
await waitFor(() => expect(document.title).toBe("first private message · nanobot"));
|
||||
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", {
|
||||
name: "Close temporary chat: first private message",
|
||||
}));
|
||||
await waitFor(() => expect(window.location.hash).toBe(secondHash));
|
||||
expect(within(sidebar).queryByRole("button", {
|
||||
name: "first private message",
|
||||
})).not.toBeInTheDocument();
|
||||
expect(within(sidebar).getByRole("button", {
|
||||
name: "second private message",
|
||||
})).toBeInTheDocument();
|
||||
expect(discardTemporaryChatSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
await waitFor(() => expect(discardTemporaryChatSpy).toHaveBeenCalledTimes(2));
|
||||
const discardedChatIds = discardTemporaryChatSpy.mock.calls.map(([chatId]) => chatId);
|
||||
expect(new Set(discardedChatIds).size).toBe(2);
|
||||
expect(discardedChatIds).toEqual([
|
||||
"00000000-0000-4000-8000-000000000001",
|
||||
"00000000-0000-4000-8000-000000000002",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the temporary-chat control only on the new-topic hero", async () => {
|
||||
mockSessions = [{
|
||||
key: "websocket:existing-chat",
|
||||
channel: "websocket",
|
||||
chatId: "existing-chat",
|
||||
createdAt: "2026-08-06T10:00:00Z",
|
||||
updatedAt: "2026-08-06T10:00:00Z",
|
||||
preview: "Existing topic",
|
||||
}];
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
const heroHeader = screen.getByTestId("thread-header");
|
||||
const heroTemporaryToggle = within(heroHeader).getByRole("button", {
|
||||
name: "Temporary chat",
|
||||
});
|
||||
const themeToggle = within(heroHeader).getByRole("button", {
|
||||
name: "Toggle theme from header",
|
||||
});
|
||||
expect(within(sidebar).queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||
expect(within(screen.getByTestId("thread-composer-motion")).queryByRole("button", {
|
||||
name: "Temporary chat",
|
||||
})).not.toBeInTheDocument();
|
||||
expect(heroTemporaryToggle.compareDocumentPosition(themeToggle)
|
||||
& Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.hover(heroTemporaryToggle);
|
||||
const temporaryTooltip = await screen.findByRole("tooltip");
|
||||
expect(temporaryTooltip).toHaveTextContent("Temporary chat");
|
||||
expect(temporaryTooltip).toHaveTextContent("Not saved to history or memory");
|
||||
expect(within(temporaryTooltip).getByText(
|
||||
"Reloading, closing, or losing the connection ends these chats.",
|
||||
)).toHaveClass("font-medium");
|
||||
await user.unhover(heroTemporaryToggle);
|
||||
|
||||
fireEvent.click(within(sidebar).getByText("Existing topic"));
|
||||
expect(window.location.hash).toBe("#/chat/websocket%3Aexisting-chat");
|
||||
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "New topic" }));
|
||||
const temporaryToggle = screen.getByRole("button", { name: "Temporary chat" });
|
||||
expect(temporaryToggle).toHaveClass("h-8", "w-8", "rounded-full");
|
||||
expect(within(temporaryToggle).queryByText("Temporary chat")).not.toBeInTheDocument();
|
||||
fireEvent.click(temporaryToggle);
|
||||
expect(temporaryToggle).toHaveAttribute("aria-pressed", "true");
|
||||
expect(temporaryToggle).toHaveClass("bg-transparent", "shadow-none", "hover:bg-transparent");
|
||||
expect(within(temporaryToggle).getByTestId("temporary-chat-icon")).toHaveClass(
|
||||
"motion-safe:duration-150",
|
||||
"text-[var(--temporary-control-active)]",
|
||||
);
|
||||
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("temporary-chat-outline")).not.toBeInTheDocument();
|
||||
fireEvent.click(temporaryToggle);
|
||||
expect(temporaryToggle).toHaveAttribute("aria-pressed", "false");
|
||||
expect(within(temporaryToggle).getByTestId("temporary-chat-icon")).toHaveClass(
|
||||
"motion-safe:duration-75",
|
||||
"text-current",
|
||||
);
|
||||
fireEvent.click(temporaryToggle);
|
||||
expect(window.location.hash).toBe("#/new");
|
||||
expect(temporaryToggle).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "start temporary chat" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
|
||||
|
||||
expect(screen.queryByText("Not saved")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Clear temporary chat" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows leaving a page with temporary chats without blocking", async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Temporary chat" }));
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "do not lose this" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
|
||||
|
||||
const beforeUnload = new Event("beforeunload", { cancelable: true });
|
||||
act(() => window.dispatchEvent(beforeUnload));
|
||||
expect(beforeUnload.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("ends temporary chats quietly after a connection interruption", async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
act(() => {
|
||||
statusHandlers.forEach((handler) => handler("open"));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Temporary chat" }));
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "connection-sensitive message" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
|
||||
|
||||
act(() => {
|
||||
statusHandlers.forEach((handler) => handler("reconnecting"));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(window.location.hash).toBe("#/new"));
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("connection-sensitive message")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the restricted default scope without offering project selection", async () => {
|
||||
mockFetchRoutes({
|
||||
"/api/workspaces": {
|
||||
schema_version: 1,
|
||||
default_access_mode: "full",
|
||||
default_scope: {
|
||||
project_path: "/tmp/workspace",
|
||||
project_name: "workspace",
|
||||
access_mode: "full",
|
||||
restrict_to_workspace: false,
|
||||
},
|
||||
controls: { can_change_project: true, can_use_full_access: true },
|
||||
},
|
||||
});
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
expect(await screen.findByRole("button", { name: "Choose project" })).toBeInTheDocument();
|
||||
act(() => {
|
||||
sessionUpdateHandlers.forEach((handler) => handler("selected-chat", "metadata", {
|
||||
project_path: "/tmp/selected-project",
|
||||
project_name: "selected-project",
|
||||
access_mode: "full",
|
||||
restrict_to_workspace: false,
|
||||
}));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Temporary chat" }));
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Choose project" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Full Access")).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "temporary project check" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(sendMessageSpy).toHaveBeenCalled());
|
||||
const options = sendMessageSpy.mock.calls.at(-1)?.[3];
|
||||
expect(options?.workspaceScope).toMatchObject({
|
||||
project_path: "/tmp/workspace",
|
||||
access_mode: "restricted",
|
||||
restrict_to_workspace: true,
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -152,39 +152,6 @@ describe("ChatList", () => {
|
||||
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha"));
|
||||
});
|
||||
|
||||
it("shows temporary chats separately and lets the user reopen or close them", async () => {
|
||||
const temporarySession = session({
|
||||
key: "temporary:temporary-one",
|
||||
chatId: "temporary-one",
|
||||
preview: "hi",
|
||||
});
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<ChatList
|
||||
sessions={[]}
|
||||
temporarySessions={[temporarySession]}
|
||||
activeKey={null}
|
||||
onSelect={onSelect}
|
||||
onCloseTemporaryChat={onClose}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const section = screen.getByRole("region", { name: "Temporary chats" });
|
||||
fireEvent.click(within(section).getByRole("button", { name: "hi" }));
|
||||
expect(onSelect).toHaveBeenCalledWith("temporary:temporary-one");
|
||||
|
||||
fireEvent.click(within(section).getByRole("button", {
|
||||
name: "Close temporary chat: hi",
|
||||
}));
|
||||
expect(onClose).toHaveBeenCalledWith("temporary:temporary-one");
|
||||
});
|
||||
|
||||
it("orders chats by latest session activity by default", () => {
|
||||
const sessions = [
|
||||
session({
|
||||
|
||||
@@ -113,25 +113,6 @@ describe("MessageBubble", () => {
|
||||
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("outlines temporary-chat user messages with a short dashed border", () => {
|
||||
const message: UIMessage = {
|
||||
id: "u-temporary",
|
||||
role: "user",
|
||||
content: "private question",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
const { rerender } = render(<MessageBubble message={message} temporary />);
|
||||
const bubble = screen.getByText("private question");
|
||||
|
||||
expect(bubble).toHaveAttribute("data-temporary-message", "true");
|
||||
expect(bubble).toHaveClass("border-dashed", "border-muted-foreground/40", "bg-transparent");
|
||||
|
||||
rerender(<MessageBubble message={message} />);
|
||||
expect(bubble).not.toHaveClass("border-dashed");
|
||||
expect(bubble).toHaveClass("bg-secondary/70");
|
||||
});
|
||||
|
||||
it("does not replay an entrance animation when persisted messages mount", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
@@ -935,14 +916,6 @@ describe("MessageBubble", () => {
|
||||
|
||||
const imageButton = screen.getByRole("button", { name: /view image/i });
|
||||
expect(imageButton).toHaveClass("w-[min(100%,34rem)]", "rounded-[20px]");
|
||||
expect(imageButton).toHaveClass(
|
||||
"border",
|
||||
"border-border/60",
|
||||
"focus-visible:ring-2",
|
||||
);
|
||||
expect(imageButton).not.toHaveClass("hover:scale-[1.01]");
|
||||
expect(imageButton).not.toHaveClass("hover:ring-2");
|
||||
expect(imageButton).not.toHaveClass("hover:ring-primary/25");
|
||||
expect(imageButton).not.toHaveAttribute("title");
|
||||
expect(container.querySelector("img")).toHaveClass("h-auto", "w-full", "object-contain");
|
||||
});
|
||||
|
||||
@@ -71,116 +71,6 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("NanobotClient", () => {
|
||||
it("keeps temporary chats out of attachment and reconnect state", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const chatId = "temp-server-id";
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
const creation = client.newTemporaryChat();
|
||||
expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({
|
||||
type: "new_temporary_chat",
|
||||
});
|
||||
lastSocket().fakeMessage({ event: "attached", chat_id: chatId, temporary: true });
|
||||
await expect(creation).resolves.toBe(chatId);
|
||||
lastSocket().sent = [];
|
||||
client.onChat(chatId, vi.fn());
|
||||
client.sendMessage(chatId, "hello", undefined, { turnId: "turn-1" });
|
||||
|
||||
expect(lastSocket().sent.map((raw) => JSON.parse(raw))).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
chat_id: chatId,
|
||||
content: "hello",
|
||||
turn_id: "turn-1",
|
||||
webui: true,
|
||||
},
|
||||
]);
|
||||
|
||||
client.discardTemporaryChat(chatId);
|
||||
expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({
|
||||
type: "discard_temporary_chat",
|
||||
chat_id: chatId,
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for the temporary attachment when creating a temporary chat", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
|
||||
const creation = client.newTemporaryChat();
|
||||
let resolved = false;
|
||||
void creation.then(() => { resolved = true; });
|
||||
lastSocket().fakeMessage({ event: "attached", chat_id: "ordinary-chat" });
|
||||
await Promise.resolve();
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "attached",
|
||||
chat_id: "server-temporary-chat",
|
||||
temporary: true,
|
||||
});
|
||||
await expect(creation).resolves.toBe("server-temporary-chat");
|
||||
});
|
||||
|
||||
it("forgets every temporary chat when the socket drops", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: true,
|
||||
maxBackoffMs: 1,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const firstHandler = vi.fn();
|
||||
const secondHandler = vi.fn();
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
const firstCreation = client.newTemporaryChat();
|
||||
lastSocket().fakeMessage({
|
||||
event: "attached",
|
||||
chat_id: "temp-drop-a",
|
||||
temporary: true,
|
||||
});
|
||||
await firstCreation;
|
||||
const secondCreation = client.newTemporaryChat();
|
||||
lastSocket().fakeMessage({
|
||||
event: "attached",
|
||||
chat_id: "temp-drop-b",
|
||||
temporary: true,
|
||||
});
|
||||
await secondCreation;
|
||||
lastSocket().sent = [];
|
||||
client.onChat("temp-drop-a", firstHandler);
|
||||
client.onChat("temp-drop-b", secondHandler);
|
||||
firstHandler.mockClear();
|
||||
secondHandler.mockClear();
|
||||
lastSocket().close();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
lastSocket().fakeOpen();
|
||||
lastSocket().fakeMessage({
|
||||
event: "message",
|
||||
chat_id: "temp-drop-a",
|
||||
text: "stale first chat",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "message",
|
||||
chat_id: "temp-drop-b",
|
||||
text: "stale second chat",
|
||||
});
|
||||
|
||||
expect(lastSocket().sent).toEqual([]);
|
||||
expect(firstHandler).not.toHaveBeenCalled();
|
||||
expect(secondHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes events to the matching chat handler", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
@@ -324,31 +214,6 @@ describe("NanobotClient", () => {
|
||||
expect(client.getRunStartedAt("chat-strip")).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the local run strip immediately when a stop is requested", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const handler = vi.fn();
|
||||
client.onRunStatus(handler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-stop",
|
||||
status: "running",
|
||||
started_at: 12_345,
|
||||
turn_id: "turn-stop",
|
||||
});
|
||||
|
||||
client.finishRunLocally("chat-stop");
|
||||
|
||||
expect(client.getRunStartedAt("chat-stop")).toBeNull();
|
||||
expect(client.hasUnsettledRun("chat-stop")).toBe(false);
|
||||
expect(handler).toHaveBeenLastCalledWith("chat-stop", null);
|
||||
});
|
||||
|
||||
it("clears stale run strip when reconnecting after a dropped socket", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
@@ -1070,55 +1070,6 @@ describe("ThreadComposer", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("slides project controls closed without offering a compact replacement", () => {
|
||||
const defaultScope = {
|
||||
project_path: "/Users/test/.nanobot/workspace",
|
||||
project_name: "workspace",
|
||||
access_mode: "full" as const,
|
||||
restrict_to_workspace: false,
|
||||
};
|
||||
const composer = (workspaceControlsHidden: boolean) => (
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Ask anything..."
|
||||
variant="hero"
|
||||
workspaceControlsHidden={workspaceControlsHidden}
|
||||
workspaceScope={defaultScope}
|
||||
workspaceDefaultScope={defaultScope}
|
||||
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
|
||||
onWorkspaceScopeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const { container, rerender } = render(composer(false));
|
||||
const drawer = container.querySelector("[data-composer-workspace-drawer]");
|
||||
|
||||
expect(drawer).toHaveAttribute("data-state", "open");
|
||||
expect(drawer).not.toHaveAttribute("aria-hidden");
|
||||
expect(container.querySelector("[data-composer-workspace-compact]")).not.toBeInTheDocument();
|
||||
|
||||
rerender(composer(true));
|
||||
|
||||
expect(container.querySelector("[data-composer-workspace-drawer]")).toBe(drawer);
|
||||
expect(drawer).toHaveAttribute("data-state", "closed");
|
||||
expect(drawer).toHaveAttribute("aria-hidden", "true");
|
||||
expect(within(drawer as HTMLElement).getByRole("button", {
|
||||
hidden: true,
|
||||
name: "Choose project",
|
||||
})).toBeDisabled();
|
||||
expect(screen.queryByRole("button", { name: "Choose project" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", {
|
||||
name: "Workspace access mode: Full Access",
|
||||
})).not.toBeInTheDocument();
|
||||
|
||||
rerender(composer(false));
|
||||
|
||||
expect(container.querySelector("[data-composer-workspace-drawer]")).toBe(drawer);
|
||||
expect(drawer).toHaveAttribute("data-state", "open");
|
||||
expect(within(drawer as HTMLElement).getByRole("button", {
|
||||
name: "Choose project",
|
||||
})).toBeEnabled();
|
||||
});
|
||||
|
||||
it("uses the native folder picker for project selection on native host", async () => {
|
||||
const onWorkspaceScopeChange = vi.fn();
|
||||
const pickFolder = vi.fn().mockResolvedValue("/Users/test/native-project");
|
||||
@@ -2937,48 +2888,4 @@ describe("ThreadComposer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps temporary chat guidance in memory only", async () => {
|
||||
const onSend = vi.fn();
|
||||
const view = render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
onStop={vi.fn()}
|
||||
isStreaming
|
||||
pendingQueueKey={null}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "do not persist this" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(await screen.findByText("do not persist this")).toBeInTheDocument();
|
||||
expect(
|
||||
window.localStorage.getItem(
|
||||
"nanobot.webui.composerQueuedGuidance.v1:temporary-private",
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
view.unmount();
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
onStop={vi.fn()}
|
||||
isStreaming
|
||||
pendingQueueKey={null}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("do not persist this")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
window.localStorage.getItem(
|
||||
"nanobot.webui.composerQueuedGuidance.v1:temporary-private",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -107,10 +107,6 @@ function makeClient() {
|
||||
};
|
||||
},
|
||||
getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null,
|
||||
finishRunLocally: vi.fn((chatId: string) => {
|
||||
runStartedAtByChatId.delete(chatId);
|
||||
latestRunTurnIdByChatId.delete(chatId);
|
||||
}),
|
||||
hasUnsettledRun: () => false,
|
||||
getRunGeneration: (chatId: string) => runGenerationByChatId.get(chatId) ?? 0,
|
||||
canReconcileCanonicalCompletion,
|
||||
@@ -852,48 +848,6 @@ describe("ThreadShell", () => {
|
||||
expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps temporary messages across navigation and drops them after clear", async () => {
|
||||
const client = makeClient();
|
||||
const view = (
|
||||
chatId: string,
|
||||
temporary: boolean,
|
||||
temporaryChatIds: readonly string[],
|
||||
) => wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session(chatId)}
|
||||
title={temporary ? "Temporary chat" : "Regular chat"}
|
||||
temporary={temporary}
|
||||
temporaryChatIds={temporaryChatIds}
|
||||
onToggleSidebar={() => {}}
|
||||
/>,
|
||||
);
|
||||
const retainedTemporaryChats = ["temporary-live"];
|
||||
const { rerender } = render(view("temporary-live", true, retainedTemporaryChats));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "keep this only in memory" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expectSendMessageWithTurn(
|
||||
client,
|
||||
"temporary-live",
|
||||
"keep this only in memory",
|
||||
));
|
||||
|
||||
rerender(view("regular", false, retainedTemporaryChats));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("keep this only in memory")).not.toBeInTheDocument();
|
||||
});
|
||||
rerender(view("temporary-live", true, retainedTemporaryChats));
|
||||
expect(screen.getByText("keep this only in memory")).toBeInTheDocument();
|
||||
|
||||
rerender(view("temporary-cleared", true, ["temporary-cleared"]));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("keep this only in memory")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("highlights sent skill references without skill metadata", async () => {
|
||||
const client = makeClient();
|
||||
render(wrap(
|
||||
@@ -990,7 +944,6 @@ describe("ThreadShell", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
|
||||
expect(onCreateChat).toHaveBeenCalledWith(null, "start for real");
|
||||
expect(onNewChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1271,7 +1224,7 @@ describe("ThreadShell", () => {
|
||||
|
||||
const greeting = screen.getByRole("heading", { level: 1, name: HERO_GREETING_PATTERN });
|
||||
expect(greeting).toHaveAttribute("data-testid", "hero-greeting");
|
||||
expect(greeting).toHaveClass("select-none", "whitespace-nowrap");
|
||||
expect(greeting).toHaveClass("whitespace-nowrap");
|
||||
expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Write code" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Create a project plan" })).not.toBeInTheDocument();
|
||||
|
||||
@@ -76,7 +76,6 @@ function fakeClient() {
|
||||
return () => set!.delete(h);
|
||||
},
|
||||
sendMessage: vi.fn(),
|
||||
finishRunLocally: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
forkChat: vi.fn(),
|
||||
attach: vi.fn(),
|
||||
@@ -2248,7 +2247,6 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
|
||||
expect(fake.client.sendMessage).toHaveBeenLastCalledWith("chat-stop", "/stop");
|
||||
expect(fake.client.finishRunLocally).toHaveBeenCalledWith("chat-stop");
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].content).toBe("long task");
|
||||
|
||||
Reference in New Issue
Block a user