mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 13:58:36 +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) |
|
| 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 |
|
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
|
||||||
| MCP | Add `tools.mcpServers` config |
|
| 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.
|
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. |
|
| `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 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.
|
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:
|
if pending_id:
|
||||||
pending_ids.add(pending_id)
|
pending_ids.add(pending_id)
|
||||||
return pending_ids
|
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 import sessions as session_tools
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.apps.cli import utils as cli_app_utils
|
from nanobot.apps.cli import utils as cli_app_utils
|
||||||
from nanobot.bus.events import (
|
from nanobot.bus.events import InboundMessage
|
||||||
INBOUND_META_RUNTIME_CONTROL,
|
|
||||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
|
||||||
InboundMessage,
|
|
||||||
)
|
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
RUNTIME_CONTEXT_END,
|
RUNTIME_CONTEXT_END,
|
||||||
RUNTIME_CONTEXT_MESSAGE_META,
|
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:
|
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 (
|
for handler in (
|
||||||
image_generation_tools.handle_runtime_control,
|
image_generation_tools.handle_runtime_control,
|
||||||
mcp_tools.handle_runtime_control,
|
mcp_tools.handle_runtime_control,
|
||||||
@@ -86,7 +79,6 @@ class ContextBuilder:
|
|||||||
channel: str | None = None,
|
channel: str | None = None,
|
||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
workspace: Path | None = None,
|
workspace: Path | None = None,
|
||||||
include_memory: bool = True,
|
|
||||||
include_memory_recent_history: bool = True,
|
include_memory_recent_history: bool = True,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
unified_session: bool = False,
|
unified_session: bool = False,
|
||||||
@@ -101,7 +93,6 @@ class ContextBuilder:
|
|||||||
|
|
||||||
parts.append(render_template("agent/tool_contract.md"))
|
parts.append(render_template("agent/tool_contract.md"))
|
||||||
|
|
||||||
if include_memory:
|
|
||||||
memory = self.memory.read_memory()
|
memory = self.memory.read_memory()
|
||||||
if memory and not self._is_template_content(memory, "memory/MEMORY.md"):
|
if memory and not self._is_template_content(memory, "memory/MEMORY.md"):
|
||||||
parts.append(f"# Memory\n\n## Long-term Memory\n{memory}")
|
parts.append(f"# Memory\n\n## Long-term Memory\n{memory}")
|
||||||
@@ -228,7 +219,6 @@ class ContextBuilder:
|
|||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
|
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
|
||||||
workspace: Path | None = None,
|
workspace: Path | None = None,
|
||||||
include_memory: bool = True,
|
|
||||||
include_memory_recent_history: bool = True,
|
include_memory_recent_history: bool = True,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
unified_session: bool = False,
|
unified_session: bool = False,
|
||||||
@@ -248,7 +238,6 @@ class ContextBuilder:
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
session_summary=session_summary,
|
session_summary=session_summary,
|
||||||
workspace=root,
|
workspace=root,
|
||||||
include_memory=include_memory,
|
|
||||||
include_memory_recent_history=include_memory_recent_history,
|
include_memory_recent_history=include_memory_recent_history,
|
||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
unified_session=unified_session,
|
unified_session=unified_session,
|
||||||
|
|||||||
+10
-50
@@ -398,7 +398,6 @@ class AgentLoop:
|
|||||||
self._mcp_connecting = False
|
self._mcp_connecting = False
|
||||||
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
||||||
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
|
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
|
||||||
self._discarding_sessions: set[str] = set()
|
|
||||||
self._background_tasks: set[asyncio.Task[Any]] = set()
|
self._background_tasks: set[asyncio.Task[Any]] = set()
|
||||||
self._close_mcp_lock = asyncio.Lock()
|
self._close_mcp_lock = asyncio.Lock()
|
||||||
self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
||||||
@@ -722,7 +721,6 @@ class AgentLoop:
|
|||||||
session_summary=ctx.pending_summary,
|
session_summary=ctx.pending_summary,
|
||||||
workspace=scope.project_path,
|
workspace=scope.project_path,
|
||||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||||
include_memory=ctx.session.policy.persist,
|
|
||||||
include_memory_recent_history=not ctx.ephemeral,
|
include_memory_recent_history=not ctx.ephemeral,
|
||||||
session_key=ctx.session.key,
|
session_key=ctx.session.key,
|
||||||
unified_session=self._unified_session,
|
unified_session=self._unified_session,
|
||||||
@@ -788,9 +786,9 @@ class AgentLoop:
|
|||||||
logger.warning("Command '{}' matched but dispatch returned None", raw)
|
logger.warning("Command '{}' matched but dispatch returned None", raw)
|
||||||
|
|
||||||
async def _cancel_active_tasks(self, key: str) -> int:
|
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()))
|
tasks = tuple(self._active_tasks.pop(key, set()))
|
||||||
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
||||||
@@ -798,17 +796,7 @@ class AgentLoop:
|
|||||||
with suppress(asyncio.CancelledError, Exception):
|
with suppress(asyncio.CancelledError, Exception):
|
||||||
await t
|
await t
|
||||||
sub_cancelled = await self.subagents.cancel_by_session(key)
|
sub_cancelled = await self.subagents.cancel_by_session(key)
|
||||||
exec_cancelled = await self._exec_session_manager.terminate_by_owner(key)
|
return cancelled + sub_cancelled
|
||||||
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)
|
|
||||||
|
|
||||||
def _effective_session_key(self, msg: InboundMessage) -> str:
|
def _effective_session_key(self, msg: InboundMessage) -> str:
|
||||||
"""Return the session key used for task routing and mid-turn injections."""
|
"""Return the session key used for task routing and mid-turn injections."""
|
||||||
@@ -1173,11 +1161,6 @@ class AgentLoop:
|
|||||||
effective_key = self._effective_session_key(msg)
|
effective_key = self._effective_session_key(msg)
|
||||||
if await agent_context.handle_runtime_control(self, msg, self.tools):
|
if await agent_context.handle_runtime_control(self, msg, self.tools):
|
||||||
continue
|
continue
|
||||||
if (
|
|
||||||
msg.require_existing_session
|
|
||||||
and self.sessions.get_cached(effective_key) is None
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
if self.commands.is_priority(raw):
|
if self.commands.is_priority(raw):
|
||||||
await self._dispatch_command_inline(
|
await self._dispatch_command_inline(
|
||||||
msg, effective_key, raw,
|
msg, effective_key, raw,
|
||||||
@@ -1296,8 +1279,6 @@ class AgentLoop:
|
|||||||
# _emit_checkpoint during tool execution; materializing
|
# _emit_checkpoint during tool execution; materializing
|
||||||
# it into session history now makes it visible in the
|
# it into session history now makes it visible in the
|
||||||
# next conversation turn.
|
# next conversation turn.
|
||||||
if session_key in self._discarding_sessions:
|
|
||||||
raise
|
|
||||||
try:
|
try:
|
||||||
key = self._effective_session_key(msg)
|
key = self._effective_session_key(msg)
|
||||||
session = self.sessions.get_or_create(key)
|
session = self.sessions.get_or_create(key)
|
||||||
@@ -1575,7 +1556,6 @@ class AgentLoop:
|
|||||||
had_injections: bool,
|
had_injections: bool,
|
||||||
streamed_content: bool,
|
streamed_content: bool,
|
||||||
*,
|
*,
|
||||||
log_content: bool = True,
|
|
||||||
turn_latency_ms: int | None = None,
|
turn_latency_ms: int | None = None,
|
||||||
) -> OutboundMessage | None:
|
) -> OutboundMessage | None:
|
||||||
"""Assemble the final outbound message from turn results."""
|
"""Assemble the final outbound message from turn results."""
|
||||||
@@ -1584,11 +1564,8 @@ class AgentLoop:
|
|||||||
if not had_injections or stop_reason == "empty_final_response":
|
if not had_injections or stop_reason == "empty_final_response":
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if log_content:
|
|
||||||
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
||||||
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
|
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||||
else:
|
|
||||||
logger.info("Response to {}:{}: [content hidden]", msg.channel, msg.sender_id)
|
|
||||||
|
|
||||||
event = None
|
event = None
|
||||||
meta = dict(msg.metadata or {})
|
meta = dict(msg.metadata or {})
|
||||||
@@ -1617,33 +1594,17 @@ class AgentLoop:
|
|||||||
ctx.msg = dataclasses.replace(msg, content=new_content, media=image_paths)
|
ctx.msg = dataclasses.replace(msg, content=new_content, media=image_paths)
|
||||||
msg = ctx.msg
|
msg = ctx.msg
|
||||||
|
|
||||||
if ctx.session is None:
|
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
|
||||||
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
|
|
||||||
|
|
||||||
if ctx.kind is TurnKind.SYSTEM:
|
if ctx.kind is TurnKind.SYSTEM:
|
||||||
logger.info("Processing system message from {}", msg.sender_id)
|
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:
|
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(
|
self._remember_unified_session_route(
|
||||||
session,
|
session,
|
||||||
msg,
|
msg,
|
||||||
@@ -1946,7 +1907,6 @@ class AgentLoop:
|
|||||||
ctx.stop_reason,
|
ctx.stop_reason,
|
||||||
ctx.had_injections,
|
ctx.had_injections,
|
||||||
ctx.streamed_content,
|
ctx.streamed_content,
|
||||||
log_content=ctx.require_session().policy.log_content,
|
|
||||||
turn_latency_ms=ctx.turn_latency_ms,
|
turn_latency_ms=ctx.turn_latency_ms,
|
||||||
)
|
)
|
||||||
if ctx.ephemeral and ctx.outbound is not None:
|
if ctx.ephemeral and ctx.outbound is not None:
|
||||||
|
|||||||
+28
-8
@@ -9,6 +9,8 @@ from typing import Any, cast
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
from nanobot.agent.agent_plugins import discover_agent_plugin_skills
|
||||||
|
|
||||||
# Default builtin skills directory (relative to this file)
|
# Default builtin skills directory (relative to this file)
|
||||||
BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills"
|
BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills"
|
||||||
|
|
||||||
@@ -33,6 +35,7 @@ class SkillsLoader:
|
|||||||
self.workspace_skills = workspace / "skills"
|
self.workspace_skills = workspace / "skills"
|
||||||
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
|
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
|
||||||
self.disabled_skills = disabled_skills or set()
|
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]]:
|
def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]:
|
||||||
if not base.exists():
|
if not base.exists():
|
||||||
@@ -60,11 +63,24 @@ class SkillsLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
List of skill info dicts with 'name', 'path', 'source'.
|
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")
|
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():
|
if self.builtin_skills and self.builtin_skills.exists():
|
||||||
skills.extend(
|
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:
|
if self.disabled_skills:
|
||||||
@@ -84,13 +100,16 @@ class SkillsLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
Skill content or None if not found.
|
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:
|
if self.builtin_skills:
|
||||||
roots.append(self.builtin_skills)
|
builtin_path = self.builtin_skills / name / "SKILL.md"
|
||||||
for root in roots:
|
if builtin_path.exists():
|
||||||
path = root / name / "SKILL.md"
|
return builtin_path.read_text(encoding="utf-8")
|
||||||
if path.exists():
|
|
||||||
return path.read_text(encoding="utf-8")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
||||||
@@ -145,6 +164,7 @@ class SkillsLoader:
|
|||||||
sections: list[str] = []
|
sections: list[str] = []
|
||||||
groups = (
|
groups = (
|
||||||
("Workspace skills", "workspace", self.workspace_skills),
|
("Workspace skills", "workspace", self.workspace_skills),
|
||||||
|
("Agent Plugin skills", "plugin", self.workspace / "plugins"),
|
||||||
("Built-in skills", "builtin", self.builtin_skills),
|
("Built-in skills", "builtin", self.builtin_skills),
|
||||||
)
|
)
|
||||||
for label, source, root in groups:
|
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
|
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(
|
||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
path=StringSchema("The file path to edit"),
|
path=StringSchema("The file path to edit"),
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from nanobot.config.paths import get_media_dir
|
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(
|
def resolve_workspace_path(
|
||||||
|
|||||||
+75
-12
@@ -18,6 +18,7 @@ from typing import Any, cast
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import yaml
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
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_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_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
|
||||||
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
|
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_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"
|
NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
|
||||||
_CATALOG_SOURCES = (
|
_CATALOG_SOURCES = (
|
||||||
@@ -41,6 +43,8 @@ _MAX_ARTIFACT_REPORT = 12
|
|||||||
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
|
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
|
||||||
_SAFE_NPM_DIR_RE = re.compile(r"^[a-z0-9._-]+$", re.IGNORECASE)
|
_SAFE_NPM_DIR_RE = re.compile(r"^[a-z0-9._-]+$", re.IGNORECASE)
|
||||||
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", 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 = ("|", "&&", "||", ";", "$(", "`", ">", "<")
|
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
|
||||||
_ENDORSEMENT_WORD_RE = re.compile(r"\bofficial\s+", re.IGNORECASE)
|
_ENDORSEMENT_WORD_RE = re.compile(r"\bofficial\s+", re.IGNORECASE)
|
||||||
_ARTIFACT_EXTENSIONS = frozenset({
|
_ARTIFACT_EXTENSIONS = frozenset({
|
||||||
@@ -211,10 +215,21 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
|
|||||||
|
|
||||||
|
|
||||||
def _safe_skill_name(name: str) -> str:
|
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("-")
|
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
||||||
return f"cli-app-{clean or 'app'}"
|
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:
|
def _has_shell_meta(command: str) -> bool:
|
||||||
return any(char in command for char in _SHELL_META_CHARS)
|
return any(char in command for char in _SHELL_META_CHARS)
|
||||||
|
|
||||||
@@ -613,7 +628,7 @@ class CliAppManager:
|
|||||||
"name": installed_name,
|
"name": installed_name,
|
||||||
"entry_point": entry_point,
|
"entry_point": entry_point,
|
||||||
"source": str(data.get("source") or ""),
|
"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",
|
"tool": "run_cli_app",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -640,7 +655,20 @@ class CliAppManager:
|
|||||||
return not _has_shell_meta(install_cmd)
|
return not _has_shell_meta(install_cmd)
|
||||||
|
|
||||||
def _skill_path(self, name: str) -> Path:
|
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(
|
def _app_payload(
|
||||||
self,
|
self,
|
||||||
@@ -677,7 +705,7 @@ class CliAppManager:
|
|||||||
"status": status,
|
"status": status,
|
||||||
"logo_url": logo_url,
|
"logo_url": logo_url,
|
||||||
"brand_color": brand_color,
|
"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),
|
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,7 +741,8 @@ class CliAppManager:
|
|||||||
name = str(app["name"])
|
name = str(app["name"])
|
||||||
entry_point = str(app.get("entry_point") or "")
|
entry_point = str(app.get("entry_point") or "")
|
||||||
strategy = self._strategy(app)
|
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 = [
|
capabilities = [
|
||||||
compact_dict({
|
compact_dict({
|
||||||
"type": "cli",
|
"type": "cli",
|
||||||
@@ -726,13 +755,13 @@ class CliAppManager:
|
|||||||
install = compact_dict({
|
install = compact_dict({
|
||||||
"supported": install_supported,
|
"supported": install_supported,
|
||||||
"strategy": strategy,
|
"strategy": strategy,
|
||||||
"managed_paths": [skill_path],
|
"managed_paths": [plugin_path],
|
||||||
"verification": ["entry_point_available"] if entry_point else [],
|
"verification": ["entry_point_available"] if entry_point else [],
|
||||||
})
|
})
|
||||||
remove = compact_dict({
|
remove = compact_dict({
|
||||||
"supported": strategy != "unsupported",
|
"supported": strategy != "unsupported",
|
||||||
"strategy": strategy,
|
"strategy": strategy,
|
||||||
"managed_paths": [skill_path],
|
"managed_paths": [plugin_path],
|
||||||
"verification": (
|
"verification": (
|
||||||
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
|
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
|
||||||
if strategy not in {"bundled", "unsupported"}
|
if strategy not in {"bundled", "unsupported"}
|
||||||
@@ -1032,11 +1061,10 @@ class CliAppManager:
|
|||||||
name = str(app.get("name") or "unknown")
|
name = str(app.get("name") or "unknown")
|
||||||
display = str(app.get("display_name") or name)
|
display = str(app.get("display_name") or name)
|
||||||
entry = str(app.get("entry_point") or f"cli-anything-{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"""---
|
return f"""---
|
||||||
name: {_safe_skill_name(name)}
|
name: {_safe_skill_name(name)}
|
||||||
description: >-
|
description: {json.dumps(description, ensure_ascii=False)}
|
||||||
{description}
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# {display}
|
# {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 "".join(lines[: index + 1]) + "\n" + note + "\n" + "".join(lines[index + 1 :])
|
||||||
return note + "\n" + content
|
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:
|
def install_skill(self, app: dict[str, Any]) -> Path:
|
||||||
path = self._skill_path(str(app["name"]))
|
path = self._skill_path(str(app["name"]))
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
content = self._fetch_skill_content(app) or self._fallback_skill(app)
|
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)
|
content = self._with_nanobot_skill_note(content, app)
|
||||||
path.write_text(content, encoding="utf-8")
|
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
|
return path
|
||||||
|
|
||||||
def remove_skill(self, name: str) -> None:
|
def remove_skill(self, name: str) -> None:
|
||||||
skill_dir = self._skill_path(name).parent
|
plugin_root = self._skill_path(name).parents[2]
|
||||||
if skill_dir.is_dir():
|
if plugin_root.is_dir():
|
||||||
shutil.rmtree(skill_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]:
|
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||||
installed = self._load_installed()
|
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 {}
|
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(
|
def runtime_lines_for_request(
|
||||||
text: str,
|
text: str,
|
||||||
metadata: Mapping[str, Any] | None,
|
metadata: Mapping[str, Any] | None,
|
||||||
@@ -20,6 +29,9 @@ def runtime_lines_for_request(
|
|||||||
"""Return CLI App annotations from an immutable request snapshot."""
|
"""Return CLI App annotations from an immutable request snapshot."""
|
||||||
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||||
if isinstance(structured, list):
|
if isinstance(structured, list):
|
||||||
|
from nanobot.apps.cli import CliAppManager
|
||||||
|
|
||||||
|
manager = CliAppManager(workspace=workspace)
|
||||||
structured_items = cast(list[Any], structured)
|
structured_items = cast(list[Any], structured)
|
||||||
mentions = [
|
mentions = [
|
||||||
cast(Mapping[str, Any], item) for item in structured_items
|
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"@{str(item['name']).strip().lower()} "
|
||||||
f"(installed; tool=run_cli_app; "
|
f"(installed; tool=run_cli_app; "
|
||||||
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
|
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."
|
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
||||||
for item in mentions
|
for item in mentions
|
||||||
if str(item.get("name") or "").strip()
|
if str(item.get("name") or "").strip()
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
|
|||||||
RUNTIME_CONTROL_ACK = "_ack"
|
RUNTIME_CONTROL_ACK = "_ack"
|
||||||
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
|
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
|
||||||
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
|
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
|
||||||
RUNTIME_CONTROL_SESSION_DISCARD = "session_discard"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -33,7 +32,6 @@ class InboundMessage:
|
|||||||
media: list[str] = field(default_factory=list) # Media URLs
|
media: list[str] = field(default_factory=list) # Media URLs
|
||||||
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
|
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
|
||||||
session_key_override: str | None = None # Optional override for thread-scoped sessions
|
session_key_override: str | None = None # Optional override for thread-scoped sessions
|
||||||
require_existing_session: bool = False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def session_key(self) -> str:
|
def session_key(self) -> str:
|
||||||
|
|||||||
@@ -262,7 +262,6 @@ class BaseChannel(ABC):
|
|||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
is_dm: bool = False,
|
is_dm: bool = False,
|
||||||
authorization_id: str | None = None,
|
authorization_id: str | None = None,
|
||||||
require_existing_session: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle a message after checking its authorization subject.
|
"""Handle a message after checking its authorization subject.
|
||||||
|
|
||||||
@@ -315,7 +314,6 @@ class BaseChannel(ABC):
|
|||||||
media=media or [],
|
media=media or [],
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
session_key_override=session_key,
|
session_key_override=session_key,
|
||||||
require_existing_session=require_existing_session,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.bus.publish_inbound(msg)
|
await self.bus.publish_inbound(msg)
|
||||||
|
|||||||
@@ -470,6 +470,15 @@ def _extract_post_content(content_json: dict[str, Any]) -> tuple[str, list[str]]
|
|||||||
return "", []
|
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
|
# QR scan-to-create onboarding
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -658,6 +658,11 @@ class MattermostChannel(BaseChannel):
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return cast(dict[str, Any], resp.json())
|
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(
|
async def _create_post(
|
||||||
self,
|
self,
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
@@ -676,6 +681,9 @@ class MattermostChannel(BaseChannel):
|
|||||||
body["file_ids"] = file_ids
|
body["file_ids"] = file_ids
|
||||||
return await self._api_post("/api/v4/posts", body)
|
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:
|
async def _upload_file(self, channel_id: str, file_path: str) -> str | None:
|
||||||
path = Path(file_path)
|
path = Path(file_path)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
|
|||||||
@@ -811,6 +811,11 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("Failed to save conversation refs: {}", 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:
|
async def _get_access_token(self) -> str:
|
||||||
"""Fetch an access token for Bot Framework / Azure Bot auth."""
|
"""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()
|
||||||
ch._save_refs_locked()
|
|
||||||
|
|
||||||
assert set(ch._conversation_refs.keys()) == {"conv-valid"}
|
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")
|
raise OSError("replace failed")
|
||||||
|
|
||||||
monkeypatch.setattr(msteams_module.os, "replace", _raise_replace)
|
monkeypatch.setattr(msteams_module.os, "replace", _raise_replace)
|
||||||
with ch._refs_guard:
|
ch._save_refs()
|
||||||
ch._save_refs_locked()
|
|
||||||
|
|
||||||
persisted = json.loads(refs_path.read_text(encoding="utf-8"))
|
persisted = json.loads(refs_path.read_text(encoding="utf-8"))
|
||||||
assert set(persisted.keys()) == {"conv-old"}
|
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()
|
||||||
ch._save_refs_locked()
|
|
||||||
|
|
||||||
assert set(ch._conversation_refs) == {"teams-good"}
|
assert set(ch._conversation_refs) == {"teams-good"}
|
||||||
saved = json.loads(ch._refs_path.read_text(encoding="utf-8"))
|
saved = json.loads(ch._refs_path.read_text(encoding="utf-8"))
|
||||||
|
|||||||
@@ -431,7 +431,6 @@ class SignalChannel(BaseChannel):
|
|||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
is_dm: bool = False,
|
is_dm: bool = False,
|
||||||
authorization_id: str | None = None,
|
authorization_id: str | None = None,
|
||||||
require_existing_session: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle an inbound message whose policy has already been checked.
|
"""Handle an inbound message whose policy has already been checked.
|
||||||
|
|
||||||
@@ -454,7 +453,6 @@ class SignalChannel(BaseChannel):
|
|||||||
media=media or [],
|
media=media or [],
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
session_key_override=session_key,
|
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.exceptions import ConnectionClosed
|
||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
|
|
||||||
from nanobot.bus.events import (
|
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
|
||||||
OUTBOUND_META_AGENT_UI,
|
|
||||||
OutboundMessage,
|
|
||||||
)
|
|
||||||
from nanobot.bus.outbound_events import (
|
from nanobot.bus.outbound_events import (
|
||||||
GoalStateSyncEvent,
|
GoalStateSyncEvent,
|
||||||
GoalStatusEvent,
|
GoalStatusEvent,
|
||||||
@@ -33,6 +30,7 @@ from nanobot.bus.outbound_events import (
|
|||||||
TurnEndEvent,
|
TurnEndEvent,
|
||||||
TurnModelUpdatedEvent,
|
TurnModelUpdatedEvent,
|
||||||
outbound_event_from_message,
|
outbound_event_from_message,
|
||||||
|
outbound_message_for_event,
|
||||||
)
|
)
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
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.goal_state import goal_state_ws_blob
|
||||||
from nanobot.session.webui_turns import (
|
from nanobot.session.webui_turns import (
|
||||||
clear_websocket_turn_if_current,
|
clear_websocket_turn_if_current,
|
||||||
clear_websocket_turns,
|
|
||||||
mark_websocket_turn_transcript_persistence_failed,
|
mark_websocket_turn_transcript_persistence_failed,
|
||||||
register_queued_websocket_turn_if_idle,
|
register_queued_websocket_turn_if_idle,
|
||||||
websocket_turn_id,
|
websocket_turn_id,
|
||||||
@@ -85,7 +82,6 @@ from nanobot.webui.session_access import (
|
|||||||
session_mentions_runtime_context,
|
session_mentions_runtime_context,
|
||||||
)
|
)
|
||||||
from nanobot.webui.sidebar_state import write_webui_sidebar_state
|
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.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
|
||||||
from nanobot.webui.transcription_ws import webui_transcription_event
|
from nanobot.webui.transcription_ws import webui_transcription_event
|
||||||
from nanobot.webui.websocket_logging import websockets_server_logger
|
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:
|
def _parse_inbound_payload(raw: str) -> str | None:
|
||||||
"""Parse a client frame into text; return None for empty or unrecognized content."""
|
"""Parse a client frame into text; return None for empty or unrecognized content."""
|
||||||
text = raw.strip()
|
text = raw.strip()
|
||||||
@@ -383,7 +394,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._ingress = gateway.ingress
|
self._ingress = gateway.ingress
|
||||||
self._transcripts = gateway.transcripts
|
self._transcripts = gateway.transcripts
|
||||||
self._workspaces = gateway.workspaces
|
self._workspaces = gateway.workspaces
|
||||||
self._temporary_chats = gateway.temporary_chats
|
|
||||||
self._session_access = (
|
self._session_access = (
|
||||||
WebuiSessionAccess(gateway.session_manager)
|
WebuiSessionAccess(gateway.session_manager)
|
||||||
if gateway.session_manager is not None
|
if gateway.session_manager is not None
|
||||||
@@ -402,33 +412,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._subs.setdefault(chat_id, set()).add(connection)
|
self._subs.setdefault(chat_id, set()).add(connection)
|
||||||
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
||||||
|
|
||||||
def _detach(self, connection: ServerConnection, chat_id: str) -> None:
|
|
||||||
chats = self._conn_chats.get(connection)
|
|
||||||
if chats is not None:
|
|
||||||
chats.discard(chat_id)
|
|
||||||
if not chats:
|
|
||||||
self._conn_chats.pop(connection, None)
|
|
||||||
subscribers = self._subs.get(chat_id)
|
|
||||||
if subscribers is not None:
|
|
||||||
subscribers.discard(connection)
|
|
||||||
if not subscribers:
|
|
||||||
self._subs.pop(chat_id, None)
|
|
||||||
|
|
||||||
def _clear_stream_buffers(self, chat_id: str) -> None:
|
|
||||||
for key in tuple(self._stream_text_buffers):
|
|
||||||
if key[0] == chat_id:
|
|
||||||
self._stream_text_buffers.pop(key, None)
|
|
||||||
|
|
||||||
async def _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(
|
async def send_webui_protocol_error(
|
||||||
self,
|
self,
|
||||||
connection: ServerConnection,
|
connection: ServerConnection,
|
||||||
@@ -457,16 +440,16 @@ class WebSocketChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
await self._hydrate_after_subscribe(fork_id)
|
await self._hydrate_after_subscribe(fork_id)
|
||||||
|
|
||||||
async def _cleanup_connection(self, connection: ServerConnection) -> None:
|
def _cleanup_connection(self, connection: ServerConnection) -> None:
|
||||||
"""Remove *connection* from every subscription set; safe to call multiple times."""
|
"""Remove *connection* from every subscription set; safe to call multiple times."""
|
||||||
chat_ids = tuple(self._conn_chats.get(connection, ()))
|
chat_ids = self._conn_chats.pop(connection, set())
|
||||||
for cid in chat_ids:
|
for cid in chat_ids:
|
||||||
if self._temporary_chats.owns(connection, cid):
|
subs = self._subs.get(cid)
|
||||||
await self._discard_connection_owned_chat(connection, cid)
|
if subs is None:
|
||||||
else:
|
continue
|
||||||
self._detach(connection, cid)
|
subs.discard(connection)
|
||||||
for cid in self._temporary_chats.chat_ids_for_owner(connection):
|
if not subs:
|
||||||
await self._discard_connection_owned_chat(connection, cid)
|
self._subs.pop(cid, None)
|
||||||
self._conn_default.pop(connection, None)
|
self._conn_default.pop(connection, None)
|
||||||
self._webui_connections.discard(connection)
|
self._webui_connections.discard(connection)
|
||||||
|
|
||||||
@@ -519,7 +502,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
await connection.send(raw)
|
await connection.send(raw)
|
||||||
except ConnectionClosed:
|
except ConnectionClosed:
|
||||||
await self._cleanup_connection(connection)
|
self._cleanup_connection(connection)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("failed to send {} event: {}", event, e)
|
self.logger.warning("failed to send {} event: {}", event, e)
|
||||||
|
|
||||||
@@ -746,7 +729,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug("connection ended: {}", e)
|
self.logger.debug("connection ended: {}", e)
|
||||||
finally:
|
finally:
|
||||||
await self._cleanup_connection(connection)
|
self._cleanup_connection(connection)
|
||||||
|
|
||||||
# -- Inbound WebSocket envelopes ---------------------------------------
|
# -- Inbound WebSocket envelopes ---------------------------------------
|
||||||
|
|
||||||
@@ -781,46 +764,14 @@ class WebSocketChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
await self._hydrate_after_subscribe(new_id)
|
await self._hydrate_after_subscribe(new_id)
|
||||||
return
|
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":
|
if t == "fork_chat":
|
||||||
await handle_webui_fork_chat(self, connection, envelope)
|
await handle_webui_fork_chat(self, connection, envelope)
|
||||||
return
|
return
|
||||||
if t == "discard_temporary_chat":
|
|
||||||
cid = envelope.get("chat_id")
|
|
||||||
if not _is_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":
|
if t == "attach":
|
||||||
cid = envelope.get("chat_id")
|
cid = envelope.get("chat_id")
|
||||||
if not _is_valid_chat_id(cid):
|
if not _is_valid_chat_id(cid):
|
||||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||||
return
|
return
|
||||||
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)
|
self._attach(connection, cid)
|
||||||
await self._send_event(connection, "attached", chat_id=cid)
|
await self._send_event(connection, "attached", chat_id=cid)
|
||||||
await self._hydrate_after_subscribe(cid)
|
await self._hydrate_after_subscribe(cid)
|
||||||
@@ -854,11 +805,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if not _is_valid_chat_id(cid):
|
if not _is_valid_chat_id(cid):
|
||||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||||
return
|
return
|
||||||
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(
|
scope = await self._workspace_scope_or_error(
|
||||||
connection,
|
connection,
|
||||||
lambda: self._workspaces.scope_for_set_request(
|
lambda: self._workspaces.scope_for_set_request(
|
||||||
@@ -927,21 +873,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
return
|
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")
|
raw_media = envelope.get("media")
|
||||||
media_paths: list[str] = []
|
media_paths: list[str] = []
|
||||||
if raw_media is not None:
|
if raw_media is not None:
|
||||||
@@ -964,8 +895,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
**rejection_fields,
|
**rejection_fields,
|
||||||
)
|
)
|
||||||
return
|
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).
|
# Allow media-only turns (content may be empty when attachments are present).
|
||||||
if not content.strip() and not media_paths:
|
if not content.strip() and not media_paths:
|
||||||
@@ -978,21 +907,16 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
# Auto-attach on first use so clients can one-shot without a separate attach.
|
# Auto-attach on first use so clients can one-shot without a separate attach.
|
||||||
self._attach(connection, cid)
|
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.
|
# Resolve after hydration so a concurrent downgrade cannot be overwritten.
|
||||||
scope = await self._workspace_scope_or_error(
|
scope = await self._workspace_scope_or_error(
|
||||||
connection,
|
connection,
|
||||||
lambda: (
|
lambda: self._workspaces.scope_for_message(
|
||||||
temporary_policy.workspace_scope
|
|
||||||
if temporary_policy is not None
|
|
||||||
else self._workspaces.scope_for_message(
|
|
||||||
envelope,
|
envelope,
|
||||||
chat_id=cid,
|
chat_id=cid,
|
||||||
chat_running=websocket_turn_wall_started_at(cid) is not None,
|
chat_running=websocket_turn_wall_started_at(cid) is not None,
|
||||||
controls_available=self._workspace_controls_available(connection),
|
controls_available=self._workspace_controls_available(connection),
|
||||||
)
|
|
||||||
),
|
),
|
||||||
chat_id=cid,
|
chat_id=cid,
|
||||||
turn_id=turn_id,
|
turn_id=turn_id,
|
||||||
@@ -1045,13 +969,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
|
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
|
||||||
accepted = False
|
accepted = False
|
||||||
try:
|
try:
|
||||||
if (
|
if is_webui:
|
||||||
is_webui
|
|
||||||
and (
|
|
||||||
temporary_policy is None
|
|
||||||
or temporary_policy.persist_transcript
|
|
||||||
)
|
|
||||||
):
|
|
||||||
self._transcripts.append_user_message(
|
self._transcripts.append_user_message(
|
||||||
cid,
|
cid,
|
||||||
content,
|
content,
|
||||||
@@ -1080,16 +998,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
media=media_paths or None,
|
media=media_paths or None,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
is_dm=False,
|
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
|
accepted = True
|
||||||
finally:
|
finally:
|
||||||
@@ -1150,7 +1058,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._conn_default.clear()
|
self._conn_default.clear()
|
||||||
self._webui_connections.clear()
|
self._webui_connections.clear()
|
||||||
self._tokens.clear()
|
self._tokens.clear()
|
||||||
self._temporary_chats.close()
|
|
||||||
|
|
||||||
async def _safe_send_to(
|
async def _safe_send_to(
|
||||||
self,
|
self,
|
||||||
@@ -1163,7 +1070,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
await connection.send(raw)
|
await connection.send(raw)
|
||||||
except ConnectionClosed:
|
except ConnectionClosed:
|
||||||
await self._cleanup_connection(connection)
|
self._cleanup_connection(connection)
|
||||||
self.logger.warning("connection gone{}", label)
|
self.logger.warning("connection gone{}", label)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("send failed{}", label)
|
self.logger.exception("send failed{}", label)
|
||||||
@@ -1180,8 +1087,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
transcript_overrides: dict[str, Any] | None = None,
|
transcript_overrides: dict[str, Any] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Persist one canonical turn event and retain unsafe owners on failure."""
|
"""Persist one canonical turn event and retain unsafe owners on failure."""
|
||||||
if not self._temporary_chats.should_persist_transcript(chat_id):
|
|
||||||
return True
|
|
||||||
persisted = self._transcripts.prepare_and_append(
|
persisted = self._transcripts.prepare_and_append(
|
||||||
chat_id,
|
chat_id,
|
||||||
event,
|
event,
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ from websockets.exceptions import ConnectionClosed
|
|||||||
from websockets.frames import Close
|
from websockets.frames import Close
|
||||||
|
|
||||||
from nanobot.bus.events import (
|
from nanobot.bus.events import (
|
||||||
INBOUND_META_RUNTIME_CONTROL,
|
|
||||||
OUTBOUND_META_AGENT_UI,
|
OUTBOUND_META_AGENT_UI,
|
||||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
|
||||||
OutboundMessage,
|
OutboundMessage,
|
||||||
)
|
)
|
||||||
from nanobot.bus.outbound_events import (
|
from nanobot.bus.outbound_events import (
|
||||||
@@ -34,11 +32,11 @@ from nanobot.channels.websocket.runtime import (
|
|||||||
_is_valid_chat_id,
|
_is_valid_chat_id,
|
||||||
_parse_envelope,
|
_parse_envelope,
|
||||||
_parse_inbound_payload,
|
_parse_inbound_payload,
|
||||||
|
publish_runtime_model_update,
|
||||||
)
|
)
|
||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import load_config, save_config
|
||||||
from nanobot.config.schema import Config, ModelPresetConfig
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META, WEBUI_QUOTE_SOURCE
|
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 import webui_turns as wth
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
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()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
|
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
|
||||||
class Conn:
|
class Conn:
|
||||||
@@ -1403,14 +1105,8 @@ async def test_send_broadcasts_runtime_model_updates() -> None:
|
|||||||
mock_ws = AsyncMock()
|
mock_ws = AsyncMock()
|
||||||
channel._attach(mock_ws, "chat-1")
|
channel._attach(mock_ws, "chat-1")
|
||||||
|
|
||||||
await channel.send(
|
publish_runtime_model_update(bus, "openai/gpt-4.1", "fast")
|
||||||
OutboundMessage(
|
await channel.send(bus.outbound.get_nowait())
|
||||||
channel="websocket",
|
|
||||||
chat_id="*",
|
|
||||||
content="",
|
|
||||||
event=RuntimeModelUpdatedEvent(model="openai/gpt-4.1", model_preset="fast"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = json.loads(mock_ws.send.call_args[0][0])
|
payload = json.loads(mock_ws.send.call_args[0][0])
|
||||||
assert payload["event"] == "runtime_model_updated"
|
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()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None:
|
async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from nanobot.webui.gateway_services import build_gateway_services
|
|||||||
from nanobot.webui.media_api import (
|
from nanobot.webui.media_api import (
|
||||||
b64url_decode,
|
b64url_decode,
|
||||||
b64url_encode,
|
b64url_encode,
|
||||||
sign_media_path,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from .ws_test_client import InProcessHttpChannel
|
from .ws_test_client import InProcessHttpChannel
|
||||||
@@ -85,16 +84,8 @@ def _fake_media_dir(root: Path):
|
|||||||
return inner
|
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()
|
media.mkdir()
|
||||||
channel = _ch(bus, port=0)
|
channel = _ch(bus, port=0)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
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
|
# Traversal via the media root is also rejected — the resolve() step
|
||||||
# normalises ``..`` out before the relative_to check.
|
# 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(
|
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)
|
(media / "a.png").write_bytes(_PNG_BYTES)
|
||||||
channel = _ch(bus, port=0)
|
channel = _ch(bus, port=0)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
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 is not None
|
||||||
assert url.startswith("/api/media/")
|
assert url.startswith("/api/media/")
|
||||||
sig, payload = url[len("/api/media/"):].split("/", 1)
|
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)
|
channel = _ch(bus, port=29920)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
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
|
assert url_path is not None
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
try:
|
try:
|
||||||
@@ -276,7 +267,7 @@ async def test_media_route_serves_video_byte_ranges(
|
|||||||
|
|
||||||
channel = _ch(bus, port=29927)
|
channel = _ch(bus, port=29927)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
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
|
assert url_path is not None
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
try:
|
try:
|
||||||
@@ -307,7 +298,7 @@ async def test_media_route_serves_suffix_video_byte_ranges(
|
|||||||
|
|
||||||
channel = _ch(bus, port=29928)
|
channel = _ch(bus, port=29928)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
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
|
assert url_path is not None
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
try:
|
try:
|
||||||
@@ -335,7 +326,7 @@ async def test_media_route_rejects_unsatisfiable_byte_range(
|
|||||||
|
|
||||||
channel = _ch(bus, port=29929)
|
channel = _ch(bus, port=29929)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
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
|
assert url_path is not None
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
try:
|
try:
|
||||||
@@ -367,7 +358,7 @@ async def test_media_route_rejects_bad_signature(
|
|||||||
|
|
||||||
channel = _ch(bus, port=29921)
|
channel = _ch(bus, port=29921)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
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
|
assert good is not None
|
||||||
_, payload = good[len("/api/media/"):].split("/", 1)
|
_, payload = good[len("/api/media/"):].split("/", 1)
|
||||||
# Forge a sig with a *different* secret.
|
# Forge a sig with a *different* secret.
|
||||||
@@ -432,7 +423,7 @@ async def test_media_route_404s_missing_file(
|
|||||||
|
|
||||||
channel = _ch(bus, port=29923)
|
channel = _ch(bus, port=29923)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
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
|
assert url_path is not None
|
||||||
target.unlink() # the file vanishes between signing and fetching
|
target.unlink() # the file vanishes between signing and fetching
|
||||||
server_task = asyncio.create_task(channel.start())
|
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)
|
channel = _ch(bus, port=29928)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
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
|
assert url_path is not None
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ from nanobot.cron.types import (
|
|||||||
CronSchedule,
|
CronSchedule,
|
||||||
CronStore,
|
CronStore,
|
||||||
)
|
)
|
||||||
|
from nanobot.utils.run_records import (
|
||||||
|
safe_run_record_name,
|
||||||
|
)
|
||||||
from nanobot.utils.run_records import (
|
from nanobot.utils.run_records import (
|
||||||
write_run_record as write_automation_run_record,
|
write_run_record as write_automation_run_record,
|
||||||
)
|
)
|
||||||
@@ -437,6 +440,10 @@ class CronService:
|
|||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
raise
|
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:
|
def write_run_record(self, run_id: str, record: dict[str, Any]) -> None:
|
||||||
"""Write an internal audit record for one cron execution."""
|
"""Write an internal audit record for one cron execution."""
|
||||||
write_automation_run_record(self._run_records_dir, run_id, record)
|
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.cron.types import CronJob
|
||||||
from nanobot.session.automation_turns import (
|
from nanobot.session.automation_turns import (
|
||||||
AutomationTurnSpec,
|
AutomationTurnSpec,
|
||||||
|
automation_history_overrides_for_spec,
|
||||||
automation_trigger,
|
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
|
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:
|
def is_bound_cron_job(job: CronJob) -> bool:
|
||||||
"""True for session-bound cron jobs with complete delivery context."""
|
"""True for session-bound cron jobs with complete delivery context."""
|
||||||
payload = job.payload
|
payload = job.payload
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from copy import deepcopy
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
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 weakref import WeakValueDictionary
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -147,15 +147,6 @@ class RetentionResult:
|
|||||||
already_consolidated_count: int
|
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
|
@dataclass
|
||||||
class Session:
|
class Session:
|
||||||
"""A conversation session."""
|
"""A conversation session."""
|
||||||
@@ -167,7 +158,6 @@ class Session:
|
|||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
last_consolidated: int = 0 # Number of messages already consolidated to files
|
last_consolidated: int = 0 # Number of messages already consolidated to files
|
||||||
provider_state: ProviderConversationState | None = field(default=None, repr=False)
|
provider_state: ProviderConversationState | None = field(default=None, repr=False)
|
||||||
policy: SessionPolicy = field(default_factory=SessionPolicy, repr=False, compare=False)
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if not isinstance(cast(object, self.metadata), dict):
|
if not isinstance(cast(object, self.metadata), dict):
|
||||||
@@ -1089,24 +1079,6 @@ class SessionManager:
|
|||||||
self._remember(session)
|
self._remember(session)
|
||||||
return 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:
|
def _load(self, key: str) -> Session | None:
|
||||||
return self._store.load(key)
|
return self._store.load(key)
|
||||||
|
|
||||||
@@ -1114,11 +1086,12 @@ class SessionManager:
|
|||||||
"""Attempt to recover a session from a corrupt JSONL file."""
|
"""Attempt to recover a session from a corrupt JSONL file."""
|
||||||
return self._jsonl_store.repair(key, path=path)
|
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:
|
def save(self, session: Session, *, fsync: bool = False) -> None:
|
||||||
"""Persist a session and retain it in the cache."""
|
"""Persist a session and retain it in the cache."""
|
||||||
if not session.policy.persist:
|
|
||||||
return
|
|
||||||
|
|
||||||
archiver = self._file_cap_archiver
|
archiver = self._file_cap_archiver
|
||||||
if archiver is not None:
|
if archiver is not None:
|
||||||
session.enforce_file_cap(
|
session.enforce_file_cap(
|
||||||
|
|||||||
@@ -334,12 +334,6 @@ def clear_websocket_turn_if_current(
|
|||||||
return False
|
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(
|
def build_bus_progress_callback(
|
||||||
bus: MessageBus,
|
bus: MessageBus,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any, Mapping
|
|||||||
|
|
||||||
from nanobot.session.automation_turns import (
|
from nanobot.session.automation_turns import (
|
||||||
AutomationTurnSpec,
|
AutomationTurnSpec,
|
||||||
|
automation_history_overrides_for_spec,
|
||||||
automation_trigger,
|
automation_trigger,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,3 +50,13 @@ def local_trigger_delivery_id(metadata: Mapping[str, Any] | None) -> str | None:
|
|||||||
return None
|
return None
|
||||||
value = trigger.get("delivery_id")
|
value = trigger.get("delivery_id")
|
||||||
return value if isinstance(value, str) and value else None
|
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
|
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_TEXT_LENGTH = 200_000
|
||||||
_MAX_EXTRACT_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
_MAX_EXTRACT_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||||
_MAX_OFFICE_ARCHIVE_MEMBERS = 10_000
|
_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
|
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(
|
def prepare_file_edit_trackers(
|
||||||
*,
|
*,
|
||||||
call_id: str,
|
call_id: str,
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ from __future__ import annotations
|
|||||||
import io
|
import io
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, Iterable, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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.refs import Ref
|
||||||
from dulwich.repo import Repo
|
from dulwich.repo import Repo
|
||||||
|
|
||||||
@@ -44,6 +45,25 @@ class CommitInfo:
|
|||||||
return f"{header}\n(no file changes)"
|
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:
|
class GitStore:
|
||||||
"""Git-backed version control for memory files."""
|
"""Git-backed version control for memory files."""
|
||||||
|
|
||||||
@@ -273,6 +293,33 @@ class GitStore:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise GitStoreError("Git log failed") from 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:
|
def diff_commits(self, sha1: str, sha2: str) -> str:
|
||||||
"""Show diff between two commits."""
|
"""Show diff between two commits."""
|
||||||
if not self.is_initialized():
|
if not self.is_initialized():
|
||||||
@@ -414,6 +461,13 @@ class GitStore:
|
|||||||
commit = cast("Commit", commit_obj)
|
commit = cast("Commit", commit_obj)
|
||||||
return cast("Tree", repo[commit.tree])
|
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(
|
def show_commit_diff(
|
||||||
self,
|
self,
|
||||||
short_sha: str,
|
short_sha: str,
|
||||||
|
|||||||
@@ -351,6 +351,18 @@ def timestamp() -> str:
|
|||||||
return datetime.now().isoformat()
|
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'[<>:"/\\|?*]')
|
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
|
||||||
_TOOL_RESULT_PREVIEW_CHARS = 1200
|
_TOOL_RESULT_PREVIEW_CHARS = 1200
|
||||||
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
|
_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.gateway_tokens import GatewayTokenStore
|
||||||
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
||||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||||
from nanobot.webui.temporary_chats import WebUITemporaryChats
|
|
||||||
from nanobot.webui.transcript import WebUITranscriptRecorder
|
from nanobot.webui.transcript import WebUITranscriptRecorder
|
||||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||||
from nanobot.webui.ws_http import GatewayHTTPHandler
|
from nanobot.webui.ws_http import GatewayHTTPHandler
|
||||||
@@ -34,7 +33,6 @@ class GatewayServices:
|
|||||||
ingress: WebUIIngressPolicy
|
ingress: WebUIIngressPolicy
|
||||||
transcripts: WebUITranscriptRecorder
|
transcripts: WebUITranscriptRecorder
|
||||||
workspaces: WebUIWorkspaceController
|
workspaces: WebUIWorkspaceController
|
||||||
temporary_chats: WebUITemporaryChats
|
|
||||||
session_manager: SessionManager | None
|
session_manager: SessionManager | None
|
||||||
cron_service: CronService | None
|
cron_service: CronService | None
|
||||||
local_trigger_store: LocalTriggerStore | None
|
local_trigger_store: LocalTriggerStore | None
|
||||||
@@ -84,12 +82,6 @@ def build_gateway_services(
|
|||||||
default_workspace=workspace_path,
|
default_workspace=workspace_path,
|
||||||
default_restrict_to_workspace=default_restrict_to_workspace,
|
default_restrict_to_workspace=default_restrict_to_workspace,
|
||||||
)
|
)
|
||||||
temporary_chats = WebUITemporaryChats(
|
|
||||||
bus=bus,
|
|
||||||
session_manager=session_manager,
|
|
||||||
workspaces=workspaces,
|
|
||||||
logger=logger,
|
|
||||||
)
|
|
||||||
http = GatewayHTTPHandler(
|
http = GatewayHTTPHandler(
|
||||||
config=config,
|
config=config,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
@@ -120,7 +112,6 @@ def build_gateway_services(
|
|||||||
ingress=ingress,
|
ingress=ingress,
|
||||||
transcripts=transcripts,
|
transcripts=transcripts,
|
||||||
workspaces=workspaces,
|
workspaces=workspaces,
|
||||||
temporary_chats=temporary_chats,
|
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
cron_service=cron_service,
|
cron_service=cron_service,
|
||||||
local_trigger_store=local_trigger_store,
|
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.ingress_policy import AttachmentIngressLimits
|
||||||
from nanobot.webui.media_api import (
|
from nanobot.webui.media_api import (
|
||||||
serve_signed_media,
|
serve_signed_media,
|
||||||
|
sign_media_path,
|
||||||
sign_or_stage_media_path,
|
sign_or_stage_media_path,
|
||||||
signed_media_attachments,
|
signed_media_attachments,
|
||||||
)
|
)
|
||||||
@@ -70,6 +71,13 @@ class WebUIMediaGateway:
|
|||||||
media_dir=self._media_dir,
|
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:
|
def sign_or_stage_media_path(self, path: Path) -> dict[str, str] | None:
|
||||||
return sign_or_stage_media_path(
|
return sign_or_stage_media_path(
|
||||||
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
|
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(
|
def _with_backfilled_user(
|
||||||
records: list[dict[str, Any]],
|
records: list[dict[str, Any]],
|
||||||
user_event: dict[str, Any],
|
user_event: dict[str, Any],
|
||||||
@@ -1350,6 +1365,20 @@ def _inject_missing_user_events(
|
|||||||
return out
|
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:
|
def _format_tool_call_trace(call: Any) -> str | None:
|
||||||
if not call or not isinstance(call, dict):
|
if not call or not isinstance(call, dict):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -191,14 +191,6 @@ class WebUIWorkspaceController:
|
|||||||
self._default_restrict_to_workspace,
|
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(
|
def _scope_from_metadata_value(
|
||||||
self,
|
self,
|
||||||
raw_scope: object,
|
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") == ""
|
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:
|
class TestShowCommitDiff:
|
||||||
def test_returns_commit_with_diff(self, git_ready):
|
def test_returns_commit_with_diff(self, git_ready):
|
||||||
ws = git_ready._workspace
|
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
|
import pytest
|
||||||
|
|
||||||
from agent.runner_helpers import make_run_spec
|
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.config.schema import AgentDefaults
|
||||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
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._cron_turns.deferred_queues[session_key] == [msg]
|
||||||
assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"}
|
assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"}
|
||||||
|
|
||||||
await publish_next_deferred_turn(
|
await loop._cron_turns.publish_next_deferred(session_key)
|
||||||
deferred_queues=loop._cron_turns.deferred_queues,
|
|
||||||
publish_inbound=loop.bus.publish_inbound,
|
|
||||||
session_key=session_key,
|
|
||||||
)
|
|
||||||
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
||||||
assert queued is msg
|
assert queued is msg
|
||||||
assert session_key not in loop._cron_turns.deferred_queues
|
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._local_trigger_turns.deferred_queues[session_key] == [msg]
|
||||||
assert loop.pending_local_trigger_ids_for_session(session_key) == {"trg_123"}
|
assert loop.pending_local_trigger_ids_for_session(session_key) == {"trg_123"}
|
||||||
|
|
||||||
assert await publish_next_deferred_turn(
|
assert await loop._local_trigger_turns.publish_next_deferred(session_key) is True
|
||||||
deferred_queues=loop._local_trigger_turns.deferred_queues,
|
|
||||||
publish_inbound=loop.bus.publish_inbound,
|
|
||||||
session_key=session_key,
|
|
||||||
) is True
|
|
||||||
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
||||||
assert queued is msg
|
assert queued is msg
|
||||||
assert session_key not in loop._local_trigger_turns.deferred_queues
|
assert session_key not in loop._local_trigger_turns.deferred_queues
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.agent_plugins import discover_agent_plugin_skills
|
||||||
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
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",
|
"_fetch_skill_content",
|
||||||
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
|
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")
|
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"]
|
assert "state_recorded" in payload["last_action"]["verification"]
|
||||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||||
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
|
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 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 '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(
|
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"]
|
assert "entry_point_available" in payload["last_action"]["verification"]
|
||||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||||
assert installed["feishu"]["entry_point_path"] == str(resolved)
|
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 skill.is_file()
|
||||||
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
|
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)
|
manager = _manager(tmp_path)
|
||||||
_seed_catalog(manager)
|
_seed_catalog(manager)
|
||||||
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
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.mkdir(parents=True)
|
||||||
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
|
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -717,7 +741,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
|
|||||||
|
|
||||||
assert payload["last_action"]["ok"] is True
|
assert payload["last_action"]["ok"] is True
|
||||||
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
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(
|
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",
|
"name": "zoom",
|
||||||
"entry_point": "cli-anything-zoom",
|
"entry_point": "cli-anything-zoom",
|
||||||
"source": "public",
|
"source": "public",
|
||||||
"skill": "skills/cli-app-zoom/SKILL.md",
|
"skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
|
||||||
"tool": "run_cli_app",
|
"tool": "run_cli_app",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "gimp",
|
"name": "gimp",
|
||||||
"entry_point": "cli-anything-gimp",
|
"entry_point": "cli-anything-gimp",
|
||||||
"source": "harness",
|
"source": "harness",
|
||||||
"skill": "skills/cli-app-gimp/SKILL.md",
|
"skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
|
||||||
"tool": "run_cli_app",
|
"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:
|
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
|
||||||
manager = _manager(tmp_path)
|
manager = _manager(tmp_path)
|
||||||
_seed_catalog(manager)
|
_seed_catalog(manager)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Tests for CLI Apps loop helpers."""
|
"""Tests for CLI Apps loop helpers."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from nanobot.apps.cli.service import CliAppManager
|
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:
|
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(
|
lines = runtime_lines(
|
||||||
"please use @zoom tonight; ignore @krita?",
|
SimpleNamespace(content="please use @zoom tonight; ignore @krita?", metadata={}),
|
||||||
{},
|
|
||||||
tmp_path,
|
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 "CLI App Mention: @zoom" in joined
|
||||||
assert "tool=run_cli_app" in joined
|
assert "tool=run_cli_app" in joined
|
||||||
assert "entry_point=cli-anything-zoom" 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):
|
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
|
||||||
lines = runtime_lines_for_request(
|
lines = runtime_lines(
|
||||||
"please use @zoom tonight",
|
SimpleNamespace(
|
||||||
{
|
content="please use @zoom tonight",
|
||||||
|
metadata={
|
||||||
"cli_apps": [{
|
"cli_apps": [{
|
||||||
"name": "zoom",
|
"name": "zoom",
|
||||||
"entry_point": "cli-anything-zoom",
|
"entry_point": "cli-anything-zoom",
|
||||||
"display_name": "Zoom",
|
"display_name": "Zoom",
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
),
|
||||||
tmp_path,
|
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 "CLI App Attachment: @zoom" in joined
|
||||||
assert "tool=run_cli_app" in joined
|
assert "tool=run_cli_app" in joined
|
||||||
assert "entry_point=cli-anything-zoom" 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 manager.flush_all() == 2
|
||||||
assert set(saved) == {("test:active", True), ("test:other", True)}
|
assert set(saved) == {("test:active", True), ("test:other", True)}
|
||||||
|
|
||||||
|
|
||||||
def test_transient_session_never_reaches_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
|
import pytest
|
||||||
|
|
||||||
from nanobot.utils.document import (
|
from nanobot.utils.document import (
|
||||||
|
SUPPORTED_EXTENSIONS,
|
||||||
PdfSafetyError,
|
PdfSafetyError,
|
||||||
_is_text_extension,
|
_is_text_extension,
|
||||||
extract_pdf_pages,
|
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:
|
class TestExtractText:
|
||||||
"""Test the extract_text function."""
|
"""Test the extract_text function."""
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import os
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.tools import file_state
|
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)
|
@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:
|
class TestQuoteStylePreservation:
|
||||||
"""When quote-normalized matching occurs, replacement should preserve actual quote style."""
|
"""When quote-normalized matching occurs, replacement should preserve actual quote style."""
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from nanobot.agent.tools.filesystem import (
|
|||||||
ListDirTool,
|
ListDirTool,
|
||||||
ReadFileTool,
|
ReadFileTool,
|
||||||
WriteFileTool,
|
WriteFileTool,
|
||||||
|
_find_match,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -115,6 +116,52 @@ class TestReadFileTool:
|
|||||||
assert "Maximum is 100 MiB" in result
|
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
|
# EditFileTool
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from nanobot.utils.file_edit_events import (
|
|||||||
build_file_edit_start_event,
|
build_file_edit_start_event,
|
||||||
build_unified_diff_payload,
|
build_unified_diff_payload,
|
||||||
line_diff_stats,
|
line_diff_stats,
|
||||||
|
prepare_file_edit_tracker,
|
||||||
prepare_file_edit_trackers,
|
prepare_file_edit_trackers,
|
||||||
read_file_snapshot,
|
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 = tmp_path / "notes.txt"
|
||||||
target.write_text("old\nkeep\n", encoding="utf-8")
|
target.write_text("old\nkeep\n", encoding="utf-8")
|
||||||
params = {"path": "notes.txt", "content": "new\nkeep\nextra\n"}
|
params = {"path": "notes.txt", "content": "new\nkeep\nextra\n"}
|
||||||
trackers = prepare_file_edit_trackers(
|
tracker = prepare_file_edit_tracker(
|
||||||
call_id="call-write",
|
call_id="call-write",
|
||||||
tool_name="write_file",
|
tool_name="write_file",
|
||||||
tool=_write_tool(tmp_path),
|
tool=_write_tool(tmp_path),
|
||||||
workspace=tmp_path,
|
workspace=tmp_path,
|
||||||
params=params,
|
params=params,
|
||||||
)
|
)
|
||||||
[tracker] = trackers
|
|
||||||
|
assert tracker is not None
|
||||||
start = build_file_edit_start_event(tracker)
|
start = build_file_edit_start_event(tracker)
|
||||||
assert start == {
|
assert start == {
|
||||||
"version": 1,
|
"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:
|
def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
|
||||||
target = tmp_path / "data.bin"
|
target = tmp_path / "data.bin"
|
||||||
target.write_bytes(b"\x00\x01before")
|
target.write_bytes(b"\x00\x01before")
|
||||||
trackers = prepare_file_edit_trackers(
|
tracker = prepare_file_edit_tracker(
|
||||||
call_id="call-bin",
|
call_id="call-bin",
|
||||||
tool_name="edit_file",
|
tool_name="edit_file",
|
||||||
tool=_edit_tool(tmp_path),
|
tool=_edit_tool(tmp_path),
|
||||||
workspace=tmp_path,
|
workspace=tmp_path,
|
||||||
params={"path": "data.bin", "old_text": "before", "new_text": "after"},
|
params={"path": "data.bin", "old_text": "before", "new_text": "after"},
|
||||||
)
|
)
|
||||||
[tracker] = trackers
|
|
||||||
|
assert tracker is not None
|
||||||
assert not read_file_snapshot(target).countable
|
assert not read_file_snapshot(target).countable
|
||||||
target.write_bytes(b"\x00\x01after")
|
target.write_bytes(b"\x00\x01after")
|
||||||
event = build_file_edit_end_event(tracker)
|
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:
|
def test_binary_before_file_is_reported_but_not_counted(tmp_path: Path) -> None:
|
||||||
target = tmp_path / "data.bin"
|
target = tmp_path / "data.bin"
|
||||||
target.write_bytes(b"\x00\x01before")
|
target.write_bytes(b"\x00\x01before")
|
||||||
trackers = prepare_file_edit_trackers(
|
tracker = prepare_file_edit_tracker(
|
||||||
call_id="call-bin",
|
call_id="call-bin",
|
||||||
tool_name="write_file",
|
tool_name="write_file",
|
||||||
tool=_write_tool(tmp_path),
|
tool=_write_tool(tmp_path),
|
||||||
workspace=tmp_path,
|
workspace=tmp_path,
|
||||||
params={"path": "data.bin", "content": "after\n"},
|
params={"path": "data.bin", "content": "after\n"},
|
||||||
)
|
)
|
||||||
[tracker] = trackers
|
|
||||||
|
assert tracker is not None
|
||||||
target.write_text("after\n", encoding="utf-8")
|
target.write_text("after\n", encoding="utf-8")
|
||||||
event = build_file_edit_end_event(tracker)
|
event = build_file_edit_end_event(tracker)
|
||||||
assert event["binary"] is True
|
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:
|
def test_oversized_file_is_reported_but_not_counted(tmp_path: Path) -> None:
|
||||||
target = tmp_path / "large.txt"
|
target = tmp_path / "large.txt"
|
||||||
params = {"path": "large.txt", "content": "x"}
|
params = {"path": "large.txt", "content": "x"}
|
||||||
trackers = prepare_file_edit_trackers(
|
tracker = prepare_file_edit_tracker(
|
||||||
call_id="call-large",
|
call_id="call-large",
|
||||||
tool_name="write_file",
|
tool_name="write_file",
|
||||||
tool=_write_tool(tmp_path),
|
tool=_write_tool(tmp_path),
|
||||||
workspace=tmp_path,
|
workspace=tmp_path,
|
||||||
params=params,
|
params=params,
|
||||||
)
|
)
|
||||||
[tracker] = trackers
|
|
||||||
|
assert tracker is not None
|
||||||
target.write_text("x" * (2 * 1024 * 1024 + 1), encoding="utf-8")
|
target.write_text("x" * (2 * 1024 * 1024 + 1), encoding="utf-8")
|
||||||
event = build_file_edit_end_event(tracker)
|
event = build_file_edit_end_event(tracker)
|
||||||
assert event["binary"] is True
|
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
|
assert "diff" not in event
|
||||||
|
|
||||||
|
|
||||||
def test_untracked_tools_do_not_prepare_file_edit_trackers(tmp_path: Path) -> None:
|
def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None:
|
||||||
assert prepare_file_edit_trackers(
|
assert prepare_file_edit_tracker(
|
||||||
call_id="call-exec",
|
call_id="call-exec",
|
||||||
tool_name="exec",
|
tool_name="exec",
|
||||||
tool=None,
|
tool=None,
|
||||||
workspace=tmp_path,
|
workspace=tmp_path,
|
||||||
params={"path": "created-by-shell.txt"},
|
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
|
import subprocess
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.utils.gitstore import GitStore
|
from nanobot.utils.gitstore import GitStore, GitStoreError
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -17,6 +18,89 @@ def git(tmp_path):
|
|||||||
return g
|
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:
|
class TestSummarizeWorkingTree:
|
||||||
"""Ground-truth diff summary used to keep Dream audit records honest."""
|
"""Ground-truth diff summary used to keep Dream audit records honest."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
import pytest
|
||||||
import tiktoken
|
import tiktoken
|
||||||
|
|
||||||
from nanobot.utils import helpers
|
from nanobot.utils import helpers
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
_write_text_atomic,
|
_write_text_atomic,
|
||||||
content_with_media_breadcrumbs,
|
content_with_media_breadcrumbs,
|
||||||
|
current_time_str,
|
||||||
split_message,
|
split_message,
|
||||||
truncate_text_to_tokens,
|
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
|
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():
|
def test_content_with_media_breadcrumbs_preserves_valid_paths():
|
||||||
assert content_with_media_breadcrumbs(
|
assert content_with_media_breadcrumbs(
|
||||||
"user",
|
"user",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
"@radix-ui/react-popover": "1.1.15",
|
"@radix-ui/react-popover": "1.1.15",
|
||||||
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"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-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-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=="],
|
"@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-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=="],
|
"@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=="],
|
"@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-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
"@radix-ui/react-popover": "1.1.15",
|
"@radix-ui/react-popover": "1.1.15",
|
||||||
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"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 |
+15
-206
@@ -61,11 +61,7 @@ import {
|
|||||||
createRuntimeHost,
|
createRuntimeHost,
|
||||||
toRuntimeSurface,
|
toRuntimeSurface,
|
||||||
} from "@/lib/runtime";
|
} from "@/lib/runtime";
|
||||||
import { projectNameFromPath, scopeWithAccessMode } from "@/lib/workspace";
|
import { projectNameFromPath } from "@/lib/workspace";
|
||||||
import {
|
|
||||||
createTemporaryChatSession,
|
|
||||||
deriveTemporaryChatTitle,
|
|
||||||
} from "@/lib/temporary-chat";
|
|
||||||
|
|
||||||
type BootState =
|
type BootState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
@@ -100,8 +96,8 @@ type ShellRoute = {
|
|||||||
view: ShellView;
|
view: ShellView;
|
||||||
activeKey: string | null;
|
activeKey: string | null;
|
||||||
settingsSection: SettingsSectionKey;
|
settingsSection: SettingsSectionKey;
|
||||||
temporary?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSettingsView = () => import("@/components/settings/SettingsView");
|
const loadSettingsView = () => import("@/components/settings/SettingsView");
|
||||||
const SettingsView = lazy(async () => {
|
const SettingsView = lazy(async () => {
|
||||||
const module = await loadSettingsView();
|
const module = await loadSettingsView();
|
||||||
@@ -231,22 +227,6 @@ function readShellRoute(): ShellRoute {
|
|||||||
if (path === "/skills") {
|
if (path === "/skills") {
|
||||||
return { view: "skills", activeKey, settingsSection: "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/")) {
|
if (path.startsWith("/chat/")) {
|
||||||
const encoded = path.slice("/chat/".length);
|
const encoded = path.slice("/chat/".length);
|
||||||
try {
|
try {
|
||||||
@@ -263,10 +243,6 @@ function readShellRoute(): ShellRoute {
|
|||||||
|
|
||||||
function shellRouteHash(route: ShellRoute): string {
|
function shellRouteHash(route: ShellRoute): string {
|
||||||
if (route.view === "chat") {
|
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
|
return route.activeKey
|
||||||
? `#/chat/${encodeURIComponent(route.activeKey)}`
|
? `#/chat/${encodeURIComponent(route.activeKey)}`
|
||||||
: "#/new";
|
: "#/new";
|
||||||
@@ -985,8 +961,6 @@ function Shell({
|
|||||||
initialRouteRef.current.activeKey,
|
initialRouteRef.current.activeKey,
|
||||||
);
|
);
|
||||||
const [view, setView] = useState<ShellView>(initialRouteRef.current.view);
|
const [view, setView] = useState<ShellView>(initialRouteRef.current.view);
|
||||||
const [temporarySessions, setTemporarySessions] = useState<Record<string, ChatSummary>>({});
|
|
||||||
const [temporaryChatEnabled, setTemporaryChatEnabled] = useState(false);
|
|
||||||
const [settingsInitialSection, setSettingsInitialSection] =
|
const [settingsInitialSection, setSettingsInitialSection] =
|
||||||
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
|
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
|
||||||
const [hostSidebarOpen, setHostSidebarOpen] =
|
const [hostSidebarOpen, setHostSidebarOpen] =
|
||||||
@@ -1031,26 +1005,11 @@ function Shell({
|
|||||||
const runningChatIdsRef = useRef<Set<string>>(new Set());
|
const runningChatIdsRef = useRef<Set<string>>(new Set());
|
||||||
const activeChatIdRef = useRef<string | null>(null);
|
const activeChatIdRef = useRef<string | null>(null);
|
||||||
const pendingCreatedSessionKeyRef = useRef<string | null>(null);
|
const pendingCreatedSessionKeyRef = useRef<string | null>(null);
|
||||||
const temporarySessionsRef = useRef<Record<string, ChatSummary>>({});
|
|
||||||
const hostSidebarPreviewCloseTimerRef = useRef<number | null>(null);
|
const hostSidebarPreviewCloseTimerRef = useRef<number | null>(null);
|
||||||
const effectiveRuntimeSurface =
|
const effectiveRuntimeSurface =
|
||||||
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
|
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
|
||||||
const showHostChrome = effectiveRuntimeSurface === "native";
|
const showHostChrome = effectiveRuntimeSurface === "native";
|
||||||
const showMainSidebar = view !== "settings";
|
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(
|
const navigate = useCallback(
|
||||||
(route: ShellRoute, options?: { replace?: boolean }) => {
|
(route: ShellRoute, options?: { replace?: boolean }) => {
|
||||||
@@ -1077,21 +1036,6 @@ function Shell({
|
|||||||
return () => window.removeEventListener("hashchange", applyRoute);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchSettings(getToken())
|
fetchSettings(getToken())
|
||||||
@@ -1177,9 +1121,8 @@ function Shell({
|
|||||||
|
|
||||||
const activeSession = useMemo<ChatSummary | null>(() => {
|
const activeSession = useMemo<ChatSummary | null>(() => {
|
||||||
if (!activeKey) return null;
|
if (!activeKey) return null;
|
||||||
if (temporarySessions[activeKey]) return temporarySessions[activeKey];
|
|
||||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||||
}, [sessions, activeKey, temporarySessions]);
|
}, [sessions, activeKey]);
|
||||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||||
const activeChatId = activeSession?.chatId ?? null;
|
const activeChatId = activeSession?.chatId ?? null;
|
||||||
@@ -1194,11 +1137,6 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, [activeChatId]);
|
}, [activeChatId]);
|
||||||
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
|
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
|
||||||
if (temporaryChatRequested) {
|
|
||||||
return workspaces?.default_scope
|
|
||||||
? normalizeWorkspaceScope(scopeWithAccessMode(workspaces.default_scope, "restricted"))
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||||
return workspaceOverrides[activeChatId];
|
return workspaceOverrides[activeChatId];
|
||||||
}
|
}
|
||||||
@@ -1210,7 +1148,6 @@ function Shell({
|
|||||||
activeChatId,
|
activeChatId,
|
||||||
activeSession?.workspaceScope,
|
activeSession?.workspaceScope,
|
||||||
draftWorkspaceScope,
|
draftWorkspaceScope,
|
||||||
temporaryChatRequested,
|
|
||||||
workspaceOverrides,
|
workspaceOverrides,
|
||||||
workspaces?.default_scope,
|
workspaces?.default_scope,
|
||||||
]);
|
]);
|
||||||
@@ -1250,17 +1187,11 @@ function Shell({
|
|||||||
if (pendingCreatedKey && sessions.some((session) => session.key === pendingCreatedKey)) {
|
if (pendingCreatedKey && sessions.some((session) => session.key === pendingCreatedKey)) {
|
||||||
pendingCreatedSessionKeyRef.current = null;
|
pendingCreatedSessionKeyRef.current = null;
|
||||||
}
|
}
|
||||||
if (!activeKey) return;
|
if (!activeKey || sessions.some((session) => session.key === 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;
|
|
||||||
// WebKit can commit the route before useSessions' optimistic insert.
|
// WebKit can commit the route before useSessions' optimistic insert.
|
||||||
// Keep that just-created destination valid until the session list catches up.
|
// Keep that just-created destination valid until the session list catches up.
|
||||||
if (pendingCreatedKey === activeKey) return;
|
if (pendingCreatedKey === activeKey) return;
|
||||||
|
const currentRoute = readShellRoute();
|
||||||
navigate(
|
navigate(
|
||||||
currentRoute.view === "chat"
|
currentRoute.view === "chat"
|
||||||
? defaultShellRoute()
|
? defaultShellRoute()
|
||||||
@@ -1270,7 +1201,7 @@ function Shell({
|
|||||||
},
|
},
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
);
|
);
|
||||||
}, [activeKey, loading, navigate, sessions, temporarySessions]);
|
}, [activeKey, loading, navigate, sessions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return client.onSessionUpdate((chatId, scope, workspaceScope) => {
|
return client.onSessionUpdate((chatId, scope, workspaceScope) => {
|
||||||
@@ -1429,22 +1360,14 @@ function Shell({
|
|||||||
const next = normalizeWorkspaceScope(scope);
|
const next = normalizeWorkspaceScope(scope);
|
||||||
setWorkspaceError(null);
|
setWorkspaceError(null);
|
||||||
if (activeChatId) {
|
if (activeChatId) {
|
||||||
if (temporaryChatActive) {
|
if (!activeChatRunning) {
|
||||||
setTemporarySessions((current) => {
|
|
||||||
if (!activeKey || !current[activeKey]) return current;
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
[activeKey]: { ...current[activeKey], workspaceScope: next },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} else if (!activeChatRunning) {
|
|
||||||
client.setWorkspaceScope(activeChatId, next);
|
client.setWorkspaceScope(activeChatId, next);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDraftWorkspaceScope(next);
|
setDraftWorkspaceScope(next);
|
||||||
},
|
},
|
||||||
[activeChatId, activeChatRunning, activeKey, client, temporaryChatActive],
|
[activeChatId, activeChatRunning, client],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onCreateChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null) => {
|
const onCreateChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null) => {
|
||||||
@@ -1475,45 +1398,6 @@ function Shell({
|
|||||||
}
|
}
|
||||||
}, [activeWorkspaceScope, createChat, navigate, t]);
|
}, [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 (
|
const onForkChat = useCallback(async (
|
||||||
sourceChatId: string,
|
sourceChatId: string,
|
||||||
beforeUserIndex: number,
|
beforeUserIndex: number,
|
||||||
@@ -1543,20 +1427,12 @@ function Shell({
|
|||||||
|
|
||||||
const onNewChat = useCallback(() => {
|
const onNewChat = useCallback(() => {
|
||||||
navigate(defaultShellRoute());
|
navigate(defaultShellRoute());
|
||||||
setTemporaryChatEnabled(false);
|
|
||||||
setDraftWorkspaceScope(null);
|
setDraftWorkspaceScope(null);
|
||||||
setWorkspaceError(null);
|
setWorkspaceError(null);
|
||||||
setSessionSearchOpen(false);
|
setSessionSearchOpen(false);
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const onTemporaryChatEnabledChange = useCallback((enabled: boolean) => {
|
|
||||||
if (view !== "chat" || activeKey) return;
|
|
||||||
setTemporaryChatEnabled(enabled);
|
|
||||||
setDraftWorkspaceScope(null);
|
|
||||||
setWorkspaceError(null);
|
|
||||||
}, [activeKey, view]);
|
|
||||||
|
|
||||||
const onNewChatInProject = useCallback(
|
const onNewChatInProject = useCallback(
|
||||||
(projectPath: string, projectName: string) => {
|
(projectPath: string, projectName: string) => {
|
||||||
const base = workspaces?.default_scope ?? activeWorkspaceScope;
|
const base = workspaces?.default_scope ?? activeWorkspaceScope;
|
||||||
@@ -1565,7 +1441,6 @@ function Shell({
|
|||||||
onNewChat();
|
onNewChat();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTemporaryChatEnabled(false);
|
|
||||||
navigate(defaultShellRoute());
|
navigate(defaultShellRoute());
|
||||||
setDraftWorkspaceScope(normalizeWorkspaceScope({
|
setDraftWorkspaceScope(normalizeWorkspaceScope({
|
||||||
project_path: trimmed,
|
project_path: trimmed,
|
||||||
@@ -1581,9 +1456,7 @@ function Shell({
|
|||||||
|
|
||||||
const onSelectChat = useCallback(
|
const onSelectChat = useCallback(
|
||||||
(key: string) => {
|
(key: string) => {
|
||||||
const selectedTemporary = temporarySessionsRef.current[key];
|
const selected = sessions.find((session) => session.key === key);
|
||||||
const selected = selectedTemporary
|
|
||||||
?? sessions.find((session) => session.key === key);
|
|
||||||
const selectedChatId = selected?.chatId;
|
const selectedChatId = selected?.chatId;
|
||||||
if (selectedChatId) {
|
if (selectedChatId) {
|
||||||
setUpdatedChatIds((current) => {
|
setUpdatedChatIds((current) => {
|
||||||
@@ -1599,38 +1472,12 @@ function Shell({
|
|||||||
setDraftWorkspaceScope(null);
|
setDraftWorkspaceScope(null);
|
||||||
}
|
}
|
||||||
setWorkspaceError(null);
|
setWorkspaceError(null);
|
||||||
navigate({
|
navigate({ view: "chat", activeKey: key, settingsSection: "overview" });
|
||||||
view: "chat",
|
|
||||||
activeKey: key,
|
|
||||||
settingsSection: "overview",
|
|
||||||
...(selectedTemporary ? { temporary: true } : {}),
|
|
||||||
});
|
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
},
|
},
|
||||||
[navigate, sessions],
|
[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(
|
const onTogglePin = useCallback(
|
||||||
(key: string) => {
|
(key: string) => {
|
||||||
void updateSidebarState((current) => {
|
void updateSidebarState((current) => {
|
||||||
@@ -1913,11 +1760,6 @@ function Shell({
|
|||||||
nextRunning.delete(chatId);
|
nextRunning.delete(chatId);
|
||||||
runningChatIdsRef.current = nextRunning;
|
runningChatIdsRef.current = nextRunning;
|
||||||
setRunningChatIds(nextRunning);
|
setRunningChatIds(nextRunning);
|
||||||
if (
|
|
||||||
Object.values(temporarySessionsRef.current).some(
|
|
||||||
(session) => session.chatId === chatId,
|
|
||||||
)
|
|
||||||
) return;
|
|
||||||
setUpdatedChatIds((current) => {
|
setUpdatedChatIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
if (activeChatIdRef.current === chatId) {
|
if (activeChatIdRef.current === chatId) {
|
||||||
@@ -1930,24 +1772,6 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, [client]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
return client.onStatus((status) => {
|
return client.onStatus((status) => {
|
||||||
const startedAt = (() => {
|
const startedAt = (() => {
|
||||||
@@ -1976,10 +1800,7 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
const onTurnEnd = useDeferredTitleRefresh(
|
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
|
||||||
temporaryChatActive ? null : activeSession,
|
|
||||||
refresh,
|
|
||||||
);
|
|
||||||
|
|
||||||
const onConfirmDelete = useCallback(async () => {
|
const onConfirmDelete = useCallback(async () => {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
@@ -2069,9 +1890,7 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const headerTitle = temporaryChatActive
|
const headerTitle = activeSession
|
||||||
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
|
|
||||||
: activeSession
|
|
||||||
? sidebarState.title_overrides[activeSession.key] ||
|
? sidebarState.title_overrides[activeSession.key] ||
|
||||||
activeSession.title ||
|
activeSession.title ||
|
||||||
deriveTitle(activeSession.preview, t("chat.newChat"))
|
deriveTitle(activeSession.preview, t("chat.newChat"))
|
||||||
@@ -2109,13 +1928,11 @@ function Shell({
|
|||||||
|
|
||||||
const sidebarProps = {
|
const sidebarProps = {
|
||||||
sessions,
|
sessions,
|
||||||
temporarySessions: temporarySessionList,
|
|
||||||
activeKey: view === "chat" ? activeKey : null,
|
activeKey: view === "chat" ? activeKey : null,
|
||||||
loading,
|
loading,
|
||||||
newChatActive: view === "chat" && activeKey === null,
|
newChatActive: view === "chat" && activeKey === null,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onSelect: onSelectChat,
|
onSelect: onSelectChat,
|
||||||
onCloseTemporaryChat,
|
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onRequestRename,
|
onRequestRename,
|
||||||
@@ -2301,16 +2118,10 @@ function Shell({
|
|||||||
session={activeSession}
|
session={activeSession}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
title={headerTitle}
|
title={headerTitle}
|
||||||
temporary={temporaryChatRequested}
|
|
||||||
temporaryChatIds={temporaryChatIds}
|
|
||||||
temporaryChatEnabled={temporaryChatEnabled}
|
|
||||||
onTemporaryChatEnabledChange={
|
|
||||||
!activeKey ? onTemporaryChatEnabledChange : undefined
|
|
||||||
}
|
|
||||||
onToggleSidebar={toggleSidebar}
|
onToggleSidebar={toggleSidebar}
|
||||||
onNewChat={onNewChat}
|
onNewChat={onNewChat}
|
||||||
onCreateChat={temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat}
|
onCreateChat={onCreateChat}
|
||||||
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
onForkChat={onForkChat}
|
||||||
onTurnEnd={onTurnEnd}
|
onTurnEnd={onTurnEnd}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={toggle}
|
onToggleTheme={toggle}
|
||||||
@@ -2389,17 +2200,15 @@ function Shell({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
{restartToast ? (
|
{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
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
className={cn(
|
className={cn(
|
||||||
floatingSurfaceElevationClassName,
|
floatingSurfaceElevationClassName,
|
||||||
"max-w-full rounded-full px-4 py-2 text-sm font-medium",
|
"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}
|
{restartToast}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
<PairingCodePopup
|
<PairingCodePopup
|
||||||
requests={visiblePairingRequests}
|
requests={visiblePairingRequests}
|
||||||
|
|||||||
@@ -4,20 +4,17 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type RefObject,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
ArchiveRestore,
|
ArchiveRestore,
|
||||||
Folder,
|
Folder,
|
||||||
MessageCircleDashed,
|
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
Pin,
|
Pin,
|
||||||
PinOff,
|
PinOff,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -44,7 +41,6 @@ import {
|
|||||||
type ChatGroupLabels,
|
type ChatGroupLabels,
|
||||||
} from "@/lib/chat-groups";
|
} from "@/lib/chat-groups";
|
||||||
import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag";
|
import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag";
|
||||||
import { deriveTemporaryChatTitle } from "@/lib/temporary-chat";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
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 {
|
interface ChatListProps {
|
||||||
sessions: ChatSummary[];
|
sessions: ChatSummary[];
|
||||||
temporarySessions?: ChatSummary[];
|
|
||||||
activeKey: string | null;
|
activeKey: string | null;
|
||||||
onSelect: (key: string) => void;
|
onSelect: (key: string) => void;
|
||||||
onCloseTemporaryChat?: (key: string) => void;
|
|
||||||
onRequestDelete: (key: string, label: string) => void;
|
onRequestDelete: (key: string, label: string) => void;
|
||||||
onTogglePin: (key: string) => void;
|
onTogglePin: (key: string) => void;
|
||||||
onRequestRename: (key: string, label: string) => void;
|
onRequestRename: (key: string, label: string) => void;
|
||||||
@@ -87,10 +81,8 @@ interface ChatListProps {
|
|||||||
|
|
||||||
export const ChatList = memo(function ChatList({
|
export const ChatList = memo(function ChatList({
|
||||||
sessions,
|
sessions,
|
||||||
temporarySessions = [],
|
|
||||||
activeKey,
|
activeKey,
|
||||||
onSelect,
|
onSelect,
|
||||||
onCloseTemporaryChat,
|
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onRequestRename,
|
onRequestRename,
|
||||||
@@ -196,7 +188,7 @@ export const ChatList = memo(function ChatList({
|
|||||||
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
|
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
|
||||||
}, [showArchived, sort]);
|
}, [showArchived, sort]);
|
||||||
|
|
||||||
if (loading && sessions.length === 0 && temporarySessions.length === 0) {
|
if (loading && sessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
||||||
{t("chat.loading")}
|
{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 (
|
return (
|
||||||
<div className="px-3 py-6 text-[12px] leading-5 text-muted-foreground/80">
|
<div className="px-3 py-6 text-[12px] leading-5 text-muted-foreground/80">
|
||||||
{emptyLabel ?? t("chat.noSessions")}
|
{emptyLabel ?? t("chat.noSessions")}
|
||||||
@@ -245,16 +237,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
data-chat-list-content
|
data-chat-list-content
|
||||||
className="relative min-w-0 space-y-3 px-2 py-1.5"
|
className="relative min-w-0 space-y-3 px-2 py-1.5"
|
||||||
>
|
>
|
||||||
{temporarySessions.length > 0 ? (
|
|
||||||
<TemporaryChatSection
|
|
||||||
sessions={temporarySessions}
|
|
||||||
activeKey={activeKey}
|
|
||||||
activeRowRef={activeRowRef}
|
|
||||||
running={running}
|
|
||||||
onSelect={onSelect}
|
|
||||||
onClose={onCloseTemporaryChat}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{limitedGroups.map((group, index) => {
|
{limitedGroups.map((group, index) => {
|
||||||
const foldableChatsGroup = isFoldableChatsGroup(group);
|
const foldableChatsGroup = isFoldableChatsGroup(group);
|
||||||
const foldedChatsGroup = isFoldedChatsGroup(group, collapsedGroups);
|
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({
|
function ProjectGroupHeader({
|
||||||
label,
|
label,
|
||||||
path,
|
path,
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ import type {
|
|||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: UIMessage;
|
message: UIMessage;
|
||||||
/** Give temporary-chat user turns the dashed private-mode treatment. */
|
|
||||||
temporary?: boolean;
|
|
||||||
/** When false, hide this message's copy button. Default true. */
|
/** When false, hide this message's copy button. Default true. */
|
||||||
showCopyAction?: boolean;
|
showCopyAction?: boolean;
|
||||||
cliApps?: CliAppInfo[];
|
cliApps?: CliAppInfo[];
|
||||||
@@ -260,7 +258,6 @@ function UserDeliveryStatus({
|
|||||||
/** Render user turns as compact bubbles and assistant turns as document-like prose. */
|
/** Render user turns as compact bubbles and assistant turns as document-like prose. */
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
message,
|
message,
|
||||||
temporary = false,
|
|
||||||
showCopyAction = true,
|
showCopyAction = true,
|
||||||
cliApps = [],
|
cliApps = [],
|
||||||
mcpPresets = [],
|
mcpPresets = [],
|
||||||
@@ -329,13 +326,9 @@ export function MessageBubble({
|
|||||||
) : null}
|
) : null}
|
||||||
{hasText ? (
|
{hasText ? (
|
||||||
<p
|
<p
|
||||||
data-temporary-message={temporary ? "true" : undefined}
|
|
||||||
className={cn(
|
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]",
|
"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}
|
{messageText}
|
||||||
@@ -731,7 +724,8 @@ function UserImageCell({
|
|||||||
aria-label={image.name ? `${openLabel}: ${image.name}` : openLabel}
|
aria-label={image.name ? `${openLabel}: ${image.name}` : openLabel}
|
||||||
className={cn(
|
className={cn(
|
||||||
tileClasses,
|
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",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -31,13 +31,11 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
sessions: ChatSummary[];
|
sessions: ChatSummary[];
|
||||||
temporarySessions?: ChatSummary[];
|
|
||||||
activeKey: string | null;
|
activeKey: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
newChatActive: boolean;
|
newChatActive: boolean;
|
||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onSelect: (key: string) => void;
|
onSelect: (key: string) => void;
|
||||||
onCloseTemporaryChat?: (key: string) => void;
|
|
||||||
onRequestDelete: (key: string, label: string) => void;
|
onRequestDelete: (key: string, label: string) => void;
|
||||||
onTogglePin: (key: string) => void;
|
onTogglePin: (key: string) => void;
|
||||||
onRequestRename: (key: string, label: string) => void;
|
onRequestRename: (key: string, label: string) => void;
|
||||||
@@ -223,12 +221,10 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<ChatList
|
<ChatList
|
||||||
sessions={props.sessions}
|
sessions={props.sessions}
|
||||||
temporarySessions={props.temporarySessions}
|
|
||||||
activeKey={props.activeKey}
|
activeKey={props.activeKey}
|
||||||
loading={props.loading}
|
loading={props.loading}
|
||||||
emptyLabel={t("chat.noSessions")}
|
emptyLabel={t("chat.noSessions")}
|
||||||
onSelect={props.onSelect}
|
onSelect={props.onSelect}
|
||||||
onCloseTemporaryChat={props.onCloseTemporaryChat}
|
|
||||||
onRequestDelete={props.onRequestDelete}
|
onRequestDelete={props.onRequestDelete}
|
||||||
onTogglePin={props.onTogglePin}
|
onTogglePin={props.onTogglePin}
|
||||||
onRequestRename={props.onRequestRename}
|
onRequestRename={props.onRequestRename}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
type KeyboardEvent as ReactKeyboardEvent,
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
type Ref,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||||
@@ -200,14 +199,12 @@ interface ThreadComposerProps {
|
|||||||
sessions?: ChatSummary[];
|
sessions?: ChatSummary[];
|
||||||
skills?: SkillSummary[];
|
skills?: SkillSummary[];
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
surfaceRef?: Ref<HTMLDivElement>;
|
|
||||||
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
|
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
|
||||||
/** Unix seconds from server; turn elapsed timer above input while set. */
|
/** Unix seconds from server; turn elapsed timer above input while set. */
|
||||||
runStartedAt?: number | null;
|
runStartedAt?: number | null;
|
||||||
/** Sustained objective for this chat (WebSocket ``goal_state``). */
|
/** Sustained objective for this chat (WebSocket ``goal_state``). */
|
||||||
goalState?: GoalStateWsPayload;
|
goalState?: GoalStateWsPayload;
|
||||||
workspaceScope?: WorkspaceScopePayload | null;
|
workspaceScope?: WorkspaceScopePayload | null;
|
||||||
workspaceControlsHidden?: boolean;
|
|
||||||
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
||||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||||
workspaceScopeDisabled?: boolean;
|
workspaceScopeDisabled?: boolean;
|
||||||
@@ -954,12 +951,10 @@ export function ThreadComposer({
|
|||||||
sessions = [],
|
sessions = [],
|
||||||
skills = [],
|
skills = [],
|
||||||
onStop,
|
onStop,
|
||||||
surfaceRef,
|
|
||||||
onTranscribeAudio,
|
onTranscribeAudio,
|
||||||
runStartedAt = null,
|
runStartedAt = null,
|
||||||
goalState,
|
goalState,
|
||||||
workspaceScope = null,
|
workspaceScope = null,
|
||||||
workspaceControlsHidden = false,
|
|
||||||
workspaceDefaultScope = null,
|
workspaceDefaultScope = null,
|
||||||
workspaceControls = null,
|
workspaceControls = null,
|
||||||
workspaceScopeDisabled = false,
|
workspaceScopeDisabled = false,
|
||||||
@@ -1010,18 +1005,17 @@ export function ThreadComposer({
|
|||||||
() => queuedPromptsStorageKey(pendingQueueKey),
|
() => queuedPromptsStorageKey(pendingQueueKey),
|
||||||
[pendingQueueKey],
|
[pendingQueueKey],
|
||||||
);
|
);
|
||||||
const projectPickerAvailable =
|
const showProjectPicker =
|
||||||
isHero
|
isHero
|
||||||
&& !!workspaceDefaultScope
|
&& !!workspaceDefaultScope
|
||||||
&& !!onWorkspaceScopeChange
|
&& !!onWorkspaceScopeChange
|
||||||
&& workspaceControls?.can_change_project !== false;
|
&& workspaceControls?.can_change_project !== false;
|
||||||
const showProjectPicker = projectPickerAvailable && !workspaceControlsHidden;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
secondEnterPromptIdRef.current = null;
|
secondEnterPromptIdRef.current = null;
|
||||||
skipQueuedPromptPersistRef.current = true;
|
skipQueuedPromptPersistRef.current = true;
|
||||||
setQueuedPrompts(queuedPromptStorageKey ? readQueuedPrompts(queuedPromptStorageKey) : []);
|
setQueuedPrompts(queuedPromptStorageKey ? readQueuedPrompts(queuedPromptStorageKey) : []);
|
||||||
}, [pendingQueueKey, queuedPromptStorageKey]);
|
}, [queuedPromptStorageKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!queuedPromptStorageKey) return;
|
if (!queuedPromptStorageKey) return;
|
||||||
@@ -2247,7 +2241,6 @@ export function ThreadComposer({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<div
|
||||||
ref={surfaceRef}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"thread-composer-surface group/composer relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
|
"thread-composer-surface group/composer relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
|
||||||
isHero
|
isHero
|
||||||
@@ -2393,7 +2386,7 @@ export function ThreadComposer({
|
|||||||
) : null}
|
) : null}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
isHero
|
||||||
? cn(
|
? cn(
|
||||||
"gap-x-1.5 px-3 sm:px-4",
|
"gap-x-1.5 px-3 sm:px-4",
|
||||||
@@ -2440,7 +2433,7 @@ export function ThreadComposer({
|
|||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
levels={voiceRecorder.levels}
|
levels={voiceRecorder.levels}
|
||||||
/>
|
/>
|
||||||
) : workspaceScope && !workspaceControlsHidden ? (
|
) : workspaceScope ? (
|
||||||
<WorkspaceAccessMenu
|
<WorkspaceAccessMenu
|
||||||
scope={workspaceScope}
|
scope={workspaceScope}
|
||||||
disabled={disabled || workspaceScopeDisabled}
|
disabled={disabled || workspaceScopeDisabled}
|
||||||
@@ -2551,18 +2544,9 @@ export function ThreadComposer({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<WorkspaceProjectPicker
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
disabled={disabled || workspaceScopeDisabled || !showProjectPicker}
|
disabled={disabled || workspaceScopeDisabled}
|
||||||
scope={workspaceScope}
|
scope={workspaceScope}
|
||||||
defaultScope={workspaceDefaultScope}
|
defaultScope={workspaceDefaultScope}
|
||||||
controls={workspaceControls}
|
controls={workspaceControls}
|
||||||
@@ -2570,10 +2554,6 @@ export function ThreadComposer({
|
|||||||
onChange={onWorkspaceScopeChange}
|
onChange={onWorkspaceScopeChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { Menu, MessageCircleDashed, Moon, Sun } from "lucide-react";
|
import { Menu, Moon, Sun } from "lucide-react";
|
||||||
import { type ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ThreadHeaderProps {
|
interface ThreadHeaderProps {
|
||||||
@@ -22,9 +16,6 @@ interface ThreadHeaderProps {
|
|||||||
minimal?: boolean;
|
minimal?: boolean;
|
||||||
promptNavigatorAction?: ReactNode;
|
promptNavigatorAction?: ReactNode;
|
||||||
sessionInfoAction?: ReactNode;
|
sessionInfoAction?: ReactNode;
|
||||||
temporaryChatEnabled?: boolean;
|
|
||||||
temporaryChatDisabled?: boolean;
|
|
||||||
onTemporaryChatEnabledChange?: (enabled: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThreadHeader({
|
export function ThreadHeader({
|
||||||
@@ -38,17 +29,13 @@ export function ThreadHeader({
|
|||||||
minimal = false,
|
minimal = false,
|
||||||
promptNavigatorAction,
|
promptNavigatorAction,
|
||||||
sessionInfoAction,
|
sessionInfoAction,
|
||||||
temporaryChatEnabled = false,
|
|
||||||
temporaryChatDisabled = false,
|
|
||||||
onTemporaryChatEnabledChange,
|
|
||||||
}: ThreadHeaderProps) {
|
}: ThreadHeaderProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="thread-header"
|
|
||||||
className={cn(
|
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 && "h-11",
|
||||||
!minimal && hostChromeTitleInset && "lg:pl-[128px]",
|
!minimal && hostChromeTitleInset && "lg:pl-[128px]",
|
||||||
)}
|
)}
|
||||||
@@ -76,54 +63,6 @@ export function ThreadHeader({
|
|||||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||||
{sessionInfoAction}
|
{sessionInfoAction}
|
||||||
{promptNavigatorAction}
|
{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 ? (
|
{!hideThemeButton ? (
|
||||||
<ThemeButton
|
<ThemeButton
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/t
|
|||||||
|
|
||||||
interface ThreadMessagesProps {
|
interface ThreadMessagesProps {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
temporary?: boolean;
|
|
||||||
/** When true, agent turn still in flight — keeps activity timeline expanded. */
|
/** When true, agent turn still in flight — keeps activity timeline expanded. */
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
hiddenUserMessageCount?: number;
|
hiddenUserMessageCount?: number;
|
||||||
@@ -51,7 +50,6 @@ export function assistantForkFlags(units: DisplayUnit[]): boolean[] {
|
|||||||
|
|
||||||
export function ThreadMessages({
|
export function ThreadMessages({
|
||||||
messages,
|
messages,
|
||||||
temporary = false,
|
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
hiddenUserMessageCount = 0,
|
hiddenUserMessageCount = 0,
|
||||||
cliApps = [],
|
cliApps = [],
|
||||||
@@ -127,7 +125,6 @@ export function ThreadMessages({
|
|||||||
forkIndex={forkIndex}
|
forkIndex={forkIndex}
|
||||||
showForkBoundary={index === forkBoundaryAfterUnitIndex}
|
showForkBoundary={index === forkBoundaryAfterUnitIndex}
|
||||||
forkBoundaryLabel={t("thread.forkedFromHistory")}
|
forkBoundaryLabel={t("thread.forkedFromHistory")}
|
||||||
temporary={temporary}
|
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
slashCommands={slashCommands}
|
slashCommands={slashCommands}
|
||||||
@@ -150,7 +147,6 @@ interface ThreadDisplayUnitProps {
|
|||||||
forkIndex?: number;
|
forkIndex?: number;
|
||||||
showForkBoundary: boolean;
|
showForkBoundary: boolean;
|
||||||
forkBoundaryLabel: string;
|
forkBoundaryLabel: string;
|
||||||
temporary: boolean;
|
|
||||||
cliApps: CliAppInfo[];
|
cliApps: CliAppInfo[];
|
||||||
mcpPresets: McpPresetInfo[];
|
mcpPresets: McpPresetInfo[];
|
||||||
slashCommands: SlashCommand[];
|
slashCommands: SlashCommand[];
|
||||||
@@ -168,7 +164,6 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
|||||||
forkIndex,
|
forkIndex,
|
||||||
showForkBoundary,
|
showForkBoundary,
|
||||||
forkBoundaryLabel,
|
forkBoundaryLabel,
|
||||||
temporary,
|
|
||||||
cliApps,
|
cliApps,
|
||||||
mcpPresets,
|
mcpPresets,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
@@ -205,7 +200,6 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
|||||||
) : (
|
) : (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
message={unit.message}
|
message={unit.message}
|
||||||
temporary={temporary}
|
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
slashCommands={slashCommands}
|
slashCommands={slashCommands}
|
||||||
@@ -233,7 +227,6 @@ function threadDisplayUnitPropsEqual(
|
|||||||
&& previous.forkIndex === next.forkIndex
|
&& previous.forkIndex === next.forkIndex
|
||||||
&& previous.showForkBoundary === next.showForkBoundary
|
&& previous.showForkBoundary === next.showForkBoundary
|
||||||
&& previous.forkBoundaryLabel === next.forkBoundaryLabel
|
&& previous.forkBoundaryLabel === next.forkBoundaryLabel
|
||||||
&& previous.temporary === next.temporary
|
|
||||||
&& previous.cliApps === next.cliApps
|
&& previous.cliApps === next.cliApps
|
||||||
&& previous.mcpPresets === next.mcpPresets
|
&& previous.mcpPresets === next.mcpPresets
|
||||||
&& previous.slashCommands === next.slashCommands
|
&& previous.slashCommands === next.slashCommands
|
||||||
|
|||||||
@@ -295,17 +295,10 @@ interface ThreadShellProps {
|
|||||||
session: ChatSummary | null;
|
session: ChatSummary | null;
|
||||||
sessions?: ChatSummary[];
|
sessions?: ChatSummary[];
|
||||||
title: string;
|
title: string;
|
||||||
temporary?: boolean;
|
|
||||||
temporaryChatIds?: readonly string[];
|
|
||||||
temporaryChatEnabled?: boolean;
|
|
||||||
onTemporaryChatEnabledChange?: (enabled: boolean) => void;
|
|
||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
onGoHome?: () => void;
|
onGoHome?: () => void;
|
||||||
onNewChat?: () => void;
|
onNewChat?: () => void;
|
||||||
onCreateChat?: (
|
onCreateChat?: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string | null>;
|
||||||
workspaceScope?: WorkspaceScopePayload | null,
|
|
||||||
initialMessage?: string,
|
|
||||||
) => Promise<string | null>;
|
|
||||||
onForkChat?: (sourceChatId: string, beforeUserIndex: number) => Promise<string | null>;
|
onForkChat?: (sourceChatId: string, beforeUserIndex: number) => Promise<string | null>;
|
||||||
onTurnEnd?: () => void;
|
onTurnEnd?: () => void;
|
||||||
theme?: "light" | "dark";
|
theme?: "light" | "dark";
|
||||||
@@ -484,7 +477,7 @@ function HeroGreeting({ text }: { text: string }) {
|
|||||||
<h1
|
<h1
|
||||||
ref={headingRef}
|
ref={headingRef}
|
||||||
data-testid="hero-greeting"
|
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}
|
{text}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -587,10 +580,6 @@ export function ThreadShell({
|
|||||||
session,
|
session,
|
||||||
sessions = [],
|
sessions = [],
|
||||||
title,
|
title,
|
||||||
temporary = false,
|
|
||||||
temporaryChatIds = [],
|
|
||||||
temporaryChatEnabled = false,
|
|
||||||
onTemporaryChatEnabledChange,
|
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
onCreateChat,
|
onCreateChat,
|
||||||
onForkChat,
|
onForkChat,
|
||||||
@@ -613,7 +602,7 @@ export function ThreadShell({
|
|||||||
}: ThreadShellProps) {
|
}: ThreadShellProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const chatId = session?.chatId ?? null;
|
const chatId = session?.chatId ?? null;
|
||||||
const historyKey = temporary ? null : session?.key ?? null;
|
const historyKey = session?.key ?? null;
|
||||||
const mentionSessions = useMemo(
|
const mentionSessions = useMemo(
|
||||||
() => sessions.filter((candidate) => (
|
() => sessions.filter((candidate) => (
|
||||||
candidate.key !== historyKey
|
candidate.key !== historyKey
|
||||||
@@ -668,7 +657,6 @@ export function ThreadShell({
|
|||||||
const [quotedContext, setQuotedContext] = useState<string | null>(null);
|
const [quotedContext, setQuotedContext] = useState<string | null>(null);
|
||||||
const [composerFocusSignal, setComposerFocusSignal] = useState(0);
|
const [composerFocusSignal, setComposerFocusSignal] = useState(0);
|
||||||
const shellRef = useRef<HTMLElement | null>(null);
|
const shellRef = useRef<HTMLElement | null>(null);
|
||||||
const composerSurfaceRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
|
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
|
||||||
const filePreviewCloseTimerRef = useRef<number | null>(null);
|
const filePreviewCloseTimerRef = useRef<number | null>(null);
|
||||||
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
||||||
@@ -676,7 +664,6 @@ export function ThreadShell({
|
|||||||
const viewportRef = useRef<ThreadViewportHandle | null>(null);
|
const viewportRef = useRef<ThreadViewportHandle | null>(null);
|
||||||
const activeViewportTurnByChatIdRef = useRef<Map<string, string>>(new Map());
|
const activeViewportTurnByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(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). */
|
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
|
||||||
const prevChatIdForCacheRef = useRef<string | null>(null);
|
const prevChatIdForCacheRef = useRef<string | null>(null);
|
||||||
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
|
/** 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 sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||||
const currentUiMessagesRef = useRef<UIMessage[] | null>(null);
|
const currentUiMessagesRef = useRef<UIMessage[] | null>(null);
|
||||||
const uiRevisionRef = useRef(0);
|
const uiRevisionRef = useRef(0);
|
||||||
const showTemporaryChatControl =
|
|
||||||
!hideHeader && !session && !loading && !!onTemporaryChatEnabledChange;
|
|
||||||
|
|
||||||
const initial = useMemo(() => {
|
const initial = useMemo(() => {
|
||||||
if (!chatId) return historical;
|
if (!chatId) return historical;
|
||||||
@@ -751,18 +736,6 @@ export function ThreadShell({
|
|||||||
setSubmittedViewportTurnId(null);
|
setSubmittedViewportTurnId(null);
|
||||||
}, [historyKey]);
|
}, [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) => {
|
const handleQuoteSelection = useCallback((text: string) => {
|
||||||
setQuotedContext(text);
|
setQuotedContext(text);
|
||||||
setComposerFocusSignal((value) => value + 1);
|
setComposerFocusSignal((value) => value + 1);
|
||||||
@@ -865,12 +838,6 @@ export function ThreadShell({
|
|||||||
() => modelPresetOptionsFromSettings(settings),
|
() => modelPresetOptionsFromSettings(settings),
|
||||||
[settings],
|
[settings],
|
||||||
);
|
);
|
||||||
const availableSlashCommands = useMemo(
|
|
||||||
() => temporary
|
|
||||||
? slashCommands.filter(({ command }) => command === "/model" || command === "/stop")
|
|
||||||
: slashCommands,
|
|
||||||
[slashCommands, temporary],
|
|
||||||
);
|
|
||||||
const modelBadge = useMemo(
|
const modelBadge = useMemo(
|
||||||
() => toModelBadgeInfo(modelName, settings, activeModelPreset),
|
() => toModelBadgeInfo(modelName, settings, activeModelPreset),
|
||||||
[activeModelPreset, modelName, settings],
|
[activeModelPreset, modelName, settings],
|
||||||
@@ -931,7 +898,7 @@ export function ThreadShell({
|
|||||||
}, [chatId, client]);
|
}, [chatId, client]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!historyKey || !chatId || loading) return;
|
if (!chatId || loading) return;
|
||||||
const cached = messageCacheRef.current.get(chatId);
|
const cached = messageCacheRef.current.get(chatId);
|
||||||
const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId);
|
const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId);
|
||||||
const hasNewCanonicalHistory = (
|
const hasNewCanonicalHistory = (
|
||||||
@@ -1061,11 +1028,10 @@ export function ThreadShell({
|
|||||||
historyLineage,
|
historyLineage,
|
||||||
historyActiveTurnId,
|
historyActiveTurnId,
|
||||||
hasPendingToolCalls,
|
hasPendingToolCalls,
|
||||||
historyKey,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!historyKey || !chatId) return;
|
if (!chatId) return;
|
||||||
const commit = pendingCanonicalCommitRef.current.get(chatId);
|
const commit = pendingCanonicalCommitRef.current.get(chatId);
|
||||||
if (!commit) return;
|
if (!commit) return;
|
||||||
if (
|
if (
|
||||||
@@ -1103,17 +1069,17 @@ export function ThreadShell({
|
|||||||
pendingCanonicalCommitRef.current.delete(chatId);
|
pendingCanonicalCommitRef.current.delete(chatId);
|
||||||
committedHistoryLineageRef.current.set(chatId, historyLineage);
|
committedHistoryLineageRef.current.set(chatId, historyLineage);
|
||||||
completedCanonicalHydrateVersionRef.current.set(chatId, historyVersion);
|
completedCanonicalHydrateVersionRef.current.set(chatId, historyVersion);
|
||||||
}, [chatId, client, historyKey, historyLineage, historyVersion, messages, setMessages]);
|
}, [chatId, client, historyLineage, historyVersion, messages, setMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!historyKey || !chatId || hasPendingToolCalls) return;
|
if (!chatId || hasPendingToolCalls) return;
|
||||||
if (completedCanonicalHydrateVersionRef.current.get(chatId) !== historyVersion) return;
|
if (completedCanonicalHydrateVersionRef.current.get(chatId) !== historyVersion) return;
|
||||||
completedCanonicalHydrateVersionRef.current.delete(chatId);
|
completedCanonicalHydrateVersionRef.current.delete(chatId);
|
||||||
reconcileTurnComplete();
|
reconcileTurnComplete();
|
||||||
}, [chatId, hasPendingToolCalls, historyKey, historyVersion, messages, reconcileTurnComplete]);
|
}, [chatId, hasPendingToolCalls, historyVersion, messages, reconcileTurnComplete]);
|
||||||
|
|
||||||
const refreshCanonicalHistory = useCallback(() => {
|
const refreshCanonicalHistory = useCallback(() => {
|
||||||
if (!historyKey || !chatId) return;
|
if (!chatId) return;
|
||||||
pendingCanonicalHydrateRef.current.set(chatId, {
|
pendingCanonicalHydrateRef.current.set(chatId, {
|
||||||
historyLineage,
|
historyLineage,
|
||||||
historyVersion,
|
historyVersion,
|
||||||
@@ -1123,10 +1089,10 @@ export function ThreadShell({
|
|||||||
uiRevision: uiRevisionRef.current,
|
uiRevision: uiRevisionRef.current,
|
||||||
});
|
});
|
||||||
refreshHistory();
|
refreshHistory();
|
||||||
}, [chatId, client, historyKey, historyLineage, historyVersion, refreshHistory]);
|
}, [chatId, client, historyLineage, historyVersion, refreshHistory]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!historyKey || !chatId) return;
|
if (!chatId) return;
|
||||||
return client.onSessionUpdate((updatedChatId, scope) => {
|
return client.onSessionUpdate((updatedChatId, scope) => {
|
||||||
if (updatedChatId !== chatId) return;
|
if (updatedChatId !== chatId) return;
|
||||||
if (scope === "metadata") return;
|
if (scope === "metadata") return;
|
||||||
@@ -1135,7 +1101,7 @@ export function ThreadShell({
|
|||||||
// so keep an active programmatic follow alive across canonical hydration.
|
// so keep an active programmatic follow alive across canonical hydration.
|
||||||
refreshCanonicalHistory();
|
refreshCanonicalHistory();
|
||||||
});
|
});
|
||||||
}, [chatId, client, historyKey, refreshCanonicalHistory]);
|
}, [chatId, client, refreshCanonicalHistory]);
|
||||||
|
|
||||||
const wasPageHiddenRef = useRef(document.visibilityState === "hidden");
|
const wasPageHiddenRef = useRef(document.visibilityState === "hidden");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1146,7 +1112,7 @@ export function ThreadShell({
|
|||||||
}
|
}
|
||||||
if (!wasPageHiddenRef.current) return;
|
if (!wasPageHiddenRef.current) return;
|
||||||
wasPageHiddenRef.current = false;
|
wasPageHiddenRef.current = false;
|
||||||
if (!historyKey || !chatId || client.status !== "open" || loading) return;
|
if (!chatId || client.status !== "open" || loading) return;
|
||||||
if (
|
if (
|
||||||
!turnActive
|
!turnActive
|
||||||
&& !hasPendingToolCalls
|
&& !hasPendingToolCalls
|
||||||
@@ -1163,7 +1129,6 @@ export function ThreadShell({
|
|||||||
chatId,
|
chatId,
|
||||||
client,
|
client,
|
||||||
hasPendingToolCalls,
|
hasPendingToolCalls,
|
||||||
historyKey,
|
|
||||||
historyError,
|
historyError,
|
||||||
loading,
|
loading,
|
||||||
refreshCanonicalHistory,
|
refreshCanonicalHistory,
|
||||||
@@ -1265,7 +1230,7 @@ export function ThreadShell({
|
|||||||
setBooting(true);
|
setBooting(true);
|
||||||
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
|
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
|
||||||
setPendingFirstTargetChatId(null);
|
setPendingFirstTargetChatId(null);
|
||||||
const newId = await onCreateChat?.(workspaceScope, content);
|
const newId = await onCreateChat?.(workspaceScope);
|
||||||
if (!newId) {
|
if (!newId) {
|
||||||
pendingFirstRef.current = null;
|
pendingFirstRef.current = null;
|
||||||
setPendingFirstTargetChatId(null);
|
setPendingFirstTargetChatId(null);
|
||||||
@@ -1421,7 +1386,7 @@ export function ThreadShell({
|
|||||||
fallbackModelName={fallbackModelName}
|
fallbackModelName={fallbackModelName}
|
||||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||||
variant={showHeroComposer ? "hero" : "thread"}
|
variant={showHeroComposer ? "hero" : "thread"}
|
||||||
slashCommands={availableSlashCommands}
|
slashCommands={slashCommands}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
sessions={mentionSessions}
|
sessions={mentionSessions}
|
||||||
@@ -1431,13 +1396,12 @@ export function ThreadShell({
|
|||||||
runStartedAt={currentRunStartedAt}
|
runStartedAt={currentRunStartedAt}
|
||||||
goalState={currentGoalState}
|
goalState={currentGoalState}
|
||||||
workspaceScope={workspaceScope}
|
workspaceScope={workspaceScope}
|
||||||
workspaceControlsHidden={temporary}
|
|
||||||
workspaceDefaultScope={workspaceDefaultScope}
|
workspaceDefaultScope={workspaceDefaultScope}
|
||||||
workspaceControls={workspaceControls}
|
workspaceControls={workspaceControls}
|
||||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||||
workspaceError={workspaceError}
|
workspaceError={workspaceError}
|
||||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||||
pendingQueueKey={temporary ? null : chatId}
|
pendingQueueKey={chatId}
|
||||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||||
ingressLimits={ingressLimits}
|
ingressLimits={ingressLimits}
|
||||||
quotedContext={quotedContext}
|
quotedContext={quotedContext}
|
||||||
@@ -1465,17 +1429,15 @@ export function ThreadShell({
|
|||||||
fallbackModelName={fallbackModelName}
|
fallbackModelName={fallbackModelName}
|
||||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||||
variant="hero"
|
variant="hero"
|
||||||
slashCommands={availableSlashCommands}
|
slashCommands={slashCommands}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
sessions={mentionSessions}
|
sessions={mentionSessions}
|
||||||
skills={skills}
|
skills={skills}
|
||||||
surfaceRef={composerSurfaceRef}
|
|
||||||
runStartedAt={currentRunStartedAt}
|
runStartedAt={currentRunStartedAt}
|
||||||
onTranscribeAudio={transcribeAudio}
|
onTranscribeAudio={transcribeAudio}
|
||||||
goalState={currentGoalState}
|
goalState={currentGoalState}
|
||||||
workspaceScope={workspaceScope}
|
workspaceScope={workspaceScope}
|
||||||
workspaceControlsHidden={temporary}
|
|
||||||
workspaceDefaultScope={workspaceDefaultScope}
|
workspaceDefaultScope={workspaceDefaultScope}
|
||||||
workspaceControls={workspaceControls}
|
workspaceControls={workspaceControls}
|
||||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||||
@@ -1522,11 +1484,6 @@ export function ThreadShell({
|
|||||||
minimal={!session && !loading}
|
minimal={!session && !loading}
|
||||||
promptNavigatorAction={promptNavigatorAction}
|
promptNavigatorAction={promptNavigatorAction}
|
||||||
sessionInfoAction={sessionInfoAction}
|
sessionInfoAction={sessionInfoAction}
|
||||||
temporaryChatEnabled={temporaryChatEnabled}
|
|
||||||
temporaryChatDisabled={booting || turnActive}
|
|
||||||
onTemporaryChatEnabledChange={
|
|
||||||
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<FilePreviewAvailabilityProvider
|
<FilePreviewAvailabilityProvider
|
||||||
@@ -1535,7 +1492,6 @@ export function ThreadShell({
|
|||||||
<ThreadViewport
|
<ThreadViewport
|
||||||
ref={viewportRef}
|
ref={viewportRef}
|
||||||
messages={displayMessages}
|
messages={displayMessages}
|
||||||
temporary={temporary}
|
|
||||||
isStreaming={turnActive}
|
isStreaming={turnActive}
|
||||||
emptyState={emptyState}
|
emptyState={emptyState}
|
||||||
composer={composer}
|
composer={composer}
|
||||||
@@ -1546,7 +1502,7 @@ export function ThreadShell({
|
|||||||
showScrollToBottomButton={!!session}
|
showScrollToBottomButton={!!session}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
slashCommands={availableSlashCommands}
|
slashCommands={slashCommands}
|
||||||
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
||||||
hasMoreBefore={hasMoreBefore}
|
hasMoreBefore={hasMoreBefore}
|
||||||
loadingOlder={loadingOlder}
|
loadingOlder={loadingOlder}
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export interface ThreadViewportHandle {
|
|||||||
|
|
||||||
interface ThreadViewportProps {
|
interface ThreadViewportProps {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
temporary?: boolean;
|
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
composer: ReactNode;
|
composer: ReactNode;
|
||||||
emptyState?: ReactNode;
|
emptyState?: ReactNode;
|
||||||
@@ -158,7 +157,6 @@ function readSoftKeyboardInsetBottom(container: HTMLElement | null): number {
|
|||||||
|
|
||||||
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
|
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
|
||||||
messages,
|
messages,
|
||||||
temporary = false,
|
|
||||||
isStreaming,
|
isStreaming,
|
||||||
composer,
|
composer,
|
||||||
emptyState,
|
emptyState,
|
||||||
@@ -684,7 +682,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
<div ref={messageContentRef} className="mx-auto w-full max-w-[49.5rem]">
|
<div ref={messageContentRef} className="mx-auto w-full max-w-[49.5rem]">
|
||||||
<ThreadMessages
|
<ThreadMessages
|
||||||
messages={visibleMessages}
|
messages={visibleMessages}
|
||||||
temporary={temporary}
|
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
hiddenUserMessageCount={hiddenUserMessageCount}
|
hiddenUserMessageCount={hiddenUserMessageCount}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ import {
|
|||||||
|
|
||||||
export function WorkspaceProjectPicker({
|
export function WorkspaceProjectPicker({
|
||||||
isHero,
|
isHero,
|
||||||
compact = false,
|
|
||||||
connected = false,
|
|
||||||
disabled,
|
disabled,
|
||||||
scope,
|
scope,
|
||||||
defaultScope,
|
defaultScope,
|
||||||
@@ -46,8 +44,6 @@ export function WorkspaceProjectPicker({
|
|||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
isHero: boolean;
|
isHero: boolean;
|
||||||
compact?: boolean;
|
|
||||||
connected?: boolean;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
scope: WorkspaceScopePayload | null;
|
scope: WorkspaceScopePayload | null;
|
||||||
defaultScope: WorkspaceScopePayload | null;
|
defaultScope: WorkspaceScopePayload | null;
|
||||||
@@ -78,12 +74,8 @@ export function WorkspaceProjectPicker({
|
|||||||
}, [currentProjectScope?.project_path, open]);
|
}, [currentProjectScope?.project_path, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (disabled) setOpen(false);
|
if (error && visible) setOpen(true);
|
||||||
}, [disabled]);
|
}, [error, visible]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (error && visible && !disabled) setOpen(true);
|
|
||||||
}, [disabled, error, visible]);
|
|
||||||
|
|
||||||
const applyProjectPath = useCallback(
|
const applyProjectPath = useCallback(
|
||||||
(projectPath: string, projectName?: string) => {
|
(projectPath: string, projectName?: string) => {
|
||||||
@@ -123,11 +115,7 @@ export function WorkspaceProjectPicker({
|
|||||||
|
|
||||||
if (nativeProjectPicker) {
|
if (nativeProjectPicker) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<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">
|
||||||
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",
|
|
||||||
)}>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled || pickingFolder}
|
disabled={disabled || pickingFolder}
|
||||||
@@ -135,18 +123,16 @@ export function WorkspaceProjectPicker({
|
|||||||
title={currentProjectScope?.project_path}
|
title={currentProjectScope?.project_path}
|
||||||
onClick={() => void pickNativeFolder()}
|
onClick={() => void pickNativeFolder()}
|
||||||
className={cn(
|
className={cn(
|
||||||
compact
|
"inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||||
? "thread-composer-action touch-target inline-flex h-8 w-8 items-center justify-center rounded-full border border-transparent"
|
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
|
||||||
: "inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
currentProjectScope && "text-foreground/82",
|
||||||
compact ? "hover:bg-muted/65" : "hover:bg-background/70",
|
|
||||||
(connected || currentProjectScope) && "text-primary",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Folder className={cn("shrink-0", compact ? "h-4 w-4" : "h-3.5 w-3.5")} />
|
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
|
||||||
<span className={compact ? "sr-only" : "truncate"}>{projectLabel}</span>
|
<span className="truncate">{projectLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
{!compact && (pathError || error) ? (
|
{pathError || error ? (
|
||||||
<span role="alert" className="ml-2 min-w-0 truncate text-[11.5px] font-medium text-destructive">
|
<span role="alert" className="ml-2 min-w-0 truncate text-[11.5px] font-medium text-destructive">
|
||||||
{pathError ?? error}
|
{pathError ?? error}
|
||||||
</span>
|
</span>
|
||||||
@@ -156,11 +142,7 @@ export function WorkspaceProjectPicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<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">
|
||||||
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",
|
|
||||||
)}>
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -168,19 +150,15 @@ export function WorkspaceProjectPicker({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={t("thread.composer.workspace.projectAria")}
|
aria-label={t("thread.composer.workspace.projectAria")}
|
||||||
className={cn(
|
className={cn(
|
||||||
compact
|
"inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||||
? "thread-composer-action touch-target inline-flex h-8 w-8 items-center justify-center rounded-full border border-transparent"
|
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
|
||||||
: "inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
currentProjectScope && "text-foreground/82",
|
||||||
compact ? "hover:bg-muted/65" : "hover:bg-background/70",
|
|
||||||
(connected || currentProjectScope) && "text-primary",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Folder className={cn("shrink-0", compact ? "h-4 w-4" : "h-3.5 w-3.5")} />
|
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
|
||||||
<span className={compact ? "sr-only" : "truncate"}>{projectLabel}</span>
|
<span className="truncate">{projectLabel}</span>
|
||||||
{!compact ? (
|
|
||||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
) : null}
|
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ function truncatePreview(text: string, maxLength: number): string {
|
|||||||
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
|
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 {
|
export function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
|
||||||
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
|
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
|
||||||
return Array.from(candidates).find(
|
return Array.from(candidates).find(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
import { Circle } from "lucide-react";
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
floatingItemClassName,
|
floatingItemClassName,
|
||||||
@@ -12,11 +12,52 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
const menuItemClassName =
|
const menuItemClassName =
|
||||||
`${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
|
`${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
|
interface DropdownMenuContentProps
|
||||||
extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> {
|
extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> {
|
||||||
portalContainer?: HTMLElement | null;
|
portalContainer?: HTMLElement | null;
|
||||||
@@ -62,6 +103,31 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
));
|
));
|
||||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
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<
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
@@ -117,11 +183,17 @@ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuPortal,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
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%;
|
--input: 40 8% 90.5%;
|
||||||
--ring: 0 0% 3.9%;
|
--ring: 0 0% 3.9%;
|
||||||
--inline-token-highlight: #ef8e30;
|
--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;
|
--radius: 0.4375rem;
|
||||||
--sidebar: 40 8% 96.8%;
|
--sidebar: 40 8% 96.8%;
|
||||||
--sidebar-foreground: 0 0% 3.9%;
|
--sidebar-foreground: 0 0% 3.9%;
|
||||||
@@ -71,10 +67,6 @@
|
|||||||
--input: var(--border);
|
--input: var(--border);
|
||||||
--ring: 0 0% 83.1%;
|
--ring: 0 0% 83.1%;
|
||||||
--inline-token-highlight: #ef8e30;
|
--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: var(--card);
|
||||||
--sidebar-foreground: 0 0% 98%;
|
--sidebar-foreground: 0 0% 98%;
|
||||||
--sidebar-accent: var(--background);
|
--sidebar-accent: var(--background);
|
||||||
@@ -243,6 +235,10 @@
|
|||||||
height: 0;
|
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. */
|
/* Keep the outer document rhythm clean at message boundaries. */
|
||||||
.markdown-content > :first-child {
|
.markdown-content > :first-child {
|
||||||
@apply mt-0;
|
@apply mt-0;
|
||||||
@@ -342,6 +338,7 @@
|
|||||||
animation: none;
|
animation: none;
|
||||||
content: "";
|
content: "";
|
||||||
}
|
}
|
||||||
|
.markdown-content-streaming > :last-child::after,
|
||||||
.streaming-text-fallback::after {
|
.streaming-text-fallback::after {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
@@ -438,41 +435,6 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
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 {
|
@keyframes run-pulse-dot {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
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. */
|
/* Subtle scrollbar that doesn't fight the dark background. */
|
||||||
.scrollbar-thin {
|
.scrollbar-thin {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export type AttachmentError =
|
|||||||
| "io"; // file read failed at the browser layer
|
| "io"; // file read failed at the browser layer
|
||||||
|
|
||||||
export const MAX_ATTACHMENTS_PER_MESSAGE = 4;
|
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_ATTACHMENT_BYTES = 6 * 1024 * 1024;
|
||||||
export const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 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));
|
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
||||||
});
|
});
|
||||||
suppressStreamUntilTurnEndRef.current = false;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
setRunStartedAt(null);
|
|
||||||
client.finishRunLocally(chatId);
|
|
||||||
client.sendMessage(chatId, "/stop");
|
client.sendMessage(chatId, "/stop");
|
||||||
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
|
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
|
||||||
|
|
||||||
|
|||||||
@@ -49,15 +49,6 @@
|
|||||||
"noMatch": "No pending request matches this code."
|
"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": {
|
"sidebar": {
|
||||||
"navigation": "Sidebar navigation",
|
"navigation": "Sidebar navigation",
|
||||||
"collapse": "Collapse sidebar",
|
"collapse": "Collapse sidebar",
|
||||||
|
|||||||
@@ -49,15 +49,6 @@
|
|||||||
"noMatch": "No hay ninguna solicitud pendiente que coincida con este código."
|
"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": {
|
"sidebar": {
|
||||||
"navigation": "Navegación de la barra lateral",
|
"navigation": "Navegación de la barra lateral",
|
||||||
"collapse": "Contraer barra lateral",
|
"collapse": "Contraer barra lateral",
|
||||||
|
|||||||
@@ -49,15 +49,6 @@
|
|||||||
"noMatch": "Aucune demande en attente ne correspond à ce code."
|
"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": {
|
"sidebar": {
|
||||||
"navigation": "Navigation de la barre latérale",
|
"navigation": "Navigation de la barre latérale",
|
||||||
"collapse": "Réduire 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."
|
"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": {
|
"sidebar": {
|
||||||
"navigation": "Navigasi bilah samping",
|
"navigation": "Navigasi bilah samping",
|
||||||
"collapse": "Ciutkan sidebar",
|
"collapse": "Ciutkan sidebar",
|
||||||
|
|||||||
@@ -49,15 +49,6 @@
|
|||||||
"noMatch": "このコードに一致する保留中のリクエストはありません。"
|
"noMatch": "このコードに一致する保留中のリクエストはありません。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"temporaryChat": {
|
|
||||||
"title": "一時チャット",
|
|
||||||
"retention": "履歴やメモリには保存されません。",
|
|
||||||
"expiration": "再読み込み、ページを閉じる操作、接続切断で一時チャットは終了します。",
|
|
||||||
"externalEffects": "リクエストは引き続きモデルプロバイダーに送信され、ツール操作による変更は残る場合があります。",
|
|
||||||
"clear": "一時チャットを消去",
|
|
||||||
"sectionTitle": "一時チャット",
|
|
||||||
"closeAction": "一時チャット「{{title}}」を閉じる"
|
|
||||||
},
|
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"navigation": "サイドバーのナビゲーション",
|
"navigation": "サイドバーのナビゲーション",
|
||||||
"collapse": "サイドバーを閉じる",
|
"collapse": "サイドバーを閉じる",
|
||||||
|
|||||||
@@ -49,15 +49,6 @@
|
|||||||
"noMatch": "이 코드와 일치하는 대기 중인 요청이 없습니다."
|
"noMatch": "이 코드와 일치하는 대기 중인 요청이 없습니다."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"temporaryChat": {
|
|
||||||
"title": "임시 채팅",
|
|
||||||
"retention": "기록이나 메모리에 저장되지 않습니다.",
|
|
||||||
"expiration": "새로고침, 페이지 닫기 또는 연결 끊김 시 임시 채팅이 종료됩니다.",
|
|
||||||
"externalEffects": "요청은 계속 모델 제공업체로 전송되며 도구 작업의 변경 사항은 남을 수 있습니다.",
|
|
||||||
"clear": "임시 채팅 지우기",
|
|
||||||
"sectionTitle": "임시 채팅",
|
|
||||||
"closeAction": "임시 채팅 닫기: {{title}}"
|
|
||||||
},
|
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"navigation": "사이드바 탐색",
|
"navigation": "사이드바 탐색",
|
||||||
"collapse": "사이드바 접기",
|
"collapse": "사이드바 접기",
|
||||||
|
|||||||
@@ -49,15 +49,6 @@
|
|||||||
"noMatch": "Nenhuma solicitação pendente corresponde a este código."
|
"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": {
|
"sidebar": {
|
||||||
"navigation": "Navegação da barra lateral",
|
"navigation": "Navegação da barra lateral",
|
||||||
"collapse": "Recolher 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."
|
"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": {
|
"sidebar": {
|
||||||
"navigation": "Điều hướng thanh bên",
|
"navigation": "Điều hướng thanh bên",
|
||||||
"collapse": "Thu gọn thanh bên",
|
"collapse": "Thu gọn thanh bên",
|
||||||
|
|||||||
@@ -49,15 +49,6 @@
|
|||||||
"noMatch": "没有待处理请求与此配对码匹配。"
|
"noMatch": "没有待处理请求与此配对码匹配。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"temporaryChat": {
|
|
||||||
"title": "临时聊天",
|
|
||||||
"retention": "不会保存到历史记录或记忆。",
|
|
||||||
"expiration": "刷新、关闭页面或连接中断后,临时聊天会结束。",
|
|
||||||
"externalEffects": "请求仍会发送给模型提供商,工具操作也可能留下更改。",
|
|
||||||
"clear": "清空临时聊天",
|
|
||||||
"sectionTitle": "临时聊天",
|
|
||||||
"closeAction": "关闭临时聊天:{{title}}"
|
|
||||||
},
|
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"navigation": "侧边栏导航",
|
"navigation": "侧边栏导航",
|
||||||
"collapse": "收起侧边栏",
|
"collapse": "收起侧边栏",
|
||||||
|
|||||||
@@ -49,15 +49,6 @@
|
|||||||
"noMatch": "沒有待處理請求符合此配對碼。"
|
"noMatch": "沒有待處理請求符合此配對碼。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"temporaryChat": {
|
|
||||||
"title": "臨時聊天",
|
|
||||||
"retention": "不會儲存至歷史記錄或記憶。",
|
|
||||||
"expiration": "重新載入、關閉頁面或連線中斷後,臨時聊天會結束。",
|
|
||||||
"externalEffects": "請求仍會傳送給模型供應商,工具操作也可能留下變更。",
|
|
||||||
"clear": "清空臨時聊天",
|
|
||||||
"sectionTitle": "臨時聊天",
|
|
||||||
"closeAction": "關閉臨時聊天:{{title}}"
|
|
||||||
},
|
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"navigation": "側邊欄導覽",
|
"navigation": "側邊欄導覽",
|
||||||
"collapse": "收合側邊欄",
|
"collapse": "收合側邊欄",
|
||||||
|
|||||||
@@ -108,10 +108,6 @@ interface PendingRequest<T> {
|
|||||||
timer: ReturnType<typeof setTimeout>;
|
timer: ReturnType<typeof setTimeout>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingChatRequest extends PendingRequest<string> {
|
|
||||||
temporary: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SYSTEM_COMMAND_TURN_PREFIX = "webui-system:";
|
const SYSTEM_COMMAND_TURN_PREFIX = "webui-system:";
|
||||||
const TURN_REJECTION_DETAILS = new Set([
|
const TURN_REJECTION_DETAILS = new Set([
|
||||||
"access_denied",
|
"access_denied",
|
||||||
@@ -177,8 +173,6 @@ export class NanobotClient {
|
|||||||
private static readonly PENDING_INBOUND_MAX = 2000;
|
private static readonly PENDING_INBOUND_MAX = 2000;
|
||||||
// chat_ids we've attached to since connect; re-attached after reconnects
|
// chat_ids we've attached to since connect; re-attached after reconnects
|
||||||
private knownChats = new Set<string>();
|
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. */
|
/** Wall-clock run strip: updated from ``goal_status`` even with no ``onChat`` subscriber. */
|
||||||
private runStartedAtByChatId = new Map<string, number>();
|
private runStartedAtByChatId = new Map<string, number>();
|
||||||
/** Per-turn clocks let a rejected newer turn fall back without borrowing its timer. */
|
/** 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;
|
private static readonly COMPLETED_TURN_FENCE_MAX = 256;
|
||||||
/** Latest ``goal_state`` snapshot per ``chat_id`` (multi-session isolation). */
|
/** Latest ``goal_state`` snapshot per ``chat_id`` (multi-session isolation). */
|
||||||
private goalStateByChatId = new Map<string, GoalStateWsPayload>();
|
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 pendingTranscriptions = new Map<string, PendingRequest<string>>();
|
||||||
private pendingSystemCommands = new Map<string, PendingRequest<void>>();
|
private pendingSystemCommands = new Map<string, PendingRequest<void>>();
|
||||||
// Frames queued while the socket is not yet OPEN
|
// Frames queued while the socket is not yet OPEN
|
||||||
@@ -288,16 +282,6 @@ export class NanobotClient {
|
|||||||
return v === undefined ? null : v;
|
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. */
|
/** Refresh transport policy after bootstrap token renewal. */
|
||||||
updateMaxFrameBytes(maxFrameBytes?: number): void {
|
updateMaxFrameBytes(maxFrameBytes?: number): void {
|
||||||
this.maxFrameBytes = this.normalizeMaxFrameBytes(maxFrameBytes);
|
this.maxFrameBytes = this.normalizeMaxFrameBytes(maxFrameBytes);
|
||||||
@@ -741,18 +725,9 @@ export class NanobotClient {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
this.clearTemporaryChats();
|
|
||||||
this.setStatus("closed");
|
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. */
|
/** Ask the server to provision a new chat_id; resolves with the assigned id. */
|
||||||
newChat(timeoutMs: number = 5_000, workspaceScope?: WorkspaceScopePayload | null): Promise<string> {
|
newChat(timeoutMs: number = 5_000, workspaceScope?: WorkspaceScopePayload | null): Promise<string> {
|
||||||
if (this.pendingNewChat) {
|
if (this.pendingNewChat) {
|
||||||
@@ -763,7 +738,7 @@ export class NanobotClient {
|
|||||||
this.pendingNewChat = null;
|
this.pendingNewChat = null;
|
||||||
reject(new Error("newChat timed out"));
|
reject(new Error("newChat timed out"));
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
this.pendingNewChat = { resolve, reject, timer, temporary: false };
|
this.pendingNewChat = { resolve, reject, timer };
|
||||||
this.queueSend({
|
this.queueSend({
|
||||||
type: "new_chat",
|
type: "new_chat",
|
||||||
...(workspaceScope ? { workspace_scope: workspaceScope } : {}),
|
...(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(
|
transcribeAudio(
|
||||||
dataUrl: string,
|
dataUrl: string,
|
||||||
options?: { durationMs?: number; timeoutMs?: number },
|
options?: { durationMs?: number; timeoutMs?: number },
|
||||||
@@ -822,7 +782,7 @@ export class NanobotClient {
|
|||||||
this.pendingNewChat = null;
|
this.pendingNewChat = null;
|
||||||
reject(new Error("forkChat timed out"));
|
reject(new Error("forkChat timed out"));
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
this.pendingNewChat = { resolve, reject, timer, temporary: false };
|
this.pendingNewChat = { resolve, reject, timer };
|
||||||
this.queueSend({
|
this.queueSend({
|
||||||
type: "fork_chat",
|
type: "fork_chat",
|
||||||
source_chat_id: sourceChatId,
|
source_chat_id: sourceChatId,
|
||||||
@@ -833,7 +793,6 @@ export class NanobotClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
attach(chatId: string): void {
|
attach(chatId: string): void {
|
||||||
if (this.temporaryChatIds.has(chatId)) return;
|
|
||||||
this.knownChats.add(chatId);
|
this.knownChats.add(chatId);
|
||||||
if (this.socket?.readyState === WS_OPEN) {
|
if (this.socket?.readyState === WS_OPEN) {
|
||||||
this.queueSend({ type: "attach", chat_id: chatId });
|
this.queueSend({ type: "attach", chat_id: chatId });
|
||||||
@@ -855,8 +814,7 @@ export class NanobotClient {
|
|||||||
startsNewRun?: boolean;
|
startsNewRun?: boolean;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
const temporary = this.temporaryChatIds.has(chatId);
|
this.knownChats.add(chatId);
|
||||||
if (!temporary) this.knownChats.add(chatId);
|
|
||||||
const frame: Outbound = {
|
const frame: Outbound = {
|
||||||
type: "message",
|
type: "message",
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
@@ -905,7 +863,6 @@ export class NanobotClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
|
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
|
||||||
if (this.temporaryChatIds.has(chatId)) return;
|
|
||||||
this.knownChats.add(chatId);
|
this.knownChats.add(chatId);
|
||||||
this.queueSend({
|
this.queueSend({
|
||||||
type: "set_workspace_scope",
|
type: "set_workspace_scope",
|
||||||
@@ -1030,15 +987,8 @@ export class NanobotClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.event === "attached") {
|
if (parsed.event === "attached") {
|
||||||
if (parsed.temporary === true) {
|
|
||||||
this.temporaryChatIds.add(parsed.chat_id);
|
|
||||||
} else {
|
|
||||||
this.knownChats.add(parsed.chat_id);
|
this.knownChats.add(parsed.chat_id);
|
||||||
}
|
if (this.pendingNewChat) {
|
||||||
if (
|
|
||||||
this.pendingNewChat
|
|
||||||
&& this.pendingNewChat.temporary === (parsed.temporary === true)
|
|
||||||
) {
|
|
||||||
clearTimeout(this.pendingNewChat.timer);
|
clearTimeout(this.pendingNewChat.timer);
|
||||||
this.pendingNewChat.resolve(parsed.chat_id);
|
this.pendingNewChat.resolve(parsed.chat_id);
|
||||||
this.pendingNewChat = null;
|
this.pendingNewChat = null;
|
||||||
@@ -1144,7 +1094,6 @@ export class NanobotClient {
|
|||||||
|
|
||||||
private handleClose(event?: { code?: number }): void {
|
private handleClose(event?: { code?: number }): void {
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
this.clearTemporaryChats();
|
|
||||||
if (this.pendingNewChat) {
|
if (this.pendingNewChat) {
|
||||||
clearTimeout(this.pendingNewChat.timer);
|
clearTimeout(this.pendingNewChat.timer);
|
||||||
this.pendingNewChat.reject(new Error("socket closed"));
|
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 {
|
private frameFitsTransport(frame: Outbound): boolean {
|
||||||
if (this.maxFrameBytes === undefined) return true;
|
if (this.maxFrameBytes === undefined) return true;
|
||||||
return new TextEncoder().encode(JSON.stringify(frame)).byteLength <= this.maxFrameBytes;
|
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 =
|
export type InboundEvent =
|
||||||
| { event: "ready"; chat_id: string; client_id: string }
|
| { 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_accepted"; chat_id: string; turn_id: string }
|
||||||
| ({
|
| ({
|
||||||
event: "message";
|
event: "message";
|
||||||
@@ -1338,11 +1338,9 @@ export interface FilePreviewPayload {
|
|||||||
|
|
||||||
export type Outbound =
|
export type Outbound =
|
||||||
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
||||||
| { type: "new_temporary_chat" }
|
|
||||||
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
||||||
| { type: "attach"; chat_id: string }
|
| { type: "attach"; chat_id: string }
|
||||||
| { type: "set_sidebar_state"; state: SidebarStatePayload }
|
| { type: "set_sidebar_state"; state: SidebarStatePayload }
|
||||||
| { type: "discard_temporary_chat"; chat_id: string }
|
|
||||||
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
|
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
|
||||||
| { type: "transcribe_audio"; request_id: string; data_url: string; duration_ms?: number }
|
| { type: "transcribe_audio"; request_id: string; data_url: string; duration_ms?: number }
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -3,12 +3,7 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import type {
|
import type { ChatSummary, SessionAutomationJob } from "@/lib/types";
|
||||||
ChatSummary,
|
|
||||||
ConnectionStatus,
|
|
||||||
SessionAutomationJob,
|
|
||||||
WorkspaceScopePayload,
|
|
||||||
} from "@/lib/types";
|
|
||||||
|
|
||||||
const connectSpy = vi.fn();
|
const connectSpy = vi.fn();
|
||||||
const refreshSpy = vi.fn();
|
const refreshSpy = vi.fn();
|
||||||
@@ -19,16 +14,8 @@ const toggleThemeSpy = vi.fn();
|
|||||||
const updateUrlSpy = vi.fn();
|
const updateUrlSpy = vi.fn();
|
||||||
const attachSpy = vi.fn();
|
const attachSpy = vi.fn();
|
||||||
const setSidebarStateSpy = 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 runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
||||||
const sessionUpdateHandlers = new Set<(
|
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||||
chatId: string,
|
|
||||||
scope?: string,
|
|
||||||
workspaceScope?: WorkspaceScopePayload,
|
|
||||||
) => void>();
|
|
||||||
let mockSessions: ChatSummary[] = [];
|
let mockSessions: ChatSummary[] = [];
|
||||||
const HERO_GREETING_PATTERN =
|
const HERO_GREETING_PATTERN =
|
||||||
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
|
/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(),
|
clearSavedSecret: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/nanobot-client", async (importOriginal) => {
|
vi.mock("@/lib/nanobot-client", () => {
|
||||||
const actual = await importOriginal<typeof import("@/lib/nanobot-client")>();
|
|
||||||
class MockClient {
|
class MockClient {
|
||||||
status = "idle" as const;
|
status = "idle" as const;
|
||||||
defaultChatId: string | null = null;
|
defaultChatId: string | null = null;
|
||||||
connect = connectSpy;
|
connect = connectSpy;
|
||||||
onStatus = (handler: (status: ConnectionStatus) => void) => {
|
onStatus = () => () => {};
|
||||||
statusHandlers.add(handler);
|
|
||||||
return () => statusHandlers.delete(handler);
|
|
||||||
};
|
|
||||||
onRuntimeModelUpdate = () => () => {};
|
onRuntimeModelUpdate = () => () => {};
|
||||||
onError = () => () => {};
|
onError = () => () => {};
|
||||||
onChat = () => () => {};
|
onChat = () => () => {};
|
||||||
onSessionUpdate = (handler: (
|
onSessionUpdate = (handler: (chatId: string, scope?: string) => void) => {
|
||||||
chatId: string,
|
|
||||||
scope?: string,
|
|
||||||
workspaceScope?: WorkspaceScopePayload,
|
|
||||||
) => void) => {
|
|
||||||
sessionUpdateHandlers.add(handler);
|
sessionUpdateHandlers.add(handler);
|
||||||
return () => sessionUpdateHandlers.delete(handler);
|
return () => sessionUpdateHandlers.delete(handler);
|
||||||
};
|
};
|
||||||
@@ -237,18 +216,16 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
getRunStartedAt = () => null;
|
getRunStartedAt = () => null;
|
||||||
getGoalState = () => undefined;
|
getGoalState = () => undefined;
|
||||||
sendMessage = sendMessageSpy;
|
sendMessage = vi.fn();
|
||||||
newChat = vi.fn();
|
newChat = vi.fn();
|
||||||
newTemporaryChat = newTemporaryChatSpy;
|
|
||||||
attach = attachSpy;
|
attach = attachSpy;
|
||||||
setSidebarState = setSidebarStateSpy;
|
setSidebarState = setSidebarStateSpy;
|
||||||
discardTemporaryChat = discardTemporaryChatSpy;
|
|
||||||
close = vi.fn();
|
close = vi.fn();
|
||||||
updateUrl = updateUrlSpy;
|
updateUrl = updateUrlSpy;
|
||||||
updateMaxFrameBytes = vi.fn();
|
updateMaxFrameBytes = vi.fn();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...actual, NanobotClient: MockClient };
|
return { NanobotClient: MockClient };
|
||||||
});
|
});
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -271,13 +248,6 @@ describe("App layout", () => {
|
|||||||
toggleThemeSpy.mockReset();
|
toggleThemeSpy.mockReset();
|
||||||
attachSpy.mockReset();
|
attachSpy.mockReset();
|
||||||
setSidebarStateSpy.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();
|
runStatusHandlers.clear();
|
||||||
sessionUpdateHandlers.clear();
|
sessionUpdateHandlers.clear();
|
||||||
window.history.replaceState(null, "", "/");
|
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 () => {
|
it("restores the Settings route after a restart fallback hash", async () => {
|
||||||
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
|
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
|
||||||
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
|
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
|
||||||
|
|||||||
@@ -152,39 +152,6 @@ describe("ChatList", () => {
|
|||||||
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha"));
|
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", () => {
|
it("orders chats by latest session activity by default", () => {
|
||||||
const sessions = [
|
const sessions = [
|
||||||
session({
|
session({
|
||||||
|
|||||||
@@ -113,25 +113,6 @@ describe("MessageBubble", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
|
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", () => {
|
it("does not replay an entrance animation when persisted messages mount", () => {
|
||||||
const messages: UIMessage[] = [
|
const messages: UIMessage[] = [
|
||||||
{
|
{
|
||||||
@@ -935,14 +916,6 @@ describe("MessageBubble", () => {
|
|||||||
|
|
||||||
const imageButton = screen.getByRole("button", { name: /view image/i });
|
const imageButton = screen.getByRole("button", { name: /view image/i });
|
||||||
expect(imageButton).toHaveClass("w-[min(100%,34rem)]", "rounded-[20px]");
|
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(imageButton).not.toHaveAttribute("title");
|
||||||
expect(container.querySelector("img")).toHaveClass("h-auto", "w-full", "object-contain");
|
expect(container.querySelector("img")).toHaveClass("h-auto", "w-full", "object-contain");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,116 +71,6 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("NanobotClient", () => {
|
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", () => {
|
it("routes events to the matching chat handler", () => {
|
||||||
const client = new NanobotClient({
|
const client = new NanobotClient({
|
||||||
url: "ws://test",
|
url: "ws://test",
|
||||||
@@ -324,31 +214,6 @@ describe("NanobotClient", () => {
|
|||||||
expect(client.getRunStartedAt("chat-strip")).toBeNull();
|
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 () => {
|
it("clears stale run strip when reconnecting after a dropped socket", async () => {
|
||||||
const client = new NanobotClient({
|
const client = new NanobotClient({
|
||||||
url: "ws://test",
|
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 () => {
|
it("uses the native folder picker for project selection on native host", async () => {
|
||||||
const onWorkspaceScopeChange = vi.fn();
|
const onWorkspaceScopeChange = vi.fn();
|
||||||
const pickFolder = vi.fn().mockResolvedValue("/Users/test/native-project");
|
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,
|
getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null,
|
||||||
finishRunLocally: vi.fn((chatId: string) => {
|
|
||||||
runStartedAtByChatId.delete(chatId);
|
|
||||||
latestRunTurnIdByChatId.delete(chatId);
|
|
||||||
}),
|
|
||||||
hasUnsettledRun: () => false,
|
hasUnsettledRun: () => false,
|
||||||
getRunGeneration: (chatId: string) => runGenerationByChatId.get(chatId) ?? 0,
|
getRunGeneration: (chatId: string) => runGenerationByChatId.get(chatId) ?? 0,
|
||||||
canReconcileCanonicalCompletion,
|
canReconcileCanonicalCompletion,
|
||||||
@@ -852,48 +848,6 @@ describe("ThreadShell", () => {
|
|||||||
expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
|
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 () => {
|
it("highlights sent skill references without skill metadata", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
render(wrap(
|
render(wrap(
|
||||||
@@ -990,7 +944,6 @@ describe("ThreadShell", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||||
|
|
||||||
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
|
||||||
expect(onCreateChat).toHaveBeenCalledWith(null, "start for real");
|
|
||||||
expect(onNewChat).not.toHaveBeenCalled();
|
expect(onNewChat).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1271,7 +1224,7 @@ describe("ThreadShell", () => {
|
|||||||
|
|
||||||
const greeting = screen.getByRole("heading", { level: 1, name: HERO_GREETING_PATTERN });
|
const greeting = screen.getByRole("heading", { level: 1, name: HERO_GREETING_PATTERN });
|
||||||
expect(greeting).toHaveAttribute("data-testid", "hero-greeting");
|
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.getByPlaceholderText("Ask anything...")).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Write code" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Write code" })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Create a project plan" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Create a project plan" })).not.toBeInTheDocument();
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ function fakeClient() {
|
|||||||
return () => set!.delete(h);
|
return () => set!.delete(h);
|
||||||
},
|
},
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
finishRunLocally: vi.fn(),
|
|
||||||
newChat: vi.fn(),
|
newChat: vi.fn(),
|
||||||
forkChat: vi.fn(),
|
forkChat: vi.fn(),
|
||||||
attach: vi.fn(),
|
attach: vi.fn(),
|
||||||
@@ -2248,7 +2247,6 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(fake.client.sendMessage).toHaveBeenLastCalledWith("chat-stop", "/stop");
|
expect(fake.client.sendMessage).toHaveBeenLastCalledWith("chat-stop", "/stop");
|
||||||
expect(fake.client.finishRunLocally).toHaveBeenCalledWith("chat-stop");
|
|
||||||
expect(result.current.isStreaming).toBe(false);
|
expect(result.current.isStreaming).toBe(false);
|
||||||
expect(result.current.messages).toHaveLength(1);
|
expect(result.current.messages).toHaveLength(1);
|
||||||
expect(result.current.messages[0].content).toBe("long task");
|
expect(result.current.messages[0].content).toBe("long task");
|
||||||
|
|||||||
Reference in New Issue
Block a user