mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 22:08:38 +03:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2349a0bfc | ||
|
|
606ac56e8f | ||
|
|
e2563e2e74 | ||
|
|
ad6900e56c | ||
|
|
c33c188afb | ||
|
|
11fcd9cc5f |
@@ -146,7 +146,6 @@ Defaults:
|
|||||||
| Memory | `<workspace>/memory/` |
|
| Memory | `<workspace>/memory/` |
|
||||||
| Cron store | `<workspace>/cron/jobs.json` |
|
| Cron store | `<workspace>/cron/jobs.json` |
|
||||||
| WebUI/media/log runtime data | config directory subdirectories such as `webui/`, `media/`, and `logs/` |
|
| WebUI/media/log runtime data | config directory subdirectories such as `webui/`, `media/`, and `logs/` |
|
||||||
| Resource path aliases | `<config-dir>/resources/<view-id>/` (best-effort, derived state) |
|
|
||||||
|
|
||||||
The schema accepts both camelCase and snake_case keys, but saves config with camelCase aliases.
|
The schema accepts both camelCase and snake_case keys, but saves config with camelCase aliases.
|
||||||
|
|
||||||
@@ -168,10 +167,6 @@ and receive only capability-specific read access to built-in/agent skills and
|
|||||||
the exact agent history file. Keep those cross-root capabilities read-only and
|
the exact agent history file. Keep those cross-root capabilities read-only and
|
||||||
explicit; do not treat the entire agent workspace as an allowed root.
|
explicit; do not treat the entire agent workspace as an allowed root.
|
||||||
|
|
||||||
Resource path aliases are created outside the workspace and resolve to these
|
|
||||||
same canonical targets. Authorization must continue to follow the resolved
|
|
||||||
target; the alias root itself must never be treated as a blanket capability.
|
|
||||||
|
|
||||||
## Memory and Sessions
|
## Memory and Sessions
|
||||||
|
|
||||||
Session history is the near-term conversation replay. Memory is the longer-term workspace state.
|
Session history is the near-term conversation replay. Memory is the longer-term workspace state.
|
||||||
|
|||||||
@@ -55,35 +55,6 @@ When no separate project is selected, one directory normally serves both roles.
|
|||||||
Selecting a project changes the working context for that chat; it does not create
|
Selecting a project changes the working context for that chat; it does not create
|
||||||
a second agent or relocate the configured agent workspace.
|
a second agent or relocate the configured agent workspace.
|
||||||
|
|
||||||
### Resource Path Aliases
|
|
||||||
|
|
||||||
When an agent runtime starts, nanobot makes a best-effort filesystem view under
|
|
||||||
the active config directory:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<config-dir>/resources/<view-id>/
|
|
||||||
├── agent -> <agent-workspace>
|
|
||||||
├── media -> <config-dir>/media
|
|
||||||
└── package -> <installed-nanobot-package>
|
|
||||||
```
|
|
||||||
|
|
||||||
`<view-id>` is deterministic for the config, agent workspace, and installed
|
|
||||||
package paths. Separate workspaces or Python environments therefore receive
|
|
||||||
separate views instead of competing for a mutable `current` link. Project files
|
|
||||||
are not linked into this view; relative paths continue to resolve from the
|
|
||||||
effective project workspace.
|
|
||||||
|
|
||||||
These links are convenient names, not a new permission boundary. Restricted
|
|
||||||
file access still checks the resolved target, and a shell sandbox may not expose
|
|
||||||
the aliases at all. Full-access prompts use the agent alias for profile, memory,
|
|
||||||
history, and custom-skill paths; restricted prompts expose only alias subtrees
|
|
||||||
that are already readable and retain canonical exact-file paths where required.
|
|
||||||
Nanobot keeps canonical paths in config and runtime state, continues to accept
|
|
||||||
real paths, and falls back to them when links are unavailable. Creating the view
|
|
||||||
never blocks startup and never replaces an existing unowned file or directory.
|
|
||||||
The `resources/` tree is derived state, so backup and indexing tools should skip
|
|
||||||
it or preserve its links instead of following them into their targets.
|
|
||||||
|
|
||||||
## Config Format
|
## Config Format
|
||||||
|
|
||||||
`config.json` accepts both camelCase and snake_case keys. The docs use camelCase because nanobot writes config back to disk with camelCase aliases, for example `apiKey`, `modelPresets`, `intervalS`, and `maxToolResultChars`.
|
`config.json` accepts both camelCase and snake_case keys. The docs use camelCase because nanobot writes config back to disk with camelCase aliases, for example `apiKey`, `modelPresets`, `intervalS`, and `maxToolResultChars`.
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ If you need a known-good snippet instead of diagnosis, use [`provider-cookbook.m
|
|||||||
| Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. |
|
| Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. |
|
||||||
| OAuth provider fails | Run the matching login command: `openai-codex`, `xai-grok`, or `github-copilot`, normally with `--set-main`. |
|
| OAuth provider fails | Run the matching login command: `openai-codex`, `xai-grok`, or `github-copilot`, normally with `--set-main`. |
|
||||||
| Codex OAuth needs a proxy | Set `providers.openaiCodex.proxy` before running the login command. The proxy applies to login, token refresh, and Codex API requests. |
|
| Codex OAuth needs a proxy | Set `providers.openaiCodex.proxy` before running the login command. The proxy applies to login, token refresh, and Codex API requests. |
|
||||||
| Codex login runs on a remote/headless machine | Open the printed URL in a local browser, then paste the final `http://localhost:1455/auth/callback?...` URL back into the terminal. |
|
| Codex login runs on a remote/headless machine | In the WebUI, open ChatGPT in your local browser; when the localhost callback page cannot load, copy the full `http://localhost:1455/auth/callback?...` URL from the address bar and paste it into the WebUI dialog. From the CLI, open the printed URL locally and paste the same callback URL back into the terminal. |
|
||||||
| Codex login runs in Docker | Start the container with `docker run -it` so the OAuth flow has an interactive terminal. |
|
| Codex login runs in Docker | Start the container with `docker run -it` so the OAuth flow has an interactive terminal. |
|
||||||
| Codex says a model is not supported with a ChatGPT account | Use provider `openai_codex` with a Codex model such as `openai-codex/gpt-5.6-sol`. Do not use the direct-API `openai/...` prefix with Codex OAuth. |
|
| Codex says a model is not supported with a ChatGPT account | Use provider `openai_codex` with a Codex model such as `openai-codex/gpt-5.6-sol`. Do not use the direct-API `openai/...` prefix with Codex OAuth. |
|
||||||
| Config says `providers.openai_codex` conflicts with the built-in provider | Under `providers`, keep only the canonical `openaiCodex` settings key and remove a duplicate `openai_codex` key. A model preset's `provider` value remains `openai_codex`. |
|
| Config says `providers.openai_codex` conflicts with the built-in provider | Under `providers`, keep only the canonical `openaiCodex` settings key and remove a duplicate `openai_codex` key. A model preset's `provider` value remains `openai_codex`. |
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""Context builder for assembling agent prompts."""
|
"""Context builder for assembling agent prompts."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import platform
|
import platform
|
||||||
@@ -9,17 +7,12 @@ from pathlib import Path
|
|||||||
from typing import Any, Mapping, Sequence, cast
|
from typing import Any, Mapping, Sequence, cast
|
||||||
|
|
||||||
from nanobot.agent.memory import MemoryStore
|
from nanobot.agent.memory import MemoryStore
|
||||||
from nanobot.agent.skills import (
|
from nanobot.agent.skills import SkillsLoader
|
||||||
ResourceViewMode,
|
|
||||||
SkillsLoader,
|
|
||||||
build_resource_aliases_section,
|
|
||||||
)
|
|
||||||
from nanobot.agent.tools import image_generation as image_generation_tools
|
from nanobot.agent.tools import image_generation as image_generation_tools
|
||||||
from nanobot.agent.tools import mcp as mcp_tools
|
from nanobot.agent.tools import mcp as mcp_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 InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.resource_links import ResourceView
|
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
RUNTIME_CONTEXT_END,
|
RUNTIME_CONTEXT_END,
|
||||||
RUNTIME_CONTEXT_MESSAGE_META,
|
RUNTIME_CONTEXT_MESSAGE_META,
|
||||||
@@ -68,23 +61,11 @@ class ContextBuilder:
|
|||||||
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
||||||
_RUNTIME_CONTEXT_END = RUNTIME_CONTEXT_END
|
_RUNTIME_CONTEXT_END = RUNTIME_CONTEXT_END
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
|
||||||
self,
|
|
||||||
workspace: Path,
|
|
||||||
timezone: str | None = None,
|
|
||||||
disabled_skills: list[str] | None = None,
|
|
||||||
*,
|
|
||||||
resource_view: ResourceView | None = None,
|
|
||||||
):
|
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.timezone = timezone
|
self.timezone = timezone
|
||||||
self.resource_view = resource_view
|
self.memory = MemoryStore(workspace)
|
||||||
self.memory = MemoryStore(workspace, resource_view=resource_view)
|
self.skills = SkillsLoader(workspace, disabled_skills=set(disabled_skills) if disabled_skills else None)
|
||||||
self.skills = SkillsLoader(
|
|
||||||
workspace,
|
|
||||||
disabled_skills=set(disabled_skills) if disabled_skills else None,
|
|
||||||
resource_view=resource_view,
|
|
||||||
)
|
|
||||||
|
|
||||||
def build_system_prompt(
|
def build_system_prompt(
|
||||||
self,
|
self,
|
||||||
@@ -96,24 +77,10 @@ class ContextBuilder:
|
|||||||
include_memory_recent_history: bool = True,
|
include_memory_recent_history: bool = True,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
unified_session: bool = False,
|
unified_session: bool = False,
|
||||||
resource_view_mode: ResourceViewMode | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
|
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
|
||||||
root = workspace or self.workspace
|
root = workspace or self.workspace
|
||||||
parts = [
|
parts = [self._get_identity(channel=channel, workspace=root)]
|
||||||
self._get_identity(
|
|
||||||
channel=channel,
|
|
||||||
workspace=root,
|
|
||||||
resource_view_mode=resource_view_mode,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
resource_aliases = build_resource_aliases_section(
|
|
||||||
self.resource_view,
|
|
||||||
resource_view_mode,
|
|
||||||
)
|
|
||||||
if resource_aliases:
|
|
||||||
parts.append(resource_aliases)
|
|
||||||
|
|
||||||
bootstrap = self._load_bootstrap_files(root)
|
bootstrap = self._load_bootstrap_files(root)
|
||||||
if bootstrap:
|
if bootstrap:
|
||||||
@@ -159,24 +126,11 @@ class ContextBuilder:
|
|||||||
|
|
||||||
return "\n\n---\n\n".join(parts)
|
return "\n\n---\n\n".join(parts)
|
||||||
|
|
||||||
def _get_identity(
|
def _get_identity(self, channel: str | None = None, workspace: Path | None = None) -> str:
|
||||||
self,
|
|
||||||
channel: str | None = None,
|
|
||||||
workspace: Path | None = None,
|
|
||||||
*,
|
|
||||||
resource_view_mode: ResourceViewMode | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Get the core identity section."""
|
"""Get the core identity section."""
|
||||||
root = workspace or self.workspace
|
root = workspace or self.workspace
|
||||||
workspace_path = str(root.expanduser().resolve())
|
workspace_path = str(root.expanduser().resolve())
|
||||||
agent_workspace_path = str(self.workspace.expanduser().resolve())
|
agent_workspace_path = str(self.workspace.expanduser().resolve())
|
||||||
agent_resource_path = agent_workspace_path
|
|
||||||
if (
|
|
||||||
resource_view_mode == "full"
|
|
||||||
and self.resource_view is not None
|
|
||||||
and self.resource_view.agent is not None
|
|
||||||
):
|
|
||||||
agent_resource_path = str(self.resource_view.agent)
|
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
||||||
|
|
||||||
@@ -184,7 +138,6 @@ class ContextBuilder:
|
|||||||
"agent/identity.md",
|
"agent/identity.md",
|
||||||
workspace_path=workspace_path,
|
workspace_path=workspace_path,
|
||||||
agent_workspace_path=agent_workspace_path,
|
agent_workspace_path=agent_workspace_path,
|
||||||
agent_resource_path=agent_resource_path,
|
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
platform_policy=render_template("agent/platform_policy.md", system=system),
|
platform_policy=render_template("agent/platform_policy.md", system=system),
|
||||||
channel=channel or "",
|
channel=channel or "",
|
||||||
@@ -264,7 +217,6 @@ class ContextBuilder:
|
|||||||
include_memory_recent_history: bool = True,
|
include_memory_recent_history: bool = True,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
unified_session: bool = False,
|
unified_session: bool = False,
|
||||||
resource_view_mode: ResourceViewMode | None = None,
|
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Build the complete message list for an LLM call."""
|
"""Build the complete message list for an LLM call."""
|
||||||
root = workspace or self.workspace
|
root = workspace or self.workspace
|
||||||
@@ -287,7 +239,6 @@ class ContextBuilder:
|
|||||||
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,
|
||||||
resource_view_mode=resource_view_mode,
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
*history,
|
*history,
|
||||||
|
|||||||
+4
-27
@@ -93,7 +93,6 @@ from nanobot.utils.runtime import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.agent.skills import ResourceViewMode
|
|
||||||
from nanobot.agent.tools.mcp import MCPConnection
|
from nanobot.agent.tools.mcp import MCPConnection
|
||||||
from nanobot.config.schema import (
|
from nanobot.config.schema import (
|
||||||
ChannelsConfig,
|
ChannelsConfig,
|
||||||
@@ -103,8 +102,6 @@ if TYPE_CHECKING:
|
|||||||
ToolsConfig,
|
ToolsConfig,
|
||||||
)
|
)
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.resource_links import ResourceView
|
|
||||||
from nanobot.security.workspace_access import WorkspaceScope
|
|
||||||
from nanobot.triggers.local_store import LocalTriggerStore
|
from nanobot.triggers.local_store import LocalTriggerStore
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
_T = TypeVar("_T")
|
||||||
@@ -288,7 +285,6 @@ class AgentLoop:
|
|||||||
restart_mode: str = "auto",
|
restart_mode: str = "auto",
|
||||||
local_trigger_store: LocalTriggerStore | None = None,
|
local_trigger_store: LocalTriggerStore | None = None,
|
||||||
idle_compact_check_interval_seconds: int = 0,
|
idle_compact_check_interval_seconds: int = 0,
|
||||||
resource_view: ResourceView | None = None,
|
|
||||||
):
|
):
|
||||||
from nanobot.config.schema import ToolsConfig
|
from nanobot.config.schema import ToolsConfig
|
||||||
|
|
||||||
@@ -360,7 +356,6 @@ class AgentLoop:
|
|||||||
self.cron_service = cron_service
|
self.cron_service = cron_service
|
||||||
self.local_trigger_store = local_trigger_store
|
self.local_trigger_store = local_trigger_store
|
||||||
self.restrict_to_workspace = restrict_to_workspace
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
self.resource_view = resource_view
|
|
||||||
self.workspace_scopes = WorkspaceScopeResolver(
|
self.workspace_scopes = WorkspaceScopeResolver(
|
||||||
default_workspace=workspace,
|
default_workspace=workspace,
|
||||||
default_restrict_to_workspace=restrict_to_workspace,
|
default_restrict_to_workspace=restrict_to_workspace,
|
||||||
@@ -370,12 +365,7 @@ class AgentLoop:
|
|||||||
self._extra_hooks: list[AgentHook] = hooks or []
|
self._extra_hooks: list[AgentHook] = hooks or []
|
||||||
self._hook_factories: list[AgentTurnHookFactory] = hook_factories or []
|
self._hook_factories: list[AgentTurnHookFactory] = hook_factories or []
|
||||||
|
|
||||||
self.context = ContextBuilder(
|
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
||||||
workspace,
|
|
||||||
timezone=timezone,
|
|
||||||
disabled_skills=disabled_skills,
|
|
||||||
resource_view=resource_view,
|
|
||||||
)
|
|
||||||
self.sessions = session_manager or SessionManager(workspace)
|
self.sessions = session_manager or SessionManager(workspace)
|
||||||
self.sessions.set_file_cap_archiver(self.context.memory.raw_archive)
|
self.sessions.set_file_cap_archiver(self.context.memory.raw_archive)
|
||||||
self.tools = ToolRegistry()
|
self.tools = ToolRegistry()
|
||||||
@@ -395,7 +385,6 @@ class AgentLoop:
|
|||||||
max_concurrent_subagents=max_concurrent_subagents,
|
max_concurrent_subagents=max_concurrent_subagents,
|
||||||
fail_on_tool_error=fail_on_tool_error,
|
fail_on_tool_error=fail_on_tool_error,
|
||||||
llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk),
|
llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk),
|
||||||
resource_view=resource_view,
|
|
||||||
)
|
)
|
||||||
self._unified_session = unified_session
|
self._unified_session = unified_session
|
||||||
self._running = False
|
self._running = False
|
||||||
@@ -727,20 +716,8 @@ class AgentLoop:
|
|||||||
include_memory_recent_history=not ctx.ephemeral,
|
include_memory_recent_history=not ctx.ephemeral,
|
||||||
session_key=ctx.session.key,
|
session_key=ctx.session.key,
|
||||||
unified_session=self._unified_session,
|
unified_session=self._unified_session,
|
||||||
resource_view_mode=self._resource_view_mode_for_scope(scope),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _resource_view_mode_for_scope(
|
|
||||||
self,
|
|
||||||
scope: WorkspaceScope,
|
|
||||||
) -> ResourceViewMode | None:
|
|
||||||
"""Return the alias visibility supported by this turn's tool boundary."""
|
|
||||||
if self.resource_view is None:
|
|
||||||
return None
|
|
||||||
if scope.restrict_to_workspace or bool(self.exec_config.sandbox):
|
|
||||||
return "restricted"
|
|
||||||
return "full"
|
|
||||||
|
|
||||||
def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext:
|
def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext:
|
||||||
assert ctx.session is not None
|
assert ctx.session is not None
|
||||||
scope = self.workspace_scopes.for_turn(
|
scope = self.workspace_scopes.for_turn(
|
||||||
@@ -1126,7 +1103,7 @@ class AgentLoop:
|
|||||||
return
|
return
|
||||||
self._next_idle_compact_check_at = now + self._idle_compact_check_interval_s
|
self._next_idle_compact_check_at = now + self._idle_compact_check_interval_s
|
||||||
self.auto_compact.check_expired(
|
self.auto_compact.check_expired(
|
||||||
self._schedule_background,
|
self.schedule_background,
|
||||||
self.runtime_for_session,
|
self.runtime_for_session,
|
||||||
active_session_keys=self._pending_queues.keys(),
|
active_session_keys=self._pending_queues.keys(),
|
||||||
)
|
)
|
||||||
@@ -1359,7 +1336,7 @@ class AgentLoop:
|
|||||||
if errors:
|
if errors:
|
||||||
raise BaseExceptionGroup("failed to close agent resources", errors)
|
raise BaseExceptionGroup("failed to close agent resources", errors)
|
||||||
|
|
||||||
def _schedule_background(self, coro: Coroutine[Any, Any, Any]) -> None:
|
def schedule_background(self, coro: Coroutine[Any, Any, Any]) -> None:
|
||||||
"""Schedule a coroutine as a tracked background task (drained on shutdown)."""
|
"""Schedule a coroutine as a tracked background task (drained on shutdown)."""
|
||||||
task = asyncio.create_task(coro)
|
task = asyncio.create_task(coro)
|
||||||
self._background_tasks.add(task)
|
self._background_tasks.add(task)
|
||||||
@@ -1775,7 +1752,7 @@ class AgentLoop:
|
|||||||
session.enforce_file_cap(
|
session.enforce_file_cap(
|
||||||
on_archive=partial(self.context.memory.raw_archive, session_key=ctx.session_key)
|
on_archive=partial(self.context.memory.raw_archive, session_key=ctx.session_key)
|
||||||
)
|
)
|
||||||
self._schedule_background(
|
self.schedule_background(
|
||||||
self.consolidator.maybe_consolidate_by_tokens(
|
self.consolidator.maybe_consolidate_by_tokens(
|
||||||
session,
|
session,
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
|
|||||||
+28
-55
@@ -20,7 +20,6 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator, cast
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.resource_links import ResourceView
|
|
||||||
from nanobot.runtime_context import public_history_messages
|
from nanobot.runtime_context import public_history_messages
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.utils.gitstore import GitStore
|
from nanobot.utils.gitstore import GitStore
|
||||||
@@ -91,16 +90,9 @@ class MemoryStore:
|
|||||||
r"^\[\d{4}-\d{2}-\d{2}[^\]]*\]\s+[A-Z][A-Z0-9_]*(?:\s+\[tools:\s*[^\]]+\])?:"
|
r"^\[\d{4}-\d{2}-\d{2}[^\]]*\]\s+[A-Z][A-Z0-9_]*(?:\s+\[tools:\s*[^\]]+\])?:"
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, workspace: Path, max_history_entries: int = _DEFAULT_MAX_HISTORY):
|
||||||
self,
|
|
||||||
workspace: Path,
|
|
||||||
max_history_entries: int = _DEFAULT_MAX_HISTORY,
|
|
||||||
*,
|
|
||||||
resource_view: ResourceView | None = None,
|
|
||||||
):
|
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.max_history_entries = max_history_entries
|
self.max_history_entries = max_history_entries
|
||||||
self.resource_view = resource_view
|
|
||||||
self.memory_dir = ensure_dir(workspace / "memory")
|
self.memory_dir = ensure_dir(workspace / "memory")
|
||||||
self.memory_file = self.memory_dir / "MEMORY.md"
|
self.memory_file = self.memory_dir / "MEMORY.md"
|
||||||
self.history_file = self.memory_dir / "history.jsonl"
|
self.history_file = self.memory_dir / "history.jsonl"
|
||||||
@@ -562,18 +554,13 @@ class MemoryStore:
|
|||||||
return has_workspace_prompt_override(self.dream_prompt_file)
|
return has_workspace_prompt_override(self.dream_prompt_file)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def default_dream_prompt(resource_view: ResourceView | None = None) -> str:
|
def default_dream_prompt() -> str:
|
||||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||||
|
|
||||||
skill_creator_path = BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md"
|
|
||||||
if resource_view is not None and resource_view.package is not None:
|
|
||||||
skill_creator_path = (
|
|
||||||
resource_view.package / "skills" / "skill-creator" / "SKILL.md"
|
|
||||||
)
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"agent/dream.md",
|
"agent/dream.md",
|
||||||
strip=True,
|
strip=True,
|
||||||
skill_creator_path=str(skill_creator_path),
|
skill_creator_path=str(BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _dream_template(self) -> str:
|
def _dream_template(self) -> str:
|
||||||
@@ -590,7 +577,7 @@ class MemoryStore:
|
|||||||
WORKSPACE_PROMPT_MAX_CHARS, original_chars,
|
WORKSPACE_PROMPT_MAX_CHARS, original_chars,
|
||||||
)
|
)
|
||||||
return text
|
return text
|
||||||
return self.default_dream_prompt(self.resource_view)
|
return self.default_dream_prompt()
|
||||||
|
|
||||||
def build_dream_prompt(self, *, max_entries: int = 20) -> tuple[str, int] | None:
|
def build_dream_prompt(self, *, max_entries: int = 20) -> tuple[str, int] | None:
|
||||||
"""Build the Dream prompt with unprocessed history context.
|
"""Build the Dream prompt with unprocessed history context.
|
||||||
@@ -820,7 +807,7 @@ _HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history
|
|||||||
|
|
||||||
|
|
||||||
class Consolidator:
|
class Consolidator:
|
||||||
"""Lightweight consolidation: summarizes evicted messages into history.jsonl."""
|
"""Summarize compacted messages into history.jsonl."""
|
||||||
|
|
||||||
_MAX_CONSOLIDATION_ROUNDS = 5
|
_MAX_CONSOLIDATION_ROUNDS = 5
|
||||||
|
|
||||||
@@ -1011,14 +998,9 @@ class Consolidator:
|
|||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
summary_messages: list[dict[str, Any]] | None = None,
|
summary_messages: list[dict[str, Any]] | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Summarize messages via LLM and append to history.jsonl.
|
"""Summarize messages and append the result to history.jsonl.
|
||||||
|
|
||||||
``messages`` are the messages being archived (removed from the live
|
``summary_messages`` adds context but is excluded from raw fallback.
|
||||||
session); they are what gets raw-dumped if the LLM call fails.
|
|
||||||
``summary_messages``, when given, lets callers include retained
|
|
||||||
messages in the summary without archiving them.
|
|
||||||
|
|
||||||
Returns the summary text on success, None if nothing to archive.
|
|
||||||
"""
|
"""
|
||||||
if not messages:
|
if not messages:
|
||||||
return None
|
return None
|
||||||
@@ -1179,13 +1161,7 @@ class Consolidator:
|
|||||||
runtime: LLMRuntime,
|
runtime: LLMRuntime,
|
||||||
max_suffix: int = 8,
|
max_suffix: int = 8,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Hard-truncate an idle session under the consolidation lock.
|
"""Archive an idle prefix and hide it from replay without deleting it."""
|
||||||
|
|
||||||
Used by AutoCompact so all session mutation goes through a single
|
|
||||||
lock-protected path. Returns the summary text on success, ``None``
|
|
||||||
if the LLM failed (raw_archive fallback), or ``""`` if there was
|
|
||||||
nothing to archive.
|
|
||||||
"""
|
|
||||||
lock = self.get_lock(session_key)
|
lock = self.get_lock(session_key)
|
||||||
async with lock:
|
async with lock:
|
||||||
self.sessions.invalidate(session_key)
|
self.sessions.invalidate(session_key)
|
||||||
@@ -1205,24 +1181,21 @@ class Consolidator:
|
|||||||
last_consolidated=0,
|
last_consolidated=0,
|
||||||
)
|
)
|
||||||
result = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
|
result = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
|
||||||
messages_to_keep = probe.messages
|
visible_suffix = probe.messages
|
||||||
messages_to_remove = result.dropped[result.already_consolidated_count:]
|
messages_to_remove = result.dropped
|
||||||
|
|
||||||
if not messages_to_remove and not messages_to_keep:
|
if not messages_to_remove:
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
last_active = session.updated_at
|
last_active = session.updated_at
|
||||||
summary: str | None = ""
|
# The visible suffix informs the summary but stays out of raw fallback.
|
||||||
if messages_to_remove:
|
summary = await self.archive(
|
||||||
# Summarize the retained suffix too, but only remove/raw-dump
|
messages_to_remove,
|
||||||
# the messages that are no longer kept in the live session.
|
runtime=runtime,
|
||||||
summary = await self.archive(
|
session_key=session_key,
|
||||||
messages_to_remove,
|
summary_messages=messages_to_summarize,
|
||||||
runtime=runtime,
|
)
|
||||||
session_key=session_key,
|
|
||||||
summary_messages=messages_to_summarize,
|
|
||||||
)
|
|
||||||
|
|
||||||
if summary and summary != "(nothing)":
|
if summary and summary != "(nothing)":
|
||||||
session.metadata["_last_summary"] = {
|
session.metadata["_last_summary"] = {
|
||||||
@@ -1230,17 +1203,17 @@ class Consolidator:
|
|||||||
"last_active": last_active.isoformat(),
|
"last_active": last_active.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
session.messages = messages_to_keep
|
# Preserve history and advance only the replay boundary.
|
||||||
session.last_consolidated = 0
|
session.last_consolidated = len(session.messages) - len(visible_suffix)
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
if messages_to_remove:
|
logger.info(
|
||||||
logger.info(
|
"Idle-session compact for {}: archived={}, visible={}, retained={}, summary={}",
|
||||||
"Idle-session compact for {}: archived={}, kept={}, summary={}",
|
session_key,
|
||||||
session_key,
|
len(messages_to_remove),
|
||||||
len(messages_to_remove),
|
len(visible_suffix),
|
||||||
len(messages_to_keep),
|
len(session.messages),
|
||||||
bool(summary),
|
bool(summary),
|
||||||
)
|
)
|
||||||
|
|
||||||
return summary
|
return summary
|
||||||
|
|||||||
+6
-75
@@ -1,24 +1,17 @@
|
|||||||
"""Skills loader for agent capabilities."""
|
"""Skills loader for agent capabilities."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal, TypeAlias, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from nanobot.resource_links import ResourceView
|
|
||||||
from nanobot.utils.prompt_templates import render_template
|
|
||||||
|
|
||||||
# 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"
|
||||||
|
|
||||||
ResourceViewMode: TypeAlias = Literal["full", "restricted"]
|
|
||||||
|
|
||||||
# Opening ---, YAML body (group 1), closing --- on its own line; supports CRLF.
|
# Opening ---, YAML body (group 1), closing --- on its own line; supports CRLF.
|
||||||
_STRIP_SKILL_FRONTMATTER = re.compile(
|
_STRIP_SKILL_FRONTMATTER = re.compile(
|
||||||
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
|
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
|
||||||
@@ -27,39 +20,6 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
|
|||||||
_SKILL_REFERENCE = re.compile(r"(?<![\w$])\$([A-Za-z0-9_-]+)")
|
_SKILL_REFERENCE = re.compile(r"(?<![\w$])\$([A-Za-z0-9_-]+)")
|
||||||
|
|
||||||
|
|
||||||
def build_resource_aliases_section(
|
|
||||||
resource_view: ResourceView | None,
|
|
||||||
mode: ResourceViewMode | None,
|
|
||||||
) -> str:
|
|
||||||
"""Render healthy resource aliases without changing their access policy."""
|
|
||||||
if resource_view is None or mode is None:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
aliases: list[tuple[str, str]] = []
|
|
||||||
if mode == "full":
|
|
||||||
if resource_view.agent is not None:
|
|
||||||
aliases.append(("Agent workspace", str(resource_view.agent)))
|
|
||||||
if resource_view.media is not None:
|
|
||||||
aliases.append(("Media", str(resource_view.media)))
|
|
||||||
if resource_view.package is not None:
|
|
||||||
aliases.append(("Nanobot package", str(resource_view.package)))
|
|
||||||
else:
|
|
||||||
if resource_view.agent is not None:
|
|
||||||
aliases.append(("Custom skills", str(resource_view.agent / "skills")))
|
|
||||||
if resource_view.media is not None:
|
|
||||||
aliases.append(("Media", str(resource_view.media)))
|
|
||||||
if resource_view.package is not None:
|
|
||||||
aliases.append(("Built-in skills", str(resource_view.package / "skills")))
|
|
||||||
|
|
||||||
if not aliases:
|
|
||||||
return ""
|
|
||||||
return render_template(
|
|
||||||
"agent/resource_aliases.md",
|
|
||||||
strip=True,
|
|
||||||
aliases=aliases,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SkillsLoader:
|
class SkillsLoader:
|
||||||
"""
|
"""
|
||||||
Loader for agent skills.
|
Loader for agent skills.
|
||||||
@@ -68,19 +28,11 @@ class SkillsLoader:
|
|||||||
specific tools or perform certain tasks.
|
specific tools or perform certain tasks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, workspace: Path, builtin_skills_dir: Path | None = None, disabled_skills: set[str] | None = None):
|
||||||
self,
|
|
||||||
workspace: Path,
|
|
||||||
builtin_skills_dir: Path | None = None,
|
|
||||||
disabled_skills: set[str] | None = None,
|
|
||||||
*,
|
|
||||||
resource_view: ResourceView | None = None,
|
|
||||||
):
|
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
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.resource_view = resource_view
|
|
||||||
|
|
||||||
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():
|
||||||
@@ -190,32 +142,12 @@ class SkillsLoader:
|
|||||||
if not all_skills:
|
if not all_skills:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
workspace_alias_root = (
|
|
||||||
self.resource_view.agent / "skills"
|
|
||||||
if self.resource_view is not None and self.resource_view.agent is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
builtin_alias_root = (
|
|
||||||
self.resource_view.package / "skills"
|
|
||||||
if self.resource_view is not None and self.resource_view.package is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
sections: list[str] = []
|
sections: list[str] = []
|
||||||
groups = (
|
groups = (
|
||||||
(
|
("Workspace skills", "workspace", self.workspace_skills),
|
||||||
"Workspace skills",
|
("Built-in skills", "builtin", self.builtin_skills),
|
||||||
"workspace",
|
|
||||||
self.workspace_skills,
|
|
||||||
workspace_alias_root,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Built-in skills",
|
|
||||||
"builtin",
|
|
||||||
self.builtin_skills,
|
|
||||||
builtin_alias_root,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
for label, source, root, alias_root in groups:
|
for label, source, root in groups:
|
||||||
entries = [
|
entries = [
|
||||||
entry
|
entry
|
||||||
for entry in all_skills
|
for entry in all_skills
|
||||||
@@ -224,8 +156,7 @@ class SkillsLoader:
|
|||||||
if not entries:
|
if not entries:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
display_root = alias_root or root.expanduser().resolve()
|
lines = [f"### {label} (`{root.expanduser().resolve()}`)"]
|
||||||
lines = [f"### {label} (`{display_root}`)"]
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
skill_name = entry["name"]
|
skill_name = entry["name"]
|
||||||
meta = self._get_skill_meta(skill_name)
|
meta = self._get_skill_meta(skill_name)
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""Subagent manager for background task execution."""
|
"""Subagent manager for background task execution."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
@@ -15,11 +13,6 @@ from loguru import logger
|
|||||||
|
|
||||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||||
from nanobot.agent.runner import AgentRunner, AgentRunResult, AgentRunSpec
|
from nanobot.agent.runner import AgentRunner, AgentRunResult, AgentRunSpec
|
||||||
from nanobot.agent.skills import (
|
|
||||||
ResourceViewMode,
|
|
||||||
SkillsLoader,
|
|
||||||
build_resource_aliases_section,
|
|
||||||
)
|
|
||||||
from nanobot.agent.tools.base import ToolResult
|
from nanobot.agent.tools.base import ToolResult
|
||||||
from nanobot.agent.tools.context import (
|
from nanobot.agent.tools.context import (
|
||||||
RequestContext,
|
RequestContext,
|
||||||
@@ -35,7 +28,6 @@ from nanobot.bus.events import InboundMessage
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.schema import AgentDefaults, ToolsConfig
|
from nanobot.config.schema import AgentDefaults, ToolsConfig
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.resource_links import ResourceView
|
|
||||||
from nanobot.security.workspace_access import (
|
from nanobot.security.workspace_access import (
|
||||||
WorkspaceScope,
|
WorkspaceScope,
|
||||||
bind_workspace_scope,
|
bind_workspace_scope,
|
||||||
@@ -111,7 +103,6 @@ class SubagentManager:
|
|||||||
max_concurrent_subagents: int | None = None,
|
max_concurrent_subagents: int | None = None,
|
||||||
fail_on_tool_error: bool | None = None,
|
fail_on_tool_error: bool | None = None,
|
||||||
llm_wall_timeout_for_session: Callable[[str | None], float | None] | None = None,
|
llm_wall_timeout_for_session: Callable[[str | None], float | None] | None = None,
|
||||||
resource_view: ResourceView | None = None,
|
|
||||||
):
|
):
|
||||||
if workspace is None:
|
if workspace is None:
|
||||||
raise TypeError("SubagentManager.__init__() missing required argument: 'workspace'")
|
raise TypeError("SubagentManager.__init__() missing required argument: 'workspace'")
|
||||||
@@ -162,7 +153,6 @@ class SubagentManager:
|
|||||||
self.runner = AgentRunner()
|
self.runner = AgentRunner()
|
||||||
self._exec_session_manager = ExecSessionManager()
|
self._exec_session_manager = ExecSessionManager()
|
||||||
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
|
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
|
||||||
self.resource_view = resource_view
|
|
||||||
self._running_tasks: dict[str, asyncio.Task[str]] = {}
|
self._running_tasks: dict[str, asyncio.Task[str]] = {}
|
||||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
||||||
@@ -386,20 +376,7 @@ class SubagentManager:
|
|||||||
cfg.restrict_to_workspace = workspace_scope.restrict_to_workspace
|
cfg.restrict_to_workspace = workspace_scope.restrict_to_workspace
|
||||||
# Construct from the agent workspace; the bound scope below supplies the project cwd.
|
# Construct from the agent workspace; the bound scope below supplies the project cwd.
|
||||||
tools = self._build_tools(tools_config=cfg)
|
tools = self._build_tools(tools_config=cfg)
|
||||||
scope_restricted = (
|
system_prompt = self._build_subagent_prompt(workspace=root)
|
||||||
workspace_scope.restrict_to_workspace
|
|
||||||
if workspace_scope is not None
|
|
||||||
else self.restrict_to_workspace
|
|
||||||
)
|
|
||||||
resource_view_mode: ResourceViewMode = (
|
|
||||||
"restricted"
|
|
||||||
if scope_restricted or bool(self.tools_config.exec.sandbox)
|
|
||||||
else "full"
|
|
||||||
)
|
|
||||||
system_prompt = self._build_subagent_prompt(
|
|
||||||
workspace=root,
|
|
||||||
resource_view_mode=resource_view_mode,
|
|
||||||
)
|
|
||||||
messages: list[dict[str, Any]] = [
|
messages: list[dict[str, Any]] = [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": task},
|
{"role": "user", "content": task},
|
||||||
@@ -549,37 +526,22 @@ class SubagentManager:
|
|||||||
lines.append(f"- {result.error}")
|
lines.append(f"- {result.error}")
|
||||||
return "\n".join(lines) or (result.error or "Error: subagent execution failed.")
|
return "\n".join(lines) or (result.error or "Error: subagent execution failed.")
|
||||||
|
|
||||||
def _build_subagent_prompt(
|
def _build_subagent_prompt(self, workspace: Path | None = None) -> str:
|
||||||
self,
|
|
||||||
workspace: Path | None = None,
|
|
||||||
*,
|
|
||||||
resource_view_mode: ResourceViewMode | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Build a focused system prompt for the subagent."""
|
"""Build a focused system prompt for the subagent."""
|
||||||
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
|
||||||
agent_workspace = self.workspace.expanduser().resolve()
|
agent_workspace = self.workspace.expanduser().resolve()
|
||||||
project_workspace = workspace.expanduser().resolve() if workspace else agent_workspace
|
project_workspace = workspace.expanduser().resolve() if workspace else agent_workspace
|
||||||
history_root = agent_workspace
|
|
||||||
if (
|
|
||||||
resource_view_mode == "full"
|
|
||||||
and self.resource_view is not None
|
|
||||||
and self.resource_view.agent is not None
|
|
||||||
):
|
|
||||||
history_root = self.resource_view.agent
|
|
||||||
skills_summary = SkillsLoader(
|
skills_summary = SkillsLoader(
|
||||||
self.workspace,
|
self.workspace,
|
||||||
disabled_skills=self.disabled_skills,
|
disabled_skills=self.disabled_skills,
|
||||||
resource_view=self.resource_view,
|
|
||||||
).build_skills_summary()
|
).build_skills_summary()
|
||||||
return render_template(
|
return render_template(
|
||||||
"agent/subagent_system.md",
|
"agent/subagent_system.md",
|
||||||
workspace=str(project_workspace),
|
workspace=str(project_workspace),
|
||||||
agent_workspace=str(agent_workspace),
|
agent_workspace=str(agent_workspace),
|
||||||
history_log=str(history_root / "memory" / "history.jsonl"),
|
history_log=str(agent_workspace / "memory" / "history.jsonl"),
|
||||||
skills_summary=skills_summary or "",
|
skills_summary=skills_summary or "",
|
||||||
resource_aliases=build_resource_aliases_section(
|
|
||||||
self.resource_view,
|
|
||||||
resource_view_mode,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def cancel_by_session(self, session_key: str) -> int:
|
async def cancel_by_session(self, session_key: str) -> int:
|
||||||
|
|||||||
+13
-14
@@ -12,7 +12,7 @@ from contextlib import AsyncExitStack, suppress
|
|||||||
from typing import TYPE_CHECKING, Any, Mapping, Protocol, cast
|
from typing import TYPE_CHECKING, Any, Mapping, Protocol, cast
|
||||||
from weakref import WeakKeyDictionary
|
from weakref import WeakKeyDictionary
|
||||||
|
|
||||||
import httpx
|
import httpx2 as httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
from nanobot.agent.tools.base import Tool, ToolResult
|
||||||
@@ -25,9 +25,9 @@ from nanobot.bus.events import (
|
|||||||
)
|
)
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.security.network import (
|
from nanobot.security.network import (
|
||||||
PinnedDNSAsyncTransport,
|
Httpx2PinnedDNSAsyncTransport,
|
||||||
env_proxy_applies_to_url,
|
env_proxy_applies_to_url,
|
||||||
httpx_env_proxy_mounts,
|
httpx2_env_proxy_mounts,
|
||||||
resolve_url_target,
|
resolve_url_target,
|
||||||
validate_url_target,
|
validate_url_target,
|
||||||
)
|
)
|
||||||
@@ -194,7 +194,7 @@ def _is_session_terminated(exc: BaseException) -> bool:
|
|||||||
messages.append(str(getattr(error, "message", "")))
|
messages.append(str(getattr(error, "message", "")))
|
||||||
return any(
|
return any(
|
||||||
marker in message.lower()
|
marker in message.lower()
|
||||||
for marker in ("session terminated", "connection closed")
|
for marker in ("session terminated", "session not found", "connection closed")
|
||||||
for message in messages
|
for message in messages
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -252,8 +252,8 @@ def _redact_url(url: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _pinned_transport_kwargs() -> dict[str, Any]:
|
def _pinned_transport_kwargs() -> dict[str, Any]:
|
||||||
kwargs: dict[str, Any] = {"transport": PinnedDNSAsyncTransport()}
|
kwargs: dict[str, Any] = {"transport": Httpx2PinnedDNSAsyncTransport()}
|
||||||
mounts = httpx_env_proxy_mounts()
|
mounts = httpx2_env_proxy_mounts()
|
||||||
if mounts:
|
if mounts:
|
||||||
kwargs["mounts"] = mounts
|
kwargs["mounts"] = mounts
|
||||||
return kwargs
|
return kwargs
|
||||||
@@ -518,7 +518,7 @@ def _image_block_data_url(block: Any, types: Any) -> str | None:
|
|||||||
"""
|
"""
|
||||||
image_cls = getattr(types, "ImageContent", None)
|
image_cls = getattr(types, "ImageContent", None)
|
||||||
if image_cls is not None and isinstance(block, image_cls):
|
if image_cls is not None and isinstance(block, image_cls):
|
||||||
mime = getattr(block, "mimeType", None) or "image/png"
|
mime = getattr(block, "mime_type", None) or "image/png"
|
||||||
return f"data:{mime};base64,{block.data}"
|
return f"data:{mime};base64,{block.data}"
|
||||||
|
|
||||||
embedded_cls = getattr(types, "EmbeddedResource", None)
|
embedded_cls = getattr(types, "EmbeddedResource", None)
|
||||||
@@ -527,7 +527,7 @@ def _image_block_data_url(block: Any, types: Any) -> str | None:
|
|||||||
resource = getattr(block, "resource", None)
|
resource = getattr(block, "resource", None)
|
||||||
if blob_cls is not None and isinstance(resource, blob_cls):
|
if blob_cls is not None and isinstance(resource, blob_cls):
|
||||||
blob_resource = cast(Any, resource)
|
blob_resource = cast(Any, resource)
|
||||||
mime = getattr(blob_resource, "mimeType", None) or ""
|
mime = getattr(blob_resource, "mime_type", None) or ""
|
||||||
if isinstance(mime, str) and mime.startswith("image/"):
|
if isinstance(mime, str) and mime.startswith("image/"):
|
||||||
return f"data:{mime};base64,{blob_resource.blob}"
|
return f"data:{mime};base64,{blob_resource.blob}"
|
||||||
return None
|
return None
|
||||||
@@ -571,7 +571,7 @@ class MCPToolWrapper(_MCPWrapperBase):
|
|||||||
self._original_name = tool_def.name
|
self._original_name = tool_def.name
|
||||||
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_{tool_def.name}")
|
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_{tool_def.name}")
|
||||||
self._description = tool_def.description or tool_def.name
|
self._description = tool_def.description or tool_def.name
|
||||||
raw_schema = tool_def.inputSchema or {"type": "object", "properties": {}}
|
raw_schema = tool_def.input_schema or {"type": "object", "properties": {}}
|
||||||
self._parameters = _normalize_schema_for_openai(raw_schema)
|
self._parameters = _normalize_schema_for_openai(raw_schema)
|
||||||
self._tool_timeout = tool_timeout
|
self._tool_timeout = tool_timeout
|
||||||
|
|
||||||
@@ -650,7 +650,7 @@ class MCPToolWrapper(_MCPWrapperBase):
|
|||||||
# Success — extract text and persist any image content as artifacts.
|
# Success — extract text and persist any image content as artifacts.
|
||||||
try:
|
try:
|
||||||
rendered = self._render_call_result(result.content, kwargs)
|
rendered = self._render_call_result(result.content, kwargs)
|
||||||
if getattr(result, "isError", False):
|
if getattr(result, "is_error", False):
|
||||||
return ToolResult.error(rendered)
|
return ToolResult.error(rendered)
|
||||||
return rendered
|
return rendered
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -876,8 +876,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
async def execute(self, **kwargs: Any) -> str:
|
async def execute(self, **kwargs: Any) -> str:
|
||||||
from mcp import types
|
from mcp import MCPError, types
|
||||||
from mcp.shared.exceptions import McpError
|
|
||||||
|
|
||||||
retried_transient = False
|
retried_transient = False
|
||||||
refreshed_session = False
|
refreshed_session = False
|
||||||
@@ -897,7 +896,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
|||||||
raise
|
raise
|
||||||
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
|
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
|
||||||
return "(MCP prompt call was cancelled)"
|
return "(MCP prompt call was cancelled)"
|
||||||
except McpError as exc:
|
except MCPError as exc:
|
||||||
if await self._refresh_session_after_termination(
|
if await self._refresh_session_after_termination(
|
||||||
exc,
|
exc,
|
||||||
refreshed_session,
|
refreshed_session,
|
||||||
@@ -1062,7 +1061,7 @@ async def connect_mcp_servers(
|
|||||||
**_pinned_transport_kwargs(),
|
**_pinned_transport_kwargs(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
read, write, _ = await server_stack.enter_async_context(
|
read, write = await server_stack.enter_async_context(
|
||||||
streamable_http_client(cfg.url, http_client=http_client)
|
streamable_http_client(cfg.url, http_client=http_client)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ from nanobot.webui.http_utils import (
|
|||||||
from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
|
from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
|
||||||
from nanobot.webui.metadata import (
|
from nanobot.webui.metadata import (
|
||||||
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||||
|
WEBUI_SYSTEM_COMMAND_TURN_PREFIX,
|
||||||
WEBUI_TURN_METADATA_KEY,
|
WEBUI_TURN_METADATA_KEY,
|
||||||
)
|
)
|
||||||
from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
|
from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
|
||||||
@@ -1003,6 +1004,13 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
# Signal that the agent has fully finished processing the current turn.
|
# Signal that the agent has fully finished processing the current turn.
|
||||||
if isinstance(event, TurnEndEvent):
|
if isinstance(event, TurnEndEvent):
|
||||||
|
turn_id = (msg.metadata or {}).get(WEBUI_TURN_METADATA_KEY)
|
||||||
|
session_update_scope = (
|
||||||
|
"metadata"
|
||||||
|
if isinstance(turn_id, str)
|
||||||
|
and turn_id.startswith(WEBUI_SYSTEM_COMMAND_TURN_PREFIX)
|
||||||
|
else "thread"
|
||||||
|
)
|
||||||
turn_owner = (msg.metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
turn_owner = (msg.metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
||||||
await self.send_turn_end(
|
await self.send_turn_end(
|
||||||
msg.chat_id,
|
msg.chat_id,
|
||||||
@@ -1011,7 +1019,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
metadata=msg.metadata,
|
metadata=msg.metadata,
|
||||||
turn_owner=turn_owner if isinstance(turn_owner, str) else None,
|
turn_owner=turn_owner if isinstance(turn_owner, str) else None,
|
||||||
)
|
)
|
||||||
await self.send_session_updated(msg.chat_id, scope="thread")
|
await self.send_session_updated(msg.chat_id, scope=session_update_scope)
|
||||||
return
|
return
|
||||||
if isinstance(event, SessionUpdatedEvent):
|
if isinstance(event, SessionUpdatedEvent):
|
||||||
if conns:
|
if conns:
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ from nanobot.webui.http_utils import (
|
|||||||
from nanobot.webui.http_utils import (
|
from nanobot.webui.http_utils import (
|
||||||
parse_request_path as _parse_request_path,
|
parse_request_path as _parse_request_path,
|
||||||
)
|
)
|
||||||
from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY
|
from nanobot.webui.metadata import (
|
||||||
|
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||||
|
WEBUI_SYSTEM_COMMAND_TURN_PREFIX,
|
||||||
|
WEBUI_TURN_METADATA_KEY,
|
||||||
|
)
|
||||||
from nanobot.webui.settings_api import settings_payload, update_provider_settings
|
from nanobot.webui.settings_api import settings_payload, update_provider_settings
|
||||||
from nanobot.webui.transcript import (
|
from nanobot.webui.transcript import (
|
||||||
append_transcript_object,
|
append_transcript_object,
|
||||||
@@ -1618,6 +1622,43 @@ async def test_send_turn_end_emits_turn_end_event() -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_command_turn_end_only_refreshes_session_metadata() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{"enabled": True, "allowFrom": ["*"]},
|
||||||
|
bus,
|
||||||
|
gateway=_basic_handler(bus),
|
||||||
|
)
|
||||||
|
mock_ws = AsyncMock()
|
||||||
|
channel._attach(mock_ws, "chat-model")
|
||||||
|
|
||||||
|
await channel.send(OutboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="chat-model",
|
||||||
|
content="",
|
||||||
|
metadata={
|
||||||
|
WEBUI_TURN_METADATA_KEY: f"{WEBUI_SYSTEM_COMMAND_TURN_PREFIX}model-switch",
|
||||||
|
},
|
||||||
|
event=TurnEndEvent(),
|
||||||
|
))
|
||||||
|
|
||||||
|
assert _sent_ws_payloads(mock_ws) == [
|
||||||
|
{
|
||||||
|
"event": "turn_end",
|
||||||
|
"chat_id": "chat-model",
|
||||||
|
"turn_id": f"{WEBUI_SYSTEM_COMMAND_TURN_PREFIX}model-switch",
|
||||||
|
"turn_phase": "complete",
|
||||||
|
"turn_seq": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "session_updated",
|
||||||
|
"chat_id": "chat-model",
|
||||||
|
"scope": "metadata",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("active_owner", "event_owner", "expected_cleared"),
|
("active_owner", "event_owner", "expected_cleared"),
|
||||||
|
|||||||
@@ -2937,6 +2937,17 @@ async def test_webui_thread_resigns_assistant_media_urls(
|
|||||||
assert media[0]["url"].startswith("/api/media/")
|
assert media[0]["url"].startswith("/api/media/")
|
||||||
assert media[0]["url"] != "/api/media/old-sig/old-payload"
|
assert media[0]["url"] != "/api/media/old-sig/old-payload"
|
||||||
|
|
||||||
|
repeated = await _http_get(
|
||||||
|
"http://127.0.0.1:29914/api/sessions/websocket:video-replay/webui-thread",
|
||||||
|
headers=auth,
|
||||||
|
)
|
||||||
|
repeated_assistant = next(
|
||||||
|
m for m in repeated.json()["messages"] if m["role"] == "assistant"
|
||||||
|
)
|
||||||
|
assert repeated_assistant["id"] == assistant["id"]
|
||||||
|
assert repeated_assistant["media"][0]["url"] == media[0]["url"]
|
||||||
|
assert len(list(websocket_media.iterdir())) == 1
|
||||||
|
|
||||||
fetched = await _http_get(f"http://127.0.0.1:29914{media[0]['url']}")
|
fetched = await _http_get(f"http://127.0.0.1:29914{media[0]['url']}")
|
||||||
assert fetched.status_code == 200
|
assert fetched.status_code == 200
|
||||||
assert fetched.content == b"video"
|
assert fetched.content == b"video"
|
||||||
|
|||||||
@@ -146,16 +146,41 @@ def test_local_markdown_image_is_staged_and_rewritten(
|
|||||||
channel = _ch(bus, workspace_path=workspace, port=0)
|
channel = _ch(bus, workspace_path=workspace, port=0)
|
||||||
|
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)):
|
with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)):
|
||||||
rewritten = channel.gateway.media.rewrite_local_markdown_images(
|
first = channel.gateway.media.rewrite_local_markdown_images(
|
||||||
|
"The result:\n"
|
||||||
|
)
|
||||||
|
second = channel.gateway.media.rewrite_local_markdown_images(
|
||||||
"The result:\n"
|
"The result:\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert ".iterdir())
|
staged = list((media / "websocket").iterdir())
|
||||||
assert len(staged) == 1
|
assert len(staged) == 1
|
||||||
assert staged[0].read_bytes() == _PNG_BYTES
|
assert staged[0].read_bytes() == _PNG_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
def test_modified_local_markdown_image_gets_a_new_immutable_url(
|
||||||
|
bus: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
source = workspace / "demo_arch.png"
|
||||||
|
source.write_bytes(_PNG_BYTES)
|
||||||
|
media = tmp_path / "media"
|
||||||
|
channel = _ch(bus, workspace_path=workspace, port=0)
|
||||||
|
markdown = ""
|
||||||
|
|
||||||
|
with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)):
|
||||||
|
first = channel.gateway.media.rewrite_local_markdown_images(markdown)
|
||||||
|
source.write_bytes(_PNG_BYTES + b"updated")
|
||||||
|
second = channel.gateway.media.rewrite_local_markdown_images(markdown)
|
||||||
|
|
||||||
|
assert second != first
|
||||||
|
assert len(list((media / "websocket").iterdir())) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_local_markdown_video_is_staged_and_rewritten(
|
def test_local_markdown_video_is_staged_and_rewritten(
|
||||||
bus: MagicMock,
|
bus: MagicMock,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""Direct and interactive agent CLI command."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from types import FrameType
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from nanobot import __logo__
|
||||||
|
from nanobot.agent.hooks import create_file_edit_activity_hook
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.outbound_events import (
|
||||||
|
StreamDeltaEvent,
|
||||||
|
StreamedResponseEvent,
|
||||||
|
StreamEndEvent,
|
||||||
|
outbound_event_from_message,
|
||||||
|
)
|
||||||
|
from nanobot.cli import terminal as cli_terminal
|
||||||
|
from nanobot.cli.log_control import _set_nanobot_logs
|
||||||
|
from nanobot.cli.runtime_config import (
|
||||||
|
_load_runtime_config,
|
||||||
|
_migrate_cron_store,
|
||||||
|
_model_display,
|
||||||
|
_print_agent_start_error,
|
||||||
|
)
|
||||||
|
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
|
||||||
|
from nanobot.config.paths import is_default_workspace
|
||||||
|
from nanobot.utils.helpers import (
|
||||||
|
sanitize_surrogates as _sanitize_surrogates,
|
||||||
|
)
|
||||||
|
from nanobot.utils.helpers import (
|
||||||
|
sync_workspace_templates,
|
||||||
|
)
|
||||||
|
from nanobot.utils.restart import (
|
||||||
|
consume_restart_notice_from_env,
|
||||||
|
format_restart_completed_message,
|
||||||
|
should_show_cli_restart_notice,
|
||||||
|
)
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
def agent(
|
||||||
|
message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
|
||||||
|
session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"),
|
||||||
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||||
|
markdown: bool = typer.Option(
|
||||||
|
True,
|
||||||
|
"--markdown/--no-markdown",
|
||||||
|
help="Render assistant output as Markdown",
|
||||||
|
),
|
||||||
|
logs: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--logs/--no-logs",
|
||||||
|
help="Show nanobot runtime logs during chat",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
"""Interact with the agent directly."""
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.cron.service import CronService
|
||||||
|
from nanobot.providers.factory import make_provider
|
||||||
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
|
|
||||||
|
runtime_config = _load_runtime_config(config, workspace)
|
||||||
|
try:
|
||||||
|
provider = make_provider(runtime_config)
|
||||||
|
except ValueError as exc:
|
||||||
|
_print_agent_start_error(exc)
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
|
sync_workspace_templates(runtime_config.workspace_path)
|
||||||
|
|
||||||
|
bus = MessageBus()
|
||||||
|
|
||||||
|
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
||||||
|
if is_default_workspace(runtime_config.workspace_path):
|
||||||
|
_migrate_cron_store(runtime_config)
|
||||||
|
|
||||||
|
# Create cron service with workspace-scoped store
|
||||||
|
cron_store_path = runtime_config.workspace_path / "cron" / "jobs.json"
|
||||||
|
cron = CronService(cron_store_path)
|
||||||
|
|
||||||
|
_set_nanobot_logs(logs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
agent_loop = AgentLoop.from_config(
|
||||||
|
runtime_config,
|
||||||
|
bus,
|
||||||
|
provider=provider,
|
||||||
|
cron_service=cron,
|
||||||
|
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
|
||||||
|
hook_factories=[create_file_edit_activity_hook],
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
_print_agent_start_error(exc)
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
restart_notice = consume_restart_notice_from_env()
|
||||||
|
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
|
||||||
|
cli_terminal._print_agent_response(
|
||||||
|
format_restart_completed_message(restart_notice.started_at_raw),
|
||||||
|
render_markdown=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Shared reference for progress callbacks
|
||||||
|
_thinking: ThinkingSpinner | None = None
|
||||||
|
|
||||||
|
def _make_progress(
|
||||||
|
renderer: StreamRenderer | None = None,
|
||||||
|
) -> Callable[..., Awaitable[None]]:
|
||||||
|
reasoning_buffer = cli_terminal._ReasoningBuffer()
|
||||||
|
|
||||||
|
async def _cli_progress(
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
tool_hint: bool = False,
|
||||||
|
reasoning: bool = False,
|
||||||
|
**_kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
ch = agent_loop.channels_config
|
||||||
|
|
||||||
|
if _kwargs.get("reasoning_end"):
|
||||||
|
if ch and not ch.show_reasoning:
|
||||||
|
reasoning_buffer.clear()
|
||||||
|
else:
|
||||||
|
cli_terminal._flush_cli_reasoning(reasoning_buffer, _thinking, renderer)
|
||||||
|
return
|
||||||
|
|
||||||
|
if reasoning:
|
||||||
|
if ch and not ch.show_reasoning:
|
||||||
|
reasoning_buffer.clear()
|
||||||
|
return
|
||||||
|
text = reasoning_buffer.add(content)
|
||||||
|
if text:
|
||||||
|
cli_terminal._print_cli_reasoning(text, _thinking, renderer)
|
||||||
|
return
|
||||||
|
if ch and tool_hint and not ch.send_tool_hints:
|
||||||
|
return
|
||||||
|
if ch and not tool_hint and not ch.send_progress:
|
||||||
|
return
|
||||||
|
cli_terminal._print_cli_progress_line(content, _thinking, renderer)
|
||||||
|
|
||||||
|
return _cli_progress
|
||||||
|
|
||||||
|
if message:
|
||||||
|
# Single message mode — direct call, no bus needed
|
||||||
|
async def run_once() -> None:
|
||||||
|
renderer = StreamRenderer(
|
||||||
|
render_markdown=markdown,
|
||||||
|
bot_name=runtime_config.agents.defaults.bot_name,
|
||||||
|
bot_icon=runtime_config.agents.defaults.bot_icon,
|
||||||
|
)
|
||||||
|
response = await agent_loop.process_direct(
|
||||||
|
message,
|
||||||
|
session_id,
|
||||||
|
on_progress=_make_progress(renderer),
|
||||||
|
on_stream=renderer.on_delta,
|
||||||
|
on_stream_end=renderer.on_end,
|
||||||
|
)
|
||||||
|
if not renderer.streamed:
|
||||||
|
await renderer.close()
|
||||||
|
print_kwargs: dict[str, Any] = {}
|
||||||
|
if renderer.header_printed:
|
||||||
|
print_kwargs["show_header"] = False
|
||||||
|
cli_terminal._print_agent_response(
|
||||||
|
response.content if response else "",
|
||||||
|
render_markdown=markdown,
|
||||||
|
metadata=response.metadata if response else None,
|
||||||
|
**print_kwargs,
|
||||||
|
)
|
||||||
|
await agent_loop.close_mcp()
|
||||||
|
|
||||||
|
asyncio.run(run_once())
|
||||||
|
else:
|
||||||
|
# Interactive mode — route through bus like other channels
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
|
||||||
|
cli_terminal._init_prompt_session()
|
||||||
|
_model, _preset_tag = _model_display(runtime_config)
|
||||||
|
_icon = runtime_config.agents.defaults.bot_icon or __logo__
|
||||||
|
console.print(
|
||||||
|
f"{_icon} Interactive mode [bold blue]({_model})[/bold blue]{_preset_tag} "
|
||||||
|
"— type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if ":" in session_id:
|
||||||
|
cli_channel, cli_chat_id = session_id.split(":", 1)
|
||||||
|
else:
|
||||||
|
cli_channel, cli_chat_id = "cli", session_id
|
||||||
|
|
||||||
|
def _handle_signal(signum: int, _frame: FrameType | None) -> None:
|
||||||
|
sig_name = signal.Signals(signum).name
|
||||||
|
cli_terminal._restore_terminal()
|
||||||
|
console.print(f"\nReceived {sig_name}, goodbye!")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, _handle_signal)
|
||||||
|
signal.signal(signal.SIGTERM, _handle_signal)
|
||||||
|
# SIGHUP is not available on Windows
|
||||||
|
if hasattr(signal, "SIGHUP"):
|
||||||
|
signal.signal(signal.SIGHUP, _handle_signal)
|
||||||
|
# Ignore SIGPIPE to prevent silent process termination when writing to closed pipes
|
||||||
|
# SIGPIPE is not available on Windows
|
||||||
|
if hasattr(signal, "SIGPIPE"):
|
||||||
|
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
||||||
|
|
||||||
|
async def run_interactive() -> None:
|
||||||
|
bus_task = asyncio.create_task(agent_loop.run())
|
||||||
|
turn_done = asyncio.Event()
|
||||||
|
turn_done.set()
|
||||||
|
turn_response: list[Any] = []
|
||||||
|
renderer: StreamRenderer | None = None
|
||||||
|
reasoning_buffer = cli_terminal._ReasoningBuffer()
|
||||||
|
|
||||||
|
async def _consume_outbound() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
||||||
|
event = outbound_event_from_message(msg)
|
||||||
|
|
||||||
|
if isinstance(event, StreamDeltaEvent):
|
||||||
|
if renderer:
|
||||||
|
await renderer.on_delta(msg.content)
|
||||||
|
continue
|
||||||
|
if isinstance(event, StreamEndEvent):
|
||||||
|
if renderer:
|
||||||
|
await renderer.on_end(
|
||||||
|
resuming=event.resuming,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if isinstance(event, StreamedResponseEvent):
|
||||||
|
if msg.content and renderer and not renderer.streamed:
|
||||||
|
await renderer.close()
|
||||||
|
print_kwargs: dict[str, Any] = {}
|
||||||
|
if renderer.header_printed:
|
||||||
|
print_kwargs["show_header"] = False
|
||||||
|
cli_terminal._print_agent_response(
|
||||||
|
msg.content,
|
||||||
|
render_markdown=markdown,
|
||||||
|
metadata=msg.metadata,
|
||||||
|
**print_kwargs,
|
||||||
|
)
|
||||||
|
turn_done.set()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if await cli_terminal._maybe_print_interactive_progress(
|
||||||
|
msg,
|
||||||
|
None,
|
||||||
|
agent_loop.channels_config,
|
||||||
|
renderer,
|
||||||
|
reasoning_buffer,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not turn_done.is_set():
|
||||||
|
if msg.content:
|
||||||
|
turn_response.append(msg)
|
||||||
|
turn_done.set()
|
||||||
|
elif msg.content:
|
||||||
|
await cli_terminal._print_interactive_response(
|
||||||
|
msg.content,
|
||||||
|
render_markdown=markdown,
|
||||||
|
metadata=msg.metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
|
||||||
|
outbound_task = asyncio.create_task(_consume_outbound())
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
cli_terminal._flush_pending_tty_input()
|
||||||
|
# Stop spinner before user input to avoid prompt_toolkit conflicts
|
||||||
|
if renderer:
|
||||||
|
renderer.stop_for_input()
|
||||||
|
user_input = _sanitize_surrogates(
|
||||||
|
await cli_terminal._read_interactive_input_async()
|
||||||
|
)
|
||||||
|
command = user_input.strip()
|
||||||
|
if not command:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if cli_terminal._is_exit_command(command):
|
||||||
|
cli_terminal._restore_terminal()
|
||||||
|
console.print("\nGoodbye!")
|
||||||
|
break
|
||||||
|
|
||||||
|
turn_done.clear()
|
||||||
|
turn_response.clear()
|
||||||
|
reasoning_buffer.clear()
|
||||||
|
renderer = StreamRenderer(
|
||||||
|
render_markdown=markdown,
|
||||||
|
bot_name=runtime_config.agents.defaults.bot_name,
|
||||||
|
bot_icon=runtime_config.agents.defaults.bot_icon,
|
||||||
|
)
|
||||||
|
|
||||||
|
await bus.publish_inbound(
|
||||||
|
InboundMessage(
|
||||||
|
channel=cli_channel,
|
||||||
|
sender_id="user",
|
||||||
|
chat_id=cli_chat_id,
|
||||||
|
content=user_input,
|
||||||
|
metadata={"_wants_stream": True},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await turn_done.wait()
|
||||||
|
|
||||||
|
if turn_response:
|
||||||
|
response_msg = turn_response[0]
|
||||||
|
content = response_msg.content
|
||||||
|
meta = response_msg.metadata
|
||||||
|
if content and not isinstance(
|
||||||
|
response_msg.event,
|
||||||
|
StreamedResponseEvent,
|
||||||
|
):
|
||||||
|
if renderer:
|
||||||
|
await renderer.close()
|
||||||
|
print_kwargs: dict[str, Any] = {}
|
||||||
|
if renderer and renderer.header_printed:
|
||||||
|
print_kwargs["show_header"] = False
|
||||||
|
cli_terminal._print_agent_response(
|
||||||
|
content,
|
||||||
|
render_markdown=markdown,
|
||||||
|
metadata=meta,
|
||||||
|
**print_kwargs,
|
||||||
|
)
|
||||||
|
elif renderer and not renderer.streamed:
|
||||||
|
await renderer.close()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
cli_terminal._restore_terminal()
|
||||||
|
console.print("\nGoodbye!")
|
||||||
|
break
|
||||||
|
except EOFError:
|
||||||
|
cli_terminal._restore_terminal()
|
||||||
|
console.print("\nGoodbye!")
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
agent_loop.stop()
|
||||||
|
outbound_task.cancel()
|
||||||
|
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
|
||||||
|
await agent_loop.close_mcp()
|
||||||
|
|
||||||
|
asyncio.run(run_interactive())
|
||||||
+27
-2627
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,828 @@
|
|||||||
|
"""Foreground gateway runtime and lifecycle helpers."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
from collections.abc import Awaitable, Callable, Coroutine, Iterable
|
||||||
|
from contextlib import suppress
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from loguru import logger
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from nanobot import __logo__, __version__
|
||||||
|
from nanobot.agent.hooks import create_file_edit_activity_hook
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.cli import terminal as cli_terminal
|
||||||
|
from nanobot.cli.runtime_config import _migrate_cron_store
|
||||||
|
from nanobot.cli.webui_support import (
|
||||||
|
_gateway_health_bind_note,
|
||||||
|
_gateway_health_url,
|
||||||
|
_host_for_local_browser,
|
||||||
|
_prepare_webui_bundle_for_gateway,
|
||||||
|
_print_foreground_port_conflict,
|
||||||
|
_tcp_endpoint_reachable,
|
||||||
|
_webui_browser_url,
|
||||||
|
_webui_channel_enabled,
|
||||||
|
_webui_endpoint_reachable,
|
||||||
|
)
|
||||||
|
from nanobot.config.paths import is_default_workspace
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.security.network import is_loopback_host
|
||||||
|
from nanobot.session.keys import UNIFIED_SESSION_KEY, last_channel_from_metadata
|
||||||
|
from nanobot.utils.evaluator import evaluate_response, resolve_evaluator_prompt
|
||||||
|
from nanobot.utils.helpers import sync_workspace_templates
|
||||||
|
from nanobot.webui.build import BuildMode
|
||||||
|
from nanobot.webui.sidebar_state import read_webui_sidebar_state
|
||||||
|
|
||||||
|
__all__ = ["_run_gateway"]
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_name(signum: int) -> str:
|
||||||
|
with suppress(ValueError):
|
||||||
|
return signal.Signals(signum).name
|
||||||
|
return f"signal {signum}"
|
||||||
|
|
||||||
|
|
||||||
|
def _install_gateway_shutdown_handlers(
|
||||||
|
loop: asyncio.AbstractEventLoop,
|
||||||
|
shutdown_event: asyncio.Event,
|
||||||
|
tasks: list[asyncio.Task[Any]],
|
||||||
|
print_status: Callable[[str], None],
|
||||||
|
) -> Callable[[], None]:
|
||||||
|
"""Install foreground gateway signal handlers and return a restore callback."""
|
||||||
|
loop_signals: list[int] = []
|
||||||
|
previous_handlers: list[tuple[int, Any]] = []
|
||||||
|
shutdown_requested = False
|
||||||
|
|
||||||
|
def request_shutdown(signum: int) -> None:
|
||||||
|
nonlocal shutdown_requested
|
||||||
|
sig_name = _signal_name(signum)
|
||||||
|
if shutdown_requested:
|
||||||
|
logger.warning("Forcing gateway shutdown after repeated {}", sig_name)
|
||||||
|
for task in tasks:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
return
|
||||||
|
shutdown_requested = True
|
||||||
|
logger.info("Gateway shutdown requested by {}", sig_name)
|
||||||
|
print_status("\nShutting down... Press Ctrl+C again to force.")
|
||||||
|
shutdown_event.set()
|
||||||
|
|
||||||
|
for signum in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
try:
|
||||||
|
loop.add_signal_handler(signum, request_shutdown, signum)
|
||||||
|
except (NotImplementedError, RuntimeError, ValueError):
|
||||||
|
try:
|
||||||
|
previous = signal.getsignal(signum)
|
||||||
|
signal.signal(signum, lambda sig, _frame: request_shutdown(sig))
|
||||||
|
except (RuntimeError, ValueError):
|
||||||
|
logger.debug("Could not install gateway handler for {}", _signal_name(signum))
|
||||||
|
continue
|
||||||
|
previous_handlers.append((signum, previous))
|
||||||
|
else:
|
||||||
|
loop_signals.append(signum)
|
||||||
|
|
||||||
|
def restore() -> None:
|
||||||
|
for signum in loop_signals:
|
||||||
|
with suppress(NotImplementedError, RuntimeError, ValueError):
|
||||||
|
loop.remove_signal_handler(signum)
|
||||||
|
for signum, handler in previous_handlers:
|
||||||
|
with suppress(RuntimeError, ValueError):
|
||||||
|
signal.signal(signum, handler)
|
||||||
|
|
||||||
|
return restore
|
||||||
|
|
||||||
|
|
||||||
|
def _advance_dream_cursor_if_behind(memory: Any) -> None:
|
||||||
|
latest = memory.get_latest_cursor()
|
||||||
|
if memory.get_last_dream_cursor() < latest:
|
||||||
|
memory.set_last_dream_cursor(latest)
|
||||||
|
|
||||||
|
|
||||||
|
def _commit_dream_changes(memory: Any) -> str | None:
|
||||||
|
"""Commit durable Dream edits, without entering the commit path for a no-op run."""
|
||||||
|
if not memory.git.is_initialized():
|
||||||
|
return None
|
||||||
|
diff_body = memory.dream_content_diff()
|
||||||
|
if not diff_body:
|
||||||
|
return None
|
||||||
|
message = memory.build_dream_commit_message(
|
||||||
|
"dream: periodic memory consolidation",
|
||||||
|
diff_body,
|
||||||
|
)
|
||||||
|
return memory.git.auto_commit(message)
|
||||||
|
|
||||||
|
|
||||||
|
_HEARTBEAT_PREAMBLE = (
|
||||||
|
"[Your response will be delivered directly to the user's messaging app. "
|
||||||
|
"Output ONLY the final user-facing message. Never reference internal "
|
||||||
|
"files (HEARTBEAT.md, AWARENESS.md, etc.), your instructions, or your "
|
||||||
|
"decision process. If nothing needs reporting, respond with just "
|
||||||
|
"'All clear.' and nothing else.]\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _heartbeat_has_active_tasks(content: str) -> bool:
|
||||||
|
"""True if HEARTBEAT.md has task lines, ignoring headers, blanks and comments."""
|
||||||
|
in_comment = False
|
||||||
|
in_active_section: bool = False
|
||||||
|
for line in content.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if in_comment:
|
||||||
|
if "-->" in stripped:
|
||||||
|
in_comment = False
|
||||||
|
continue
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
if stripped.startswith("##") and not stripped.startswith("###"):
|
||||||
|
heading = stripped.lstrip("#").strip().lower()
|
||||||
|
in_active_section = heading.startswith("active tasks")
|
||||||
|
continue
|
||||||
|
if stripped.startswith("<!--"):
|
||||||
|
if "-->" not in stripped[4:]:
|
||||||
|
in_comment = True
|
||||||
|
continue
|
||||||
|
if in_active_section is False:
|
||||||
|
continue
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_heartbeat_target_from_sessions(
|
||||||
|
*,
|
||||||
|
enabled_channels: Iterable[str],
|
||||||
|
sessions: Iterable[dict[str, Any]],
|
||||||
|
archived_keys: Iterable[str],
|
||||||
|
unified_session_metadata: dict[str, Any] | None = None,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
enabled = set(enabled_channels)
|
||||||
|
archived = set(archived_keys)
|
||||||
|
for item in sessions:
|
||||||
|
key = item.get("key") or ""
|
||||||
|
if key in archived:
|
||||||
|
continue
|
||||||
|
if key == UNIFIED_SESSION_KEY:
|
||||||
|
route = last_channel_from_metadata(unified_session_metadata)
|
||||||
|
if route is not None:
|
||||||
|
channel, chat_id = route
|
||||||
|
if channel not in {"cli", "system"} and channel in enabled:
|
||||||
|
return channel, chat_id
|
||||||
|
continue
|
||||||
|
if ":" not in key:
|
||||||
|
continue
|
||||||
|
channel, chat_id = key.split(":", 1)
|
||||||
|
if channel in {"cli", "system"}:
|
||||||
|
continue
|
||||||
|
if channel in enabled and chat_id:
|
||||||
|
return channel, chat_id
|
||||||
|
return "cli", "direct"
|
||||||
|
|
||||||
|
|
||||||
|
_GATEWAY_HEALTH_MAX_CONNECTIONS = 64
|
||||||
|
_GATEWAY_HEALTH_READ_TIMEOUT_SECONDS = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def _print_gateway_health_endpoint(host: str, port: int) -> None:
|
||||||
|
"""Print a usable health URL and make non-loopback binds explicit."""
|
||||||
|
console.print(
|
||||||
|
f"[green]✓[/green] Health endpoint: {_gateway_health_url(host, port)}"
|
||||||
|
f"{_gateway_health_bind_note(host)}"
|
||||||
|
)
|
||||||
|
if is_loopback_host(host):
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print(
|
||||||
|
"[yellow]Warning: the unauthenticated health endpoint is listening beyond loopback "
|
||||||
|
"and may be reachable from other devices. "
|
||||||
|
f"Keep port {port} private or protect it with a firewall or reverse proxy.[/yellow]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_gateway(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
port: int | None = None,
|
||||||
|
open_browser_url: str | None = None,
|
||||||
|
webui_static_dist: bool = True,
|
||||||
|
webui_bundle_mode: BuildMode = "warn",
|
||||||
|
webui_runtime_surface: str = "browser",
|
||||||
|
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||||
|
health_server_enabled: bool = True,
|
||||||
|
unconfigured_provider_error: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
|
||||||
|
from nanobot.agent.model_presets import load_model_preset_catalog
|
||||||
|
from nanobot.agent.tools.message import MessageTool
|
||||||
|
from nanobot.agent.turn_delivery import TurnDeliveryFactory
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.bus.runtime_events import RuntimeEventBus
|
||||||
|
from nanobot.channels.manager import ChannelManager
|
||||||
|
from nanobot.config.watcher import watch_config_file
|
||||||
|
from nanobot.cron.bound_runner import run_bound_cron_job
|
||||||
|
from nanobot.cron.service import CronJobSkippedError, CronService
|
||||||
|
from nanobot.cron.session_turns import is_bound_cron_job
|
||||||
|
from nanobot.cron.types import CronJob
|
||||||
|
from nanobot.providers.factory import (
|
||||||
|
ProviderSnapshot,
|
||||||
|
build_provider_snapshot,
|
||||||
|
build_unconfigured_provider_snapshot,
|
||||||
|
load_provider_snapshot,
|
||||||
|
)
|
||||||
|
from nanobot.providers.fallback_provider import FallbackProvider
|
||||||
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
|
from nanobot.session.webui_turns import (
|
||||||
|
WebuiTurnCoordinator,
|
||||||
|
WebuiTurnRoutePolicy,
|
||||||
|
build_webui_fallback_model_observer,
|
||||||
|
)
|
||||||
|
from nanobot.triggers.local_runner import run_local_trigger_queue
|
||||||
|
from nanobot.triggers.local_store import LocalTriggerStore
|
||||||
|
from nanobot.webui.token_usage import TokenUsageHook
|
||||||
|
|
||||||
|
port = port if port is not None else config.gateway.port
|
||||||
|
webui_url = _webui_browser_url(config)
|
||||||
|
gateway_host_for_browser = _host_for_local_browser(config.gateway.host)
|
||||||
|
if health_server_enabled and _tcp_endpoint_reachable(gateway_host_for_browser, port):
|
||||||
|
_print_foreground_port_conflict(
|
||||||
|
webui_url=webui_url,
|
||||||
|
gateway_host=config.gateway.host,
|
||||||
|
gateway_port=port,
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
if _webui_channel_enabled(config) and _webui_endpoint_reachable(webui_url):
|
||||||
|
_print_foreground_port_conflict(
|
||||||
|
webui_url=webui_url,
|
||||||
|
gateway_host=config.gateway.host,
|
||||||
|
gateway_port=port,
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
|
||||||
|
_prepare_webui_bundle_for_gateway(
|
||||||
|
config,
|
||||||
|
mode=webui_bundle_mode,
|
||||||
|
webui_static_dist=webui_static_dist,
|
||||||
|
)
|
||||||
|
sync_workspace_templates(config.workspace_path)
|
||||||
|
bus = MessageBus()
|
||||||
|
runtime_events = RuntimeEventBus()
|
||||||
|
fallback_model_observer = build_webui_fallback_model_observer(bus)
|
||||||
|
|
||||||
|
def _observe_fallback_models(snapshot: ProviderSnapshot) -> ProviderSnapshot:
|
||||||
|
if isinstance(snapshot.provider, FallbackProvider):
|
||||||
|
snapshot.provider.set_fallback_model_observer(fallback_model_observer)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def _load_gateway_provider_snapshot(
|
||||||
|
*args: Any,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> ProviderSnapshot:
|
||||||
|
try:
|
||||||
|
return _observe_fallback_models(load_provider_snapshot(*args, **kwargs))
|
||||||
|
except ValueError as exc:
|
||||||
|
if unconfigured_provider_error is None:
|
||||||
|
raise
|
||||||
|
return build_unconfigured_provider_snapshot(config, str(exc))
|
||||||
|
|
||||||
|
if unconfigured_provider_error is not None:
|
||||||
|
provider_snapshot = build_unconfigured_provider_snapshot(
|
||||||
|
config,
|
||||||
|
unconfigured_provider_error,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
provider_snapshot = _observe_fallback_models(build_provider_snapshot(config))
|
||||||
|
except ValueError as exc:
|
||||||
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
session_manager = SessionManager(config.workspace_path)
|
||||||
|
|
||||||
|
# Self-heal the gateway state file with the current PID after any restart.
|
||||||
|
from nanobot.config.loader import get_config_path
|
||||||
|
from nanobot.gateway.runtime import GatewayRuntime, GatewayRuntimePaths
|
||||||
|
|
||||||
|
config_path = str(get_config_path().resolve(strict=False))
|
||||||
|
GatewayRuntime.refresh_state_pid(
|
||||||
|
paths=GatewayRuntimePaths.for_instance(
|
||||||
|
workspace=str(config.workspace_path)
|
||||||
|
if not is_default_workspace(config.workspace_path)
|
||||||
|
else None,
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
||||||
|
if is_default_workspace(config.workspace_path):
|
||||||
|
_migrate_cron_store(config)
|
||||||
|
|
||||||
|
# Create cron service with workspace-scoped store
|
||||||
|
cron_store_path = config.workspace_path / "cron" / "jobs.json"
|
||||||
|
cron = CronService(cron_store_path)
|
||||||
|
trigger_store = LocalTriggerStore(config.workspace_path)
|
||||||
|
|
||||||
|
turn_delivery_factory = TurnDeliveryFactory(
|
||||||
|
bus,
|
||||||
|
runtime_events,
|
||||||
|
route_policy=WebuiTurnRoutePolicy(session_manager),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create agent with cron service
|
||||||
|
agent = AgentLoop.from_config(
|
||||||
|
config, bus,
|
||||||
|
provider=provider_snapshot.provider,
|
||||||
|
model=provider_snapshot.model,
|
||||||
|
context_window_tokens=provider_snapshot.context_window_tokens,
|
||||||
|
cron_service=cron,
|
||||||
|
session_manager=session_manager,
|
||||||
|
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||||
|
provider_snapshot_loader=_load_gateway_provider_snapshot,
|
||||||
|
preset_catalog_loader=load_model_preset_catalog,
|
||||||
|
runtime_events=runtime_events,
|
||||||
|
turn_delivery_factory=turn_delivery_factory,
|
||||||
|
provider_signature=provider_snapshot.signature,
|
||||||
|
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
|
||||||
|
local_trigger_store=trigger_store,
|
||||||
|
hook_factories=[create_file_edit_activity_hook],
|
||||||
|
)
|
||||||
|
def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
|
||||||
|
agent.schedule_background(cast(Coroutine[Any, Any, None], awaitable))
|
||||||
|
|
||||||
|
webui_turn_coordinator = WebuiTurnCoordinator(
|
||||||
|
bus=bus,
|
||||||
|
sessions=session_manager,
|
||||||
|
schedule_background=_schedule_webui_background,
|
||||||
|
)
|
||||||
|
webui_turn_coordinator.subscribe(runtime_events)
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
from nanobot.session.keys import session_key_for_channel
|
||||||
|
|
||||||
|
def _channel_session_key(channel: str, chat_id: str) -> str:
|
||||||
|
return session_key_for_channel(
|
||||||
|
channel,
|
||||||
|
chat_id,
|
||||||
|
unified_session=config.agents.defaults.unified_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _deliver_to_channel(
|
||||||
|
msg: OutboundMessage, *, record: bool = False, session_key: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Publish a user-visible message and mirror it into that channel's session."""
|
||||||
|
metadata = dict(msg.metadata or {})
|
||||||
|
record = record or bool(metadata.pop("_record_channel_delivery", False))
|
||||||
|
if metadata != (msg.metadata or {}):
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel=msg.channel,
|
||||||
|
chat_id=msg.chat_id,
|
||||||
|
content=msg.content,
|
||||||
|
reply_to=msg.reply_to,
|
||||||
|
media=msg.media,
|
||||||
|
metadata=metadata,
|
||||||
|
buttons=msg.buttons,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
record
|
||||||
|
and msg.channel != "cli"
|
||||||
|
and msg.content.strip()
|
||||||
|
and hasattr(session_manager, "get_or_create")
|
||||||
|
and hasattr(session_manager, "save")
|
||||||
|
):
|
||||||
|
key = session_key or _channel_session_key(msg.channel, msg.chat_id)
|
||||||
|
session = session_manager.get_or_create(key)
|
||||||
|
extra: dict[str, Any] = {"_channel_delivery": True}
|
||||||
|
if msg.media:
|
||||||
|
extra["media"] = list(msg.media)
|
||||||
|
session.add_message("assistant", msg.content, **extra)
|
||||||
|
session_manager.save(session)
|
||||||
|
await bus.publish_outbound(msg)
|
||||||
|
|
||||||
|
message_tool = agent.tools.get("message")
|
||||||
|
if isinstance(message_tool, MessageTool):
|
||||||
|
message_tool.set_send_callback(_deliver_to_channel)
|
||||||
|
|
||||||
|
# Set cron callback (needs agent)
|
||||||
|
async def on_cron_job(job: CronJob) -> str | None:
|
||||||
|
"""Execute a cron job through the agent."""
|
||||||
|
async def _silent(*_args: Any, **_kwargs: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Dream is an internal job — run directly, not through the agent loop.
|
||||||
|
if job.name == "dream":
|
||||||
|
from nanobot.agent.memory import DreamRunProgress, MemoryStore
|
||||||
|
|
||||||
|
dream_session_key = MemoryStore.dream_session_key
|
||||||
|
prune_dream_sessions = MemoryStore.prune_dream_sessions
|
||||||
|
|
||||||
|
store = agent.context.memory
|
||||||
|
progress = DreamRunProgress()
|
||||||
|
resp = None
|
||||||
|
diff_body = ""
|
||||||
|
try:
|
||||||
|
result = store.build_dream_prompt()
|
||||||
|
if result is None:
|
||||||
|
logger.info("Dream: nothing to process")
|
||||||
|
return None
|
||||||
|
prompt, last_cursor = result
|
||||||
|
key = dream_session_key()
|
||||||
|
dream_runtime = agent.dream_runtime()
|
||||||
|
resp = await agent.process_direct(
|
||||||
|
prompt,
|
||||||
|
session_key=key,
|
||||||
|
ephemeral=True,
|
||||||
|
tools=store.build_dream_tools(),
|
||||||
|
on_progress=progress,
|
||||||
|
runtime=dream_runtime,
|
||||||
|
)
|
||||||
|
# The real file delta grounds the audit record; clean completion
|
||||||
|
# decides whether this history batch has finished processing.
|
||||||
|
diff_body = store.dream_content_diff()
|
||||||
|
completed = MemoryStore.dream_run_completed(
|
||||||
|
resp,
|
||||||
|
had_tool_errors=progress.had_tool_errors,
|
||||||
|
)
|
||||||
|
if completed:
|
||||||
|
store.set_last_dream_cursor(last_cursor)
|
||||||
|
if diff_body:
|
||||||
|
logger.info(
|
||||||
|
"Dream cron job completed, cursor advanced to {}",
|
||||||
|
last_cursor,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Dream cron job completed with no memory changes; "
|
||||||
|
"cursor advanced to {}",
|
||||||
|
last_cursor,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Dream cron job did not complete; cursor remains at {}",
|
||||||
|
store.get_last_dream_cursor(),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Dream cron job failed")
|
||||||
|
finally:
|
||||||
|
from nanobot.webui.token_usage import record_response_token_usage
|
||||||
|
|
||||||
|
record_response_token_usage(
|
||||||
|
resp,
|
||||||
|
source="dream",
|
||||||
|
timezone_name=config.agents.defaults.timezone,
|
||||||
|
)
|
||||||
|
sha = _commit_dream_changes(store)
|
||||||
|
if sha:
|
||||||
|
logger.info("Dream commit: {}", sha)
|
||||||
|
store.compact_history()
|
||||||
|
prune_dream_sessions(agent.sessions.sessions_dir)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Heartbeat is a system job that checks HEARTBEAT.md for active tasks.
|
||||||
|
if job.name == "heartbeat":
|
||||||
|
heartbeat_file = config.workspace_path / "HEARTBEAT.md"
|
||||||
|
try:
|
||||||
|
content = heartbeat_file.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
logger.debug("Heartbeat: HEARTBEAT.md missing")
|
||||||
|
return None
|
||||||
|
if not _heartbeat_has_active_tasks(content):
|
||||||
|
logger.debug("Heartbeat: HEARTBEAT.md has no active tasks")
|
||||||
|
return None
|
||||||
|
|
||||||
|
channel, chat_id = _pick_heartbeat_target()
|
||||||
|
if channel == "cli":
|
||||||
|
return None
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
_HEARTBEAT_PREAMBLE
|
||||||
|
+ f"You are executing periodic heartbeat tasks. Read the active tasks below, perform each one, and report what you did:\n\n{content}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Internal check: funnel all output through the post-run gate so the
|
||||||
|
# turn can't deliver directly via the message tool and skip it.
|
||||||
|
suppress_token = None
|
||||||
|
if isinstance(message_tool, MessageTool):
|
||||||
|
suppress_token = message_tool.set_suppress_delivery(True)
|
||||||
|
try:
|
||||||
|
resp = await agent.process_direct(
|
||||||
|
prompt,
|
||||||
|
session_key="heartbeat",
|
||||||
|
channel=channel,
|
||||||
|
chat_id=chat_id,
|
||||||
|
on_progress=_silent,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if isinstance(message_tool, MessageTool) and suppress_token is not None:
|
||||||
|
message_tool.reset_suppress_delivery(suppress_token)
|
||||||
|
|
||||||
|
# Keep a small tail of heartbeat history so the loop stays bounded.
|
||||||
|
session = agent.sessions.get_or_create("heartbeat")
|
||||||
|
session.retain_recent_legal_suffix(hb_cfg.keep_recent_messages)
|
||||||
|
agent.sessions.save(session)
|
||||||
|
|
||||||
|
if not resp or not resp.content:
|
||||||
|
return
|
||||||
|
|
||||||
|
response = resp.content
|
||||||
|
|
||||||
|
evaluator_prompt = resolve_evaluator_prompt(config.workspace_path)
|
||||||
|
|
||||||
|
# Fail closed: stay silent on evaluator failure instead of notifying.
|
||||||
|
should_notify = await evaluate_response(
|
||||||
|
response=response,
|
||||||
|
task_context=prompt,
|
||||||
|
provider=agent.provider,
|
||||||
|
model=agent.model,
|
||||||
|
evaluator_prompt=evaluator_prompt,
|
||||||
|
default_notify=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if should_notify:
|
||||||
|
logger.info("Heartbeat: completed, delivering response")
|
||||||
|
await _deliver_to_channel(
|
||||||
|
OutboundMessage(channel=channel, chat_id=chat_id, content=response),
|
||||||
|
record=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Heartbeat: silenced by post-run evaluation")
|
||||||
|
return response
|
||||||
|
|
||||||
|
if is_bound_cron_job(job):
|
||||||
|
return await run_bound_cron_job(job, agent=agent, cron=cron)
|
||||||
|
|
||||||
|
reason = "unbound agent cron job must be recreated from a chat session"
|
||||||
|
logger.warning(
|
||||||
|
"Cron: skipped unbound agent job '{}' ({}): {}",
|
||||||
|
job.name,
|
||||||
|
job.id,
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
raise CronJobSkippedError(reason)
|
||||||
|
|
||||||
|
cron.on_job = on_cron_job
|
||||||
|
|
||||||
|
def _webui_runtime_model_name() -> str | None:
|
||||||
|
return agent.model.strip() or None
|
||||||
|
|
||||||
|
def _webui_skill_state_action(disabled_skills: set[str]) -> None:
|
||||||
|
config.agents.defaults.disabled_skills = sorted(disabled_skills)
|
||||||
|
agent.context.skills.disabled_skills = set(disabled_skills)
|
||||||
|
agent.subagents.disabled_skills = set(disabled_skills)
|
||||||
|
|
||||||
|
# Create channel manager (forwards SessionManager so the WebSocket channel
|
||||||
|
# can serve the embedded webui's REST surface).
|
||||||
|
channels = ChannelManager(
|
||||||
|
config,
|
||||||
|
bus,
|
||||||
|
session_manager=session_manager,
|
||||||
|
cron_service=cron,
|
||||||
|
local_trigger_store=trigger_store,
|
||||||
|
webui_runtime_model_name=_webui_runtime_model_name,
|
||||||
|
webui_cron_pending_job_ids=agent.pending_cron_job_ids_for_session,
|
||||||
|
webui_local_trigger_pending_ids=agent.pending_local_trigger_ids_for_session,
|
||||||
|
webui_static_dist=webui_static_dist,
|
||||||
|
webui_runtime_surface=webui_runtime_surface,
|
||||||
|
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||||
|
webui_skill_state_action=_webui_skill_state_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||||
|
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
|
||||||
|
sidebar_state = read_webui_sidebar_state()
|
||||||
|
unified_metadata = None
|
||||||
|
if config.agents.defaults.unified_session:
|
||||||
|
record = session_manager.read_session_metadata(UNIFIED_SESSION_KEY)
|
||||||
|
if isinstance(record, dict) and isinstance(record.get("metadata"), dict):
|
||||||
|
unified_metadata = record["metadata"]
|
||||||
|
return _pick_heartbeat_target_from_sessions(
|
||||||
|
enabled_channels=channels.enabled_channels,
|
||||||
|
sessions=session_manager.list_sessions(),
|
||||||
|
archived_keys=sidebar_state.get("archived_keys", []),
|
||||||
|
unified_session_metadata=unified_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if channels.enabled_channels:
|
||||||
|
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
||||||
|
else:
|
||||||
|
console.print("[yellow]Warning: No channels enabled[/yellow]")
|
||||||
|
|
||||||
|
cron_status = cron.status()
|
||||||
|
cron_job_count = cast(int, cron_status["jobs"])
|
||||||
|
if cron_job_count > 0:
|
||||||
|
console.print(f"[green]✓[/green] Cron: {cron_job_count} scheduled jobs")
|
||||||
|
|
||||||
|
hb_cfg = config.gateway.heartbeat
|
||||||
|
if hb_cfg.enabled:
|
||||||
|
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
|
||||||
|
else:
|
||||||
|
console.print("[yellow]✗[/yellow] Heartbeat: disabled")
|
||||||
|
|
||||||
|
async def _health_server(host: str, health_port: int) -> None:
|
||||||
|
"""Lightweight HTTP health endpoint on the gateway port."""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
connection_slots = asyncio.Semaphore(_GATEWAY_HEALTH_MAX_CONNECTIONS)
|
||||||
|
|
||||||
|
async def handle(
|
||||||
|
reader: asyncio.StreamReader,
|
||||||
|
writer: asyncio.StreamWriter,
|
||||||
|
) -> None:
|
||||||
|
if connection_slots.locked():
|
||||||
|
writer.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
async with connection_slots:
|
||||||
|
try:
|
||||||
|
data = await asyncio.wait_for(
|
||||||
|
reader.read(4096),
|
||||||
|
timeout=_GATEWAY_HEALTH_READ_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
request_line = data.split(b"\r\n", 1)[0].decode(
|
||||||
|
"utf-8", errors="replace",
|
||||||
|
)
|
||||||
|
method, path = "", ""
|
||||||
|
parts = request_line.split(" ")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
method, path = parts[0], parts[1]
|
||||||
|
|
||||||
|
if method == "GET" and path == "/health":
|
||||||
|
body = _json.dumps({"status": "ok"})
|
||||||
|
status = "200 OK"
|
||||||
|
content_type = "application/json"
|
||||||
|
else:
|
||||||
|
body = "Not Found"
|
||||||
|
status = "404 Not Found"
|
||||||
|
content_type = "text/plain"
|
||||||
|
|
||||||
|
resp = (
|
||||||
|
f"HTTP/1.0 {status}\r\n"
|
||||||
|
f"Content-Type: {content_type}\r\n"
|
||||||
|
f"Content-Length: {len(body)}\r\n"
|
||||||
|
"Connection: close\r\n"
|
||||||
|
f"\r\n{body}"
|
||||||
|
)
|
||||||
|
writer.write(resp.encode())
|
||||||
|
await writer.drain()
|
||||||
|
except (asyncio.TimeoutError, ConnectionError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
server = await asyncio.start_server(handle, host, health_port)
|
||||||
|
_print_gateway_health_endpoint(host, health_port)
|
||||||
|
async with server:
|
||||||
|
await server.serve_forever()
|
||||||
|
# Register Dream system job (idempotent on restart)
|
||||||
|
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
|
||||||
|
dream_cfg = config.agents.defaults.dream
|
||||||
|
if dream_cfg.enabled:
|
||||||
|
cron.register_system_job(CronJob(
|
||||||
|
id="dream",
|
||||||
|
name="dream",
|
||||||
|
schedule=dream_cfg.build_schedule(config.agents.defaults.timezone),
|
||||||
|
payload=CronPayload(kind="system_event"),
|
||||||
|
))
|
||||||
|
console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}")
|
||||||
|
else:
|
||||||
|
console.print("[yellow]○[/yellow] Dream: disabled")
|
||||||
|
_advance_dream_cursor_if_behind(agent.context.memory)
|
||||||
|
|
||||||
|
# Register Heartbeat system job (idempotent on restart)
|
||||||
|
if hb_cfg.enabled:
|
||||||
|
cron.register_system_job(CronJob(
|
||||||
|
id="heartbeat",
|
||||||
|
name="heartbeat",
|
||||||
|
schedule=CronSchedule(
|
||||||
|
kind="every",
|
||||||
|
every_ms=hb_cfg.interval_s * 1000,
|
||||||
|
tz=config.agents.defaults.timezone,
|
||||||
|
),
|
||||||
|
payload=CronPayload(kind="system_event"),
|
||||||
|
))
|
||||||
|
|
||||||
|
async def _open_browser_when_ready() -> None:
|
||||||
|
"""Wait for the gateway to bind, then point the user's browser at the webui."""
|
||||||
|
if not open_browser_url:
|
||||||
|
return
|
||||||
|
import webbrowser
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(open_browser_url)
|
||||||
|
target_host = parsed.hostname or config.gateway.host or "127.0.0.1"
|
||||||
|
target_port = parsed.port or port
|
||||||
|
# Channels start asynchronously; a short poll lets us avoid racing the bind.
|
||||||
|
for _ in range(40): # ~4s max
|
||||||
|
try:
|
||||||
|
_reader, writer = await asyncio.open_connection(
|
||||||
|
target_host,
|
||||||
|
target_port,
|
||||||
|
)
|
||||||
|
writer.close()
|
||||||
|
with suppress(Exception):
|
||||||
|
await writer.wait_closed()
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
try:
|
||||||
|
webbrowser.open(open_browser_url)
|
||||||
|
console.print(f"[green]✓[/green] Opened browser at {open_browser_url}")
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[yellow]Could not open browser ({e}); visit {open_browser_url}[/yellow]")
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
tasks: list[asyncio.Task[Any]] = []
|
||||||
|
shutdown_task: asyncio.Task[Any] | None = None
|
||||||
|
runtime_tasks: asyncio.Future[list[Any]] | None = None
|
||||||
|
runtime_tasks_drained = False
|
||||||
|
shutdown_event = asyncio.Event()
|
||||||
|
cli_terminal._ensure_interactive_tty_mode()
|
||||||
|
restore_shutdown_handlers = _install_gateway_shutdown_handlers(
|
||||||
|
asyncio.get_running_loop(),
|
||||||
|
shutdown_event,
|
||||||
|
tasks,
|
||||||
|
console.print,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await cron.start()
|
||||||
|
# Re-read once on first admission to close the watcher subscription window.
|
||||||
|
agent.runtime_resolver.invalidate()
|
||||||
|
tasks = [
|
||||||
|
asyncio.create_task(
|
||||||
|
watch_config_file(
|
||||||
|
Path(config_path),
|
||||||
|
lambda: agent.invalidate_runtime_config(),
|
||||||
|
),
|
||||||
|
name="nanobot-config-watcher",
|
||||||
|
),
|
||||||
|
asyncio.create_task(agent.run(), name="nanobot-agent-loop"),
|
||||||
|
asyncio.create_task(channels.start_all(), name="nanobot-channels"),
|
||||||
|
asyncio.create_task(
|
||||||
|
run_local_trigger_queue(
|
||||||
|
store=trigger_store,
|
||||||
|
submit_turn=agent.submit_local_trigger_turn,
|
||||||
|
is_channel_enabled=lambda name: channels.get_channel(name) is not None,
|
||||||
|
),
|
||||||
|
name="nanobot-local-triggers",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if health_server_enabled:
|
||||||
|
tasks.append(asyncio.create_task(
|
||||||
|
_health_server(config.gateway.host, port),
|
||||||
|
name="nanobot-health-server",
|
||||||
|
))
|
||||||
|
if open_browser_url:
|
||||||
|
tasks.append(asyncio.create_task(
|
||||||
|
_open_browser_when_ready(),
|
||||||
|
name="nanobot-open-browser",
|
||||||
|
))
|
||||||
|
runtime_tasks = asyncio.gather(*tasks)
|
||||||
|
shutdown_task = asyncio.create_task(
|
||||||
|
shutdown_event.wait(),
|
||||||
|
name="nanobot-gateway-shutdown",
|
||||||
|
)
|
||||||
|
done, _pending = await asyncio.wait(
|
||||||
|
{runtime_tasks, shutdown_task},
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
if runtime_tasks in done:
|
||||||
|
runtime_tasks_drained = True
|
||||||
|
await runtime_tasks
|
||||||
|
else:
|
||||||
|
runtime_tasks.cancel()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
console.print("\nShutting down...")
|
||||||
|
except Exception:
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
console.print("\n[red]Error: Gateway crashed unexpectedly[/red]")
|
||||||
|
console.print(traceback.format_exc())
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if shutdown_task and not shutdown_task.done():
|
||||||
|
shutdown_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await shutdown_task
|
||||||
|
cron.stop()
|
||||||
|
agent.stop()
|
||||||
|
# Some SDKs swallow task cancellation while attempting to reconnect.
|
||||||
|
# Close channel transports before waiting for their runners to exit.
|
||||||
|
await channels.stop_all()
|
||||||
|
for task in tasks:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
if runtime_tasks is not None and not runtime_tasks_drained:
|
||||||
|
with suppress(asyncio.CancelledError, Exception):
|
||||||
|
await runtime_tasks
|
||||||
|
# Flush all cached sessions to durable storage before exit.
|
||||||
|
# This prevents data loss on filesystems with write-back
|
||||||
|
# caching (rclone VFS, NFS, FUSE mounts, etc.).
|
||||||
|
flushed = agent.sessions.flush_all()
|
||||||
|
if flushed:
|
||||||
|
logger.info("Shutdown: flushed {} session(s) to disk", flushed)
|
||||||
|
finally:
|
||||||
|
restore_shutdown_handlers()
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Runtime log visibility controls shared by CLI commands."""
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
__all__ = ["_set_nanobot_logs"]
|
||||||
|
|
||||||
|
|
||||||
|
def _set_nanobot_logs(enabled: bool) -> None:
|
||||||
|
if enabled:
|
||||||
|
logger.enable("nanobot")
|
||||||
|
else:
|
||||||
|
logger.disable("nanobot")
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"""Typer commands for OAuth provider authentication."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from contextlib import suppress
|
||||||
|
from importlib import import_module
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Protocol, cast
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from nanobot import __logo__
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.providers.registry import ProviderSpec
|
||||||
|
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
provider_app = typer.Typer(help="Manage providers")
|
||||||
|
|
||||||
|
_PROVIDER_DISPLAY: dict[str, str] = {
|
||||||
|
"openai_codex": "OpenAI Codex",
|
||||||
|
"xai_grok": "xAI Grok",
|
||||||
|
"github_copilot": "GitHub Copilot",
|
||||||
|
}
|
||||||
|
|
||||||
|
_OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
|
||||||
|
"openai_codex": "openai-codex/gpt-5.6-sol",
|
||||||
|
"xai_grok": "xai-grok/grok-4.5",
|
||||||
|
"github_copilot": "github-copilot/gpt-5.4-mini",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _OAuthToken(Protocol):
|
||||||
|
access: str | None
|
||||||
|
account_id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class _GetOAuthToken(Protocol):
|
||||||
|
def __call__(self, *, proxy: str | None = None) -> _OAuthToken | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class _LoginOAuthInteractive(Protocol):
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
print_fn: Callable[[str], None],
|
||||||
|
prompt_fn: Callable[[str], str],
|
||||||
|
proxy: str | None = None,
|
||||||
|
) -> _OAuthToken | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class _OAuthProviderConfig(Protocol):
|
||||||
|
token_filename: str
|
||||||
|
|
||||||
|
|
||||||
|
class _TokenStorage(Protocol):
|
||||||
|
def get_token_path(self) -> Path: ...
|
||||||
|
|
||||||
|
|
||||||
|
class _FileTokenStorageFactory(Protocol):
|
||||||
|
def __call__(self, *, token_filename: str) -> _TokenStorage: ...
|
||||||
|
|
||||||
|
|
||||||
|
def _required_module_attribute(module_name: str, attribute: str) -> object:
|
||||||
|
"""Load an optional dependency attribute with import-compatible errors."""
|
||||||
|
module = import_module(module_name)
|
||||||
|
try:
|
||||||
|
return getattr(module, attribute)
|
||||||
|
except AttributeError as exc:
|
||||||
|
raise ImportError(f"{module_name}.{attribute} is unavailable") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _load_openai_oauth_client() -> tuple[_GetOAuthToken, _LoginOAuthInteractive]:
|
||||||
|
"""Load the optional untyped OAuth client behind a typed boundary."""
|
||||||
|
return (
|
||||||
|
cast(_GetOAuthToken, _required_module_attribute("oauth_cli_kit", "get_token")),
|
||||||
|
cast(
|
||||||
|
_LoginOAuthInteractive,
|
||||||
|
_required_module_attribute("oauth_cli_kit", "login_oauth_interactive"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_openai_oauth_storage() -> tuple[_OAuthProviderConfig, _FileTokenStorageFactory]:
|
||||||
|
"""Load the optional untyped OAuth storage API behind a typed boundary."""
|
||||||
|
return (
|
||||||
|
cast(
|
||||||
|
_OAuthProviderConfig,
|
||||||
|
_required_module_attribute(
|
||||||
|
"oauth_cli_kit.providers",
|
||||||
|
"OPENAI_CODEX_PROVIDER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cast(
|
||||||
|
_FileTokenStorageFactory,
|
||||||
|
_required_module_attribute("oauth_cli_kit.storage", "FileTokenStorage"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_oauth_provider(provider: str) -> ProviderSpec:
|
||||||
|
"""Resolve and validate an OAuth provider configuration."""
|
||||||
|
from nanobot.providers.registry import PROVIDERS
|
||||||
|
|
||||||
|
key = provider.replace("-", "_")
|
||||||
|
spec = next((s for s in PROVIDERS if s.name == key and s.is_oauth), None)
|
||||||
|
if not spec:
|
||||||
|
names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth)
|
||||||
|
console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
return spec
|
||||||
|
|
||||||
|
|
||||||
|
def _set_oauth_provider_as_main(
|
||||||
|
provider_name: str,
|
||||||
|
*,
|
||||||
|
model: str | None = None,
|
||||||
|
config_path: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Persist an OAuth provider as the active agent provider."""
|
||||||
|
from nanobot.config.loader import get_config_path, load_config, save_config, set_config_path
|
||||||
|
|
||||||
|
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
|
||||||
|
if resolved_config_path is not None and get_config_path() != resolved_config_path:
|
||||||
|
set_config_path(resolved_config_path)
|
||||||
|
console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
|
||||||
|
|
||||||
|
config = load_config(resolved_config_path)
|
||||||
|
selected_model = (model or "").strip() or _OAUTH_PROVIDER_DEFAULT_MODELS[provider_name]
|
||||||
|
config.agents.defaults.model_preset = None
|
||||||
|
config.agents.defaults.provider = provider_name
|
||||||
|
config.agents.defaults.model = selected_model
|
||||||
|
if provider_name == "xai_grok" and selected_model == "xai-grok/grok-4.5":
|
||||||
|
config.agents.defaults.context_window_tokens = 500_000
|
||||||
|
save_config(config, resolved_config_path)
|
||||||
|
|
||||||
|
saved_path = resolved_config_path or get_config_path()
|
||||||
|
console.print(
|
||||||
|
f"[green]✓ Set {provider_name.replace('_', '-')} as the main provider[/green] "
|
||||||
|
f"[dim]{selected_model}[/dim]"
|
||||||
|
)
|
||||||
|
console.print(f"[dim]Saved: {saved_path}[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
@provider_app.command("login")
|
||||||
|
def provider_login(
|
||||||
|
provider: str = typer.Argument(
|
||||||
|
...,
|
||||||
|
help="OAuth provider (e.g. 'openai-codex', 'xai-grok', 'github-copilot')",
|
||||||
|
),
|
||||||
|
set_main: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--set-main",
|
||||||
|
"--main",
|
||||||
|
help="Set this OAuth provider as the active agent provider after login",
|
||||||
|
),
|
||||||
|
model: str | None = typer.Option(
|
||||||
|
None,
|
||||||
|
"--model",
|
||||||
|
"-m",
|
||||||
|
help="Model to use when setting this provider as the active provider",
|
||||||
|
),
|
||||||
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||||
|
):
|
||||||
|
"""Authenticate with an OAuth provider."""
|
||||||
|
spec = _resolve_oauth_provider(provider)
|
||||||
|
|
||||||
|
handler = _LOGIN_HANDLERS.get(spec.name)
|
||||||
|
if not handler:
|
||||||
|
console.print(f"[red]Login not implemented for {spec.label}[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
if config:
|
||||||
|
from nanobot.config.loader import set_config_path
|
||||||
|
|
||||||
|
resolved_config_path = Path(config).expanduser().resolve()
|
||||||
|
set_config_path(resolved_config_path)
|
||||||
|
console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
|
||||||
|
|
||||||
|
console.print(f"{__logo__} OAuth Login - {spec.label}\n")
|
||||||
|
handler()
|
||||||
|
if set_main or model:
|
||||||
|
_set_oauth_provider_as_main(spec.name, model=model, config_path=config)
|
||||||
|
|
||||||
|
|
||||||
|
@provider_app.command("logout")
|
||||||
|
def provider_logout(
|
||||||
|
provider: str = typer.Argument(
|
||||||
|
...,
|
||||||
|
help="OAuth provider (e.g. 'openai-codex', 'xai-grok', 'github-copilot')",
|
||||||
|
),
|
||||||
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||||
|
):
|
||||||
|
"""Log out from an OAuth provider."""
|
||||||
|
spec = _resolve_oauth_provider(provider)
|
||||||
|
|
||||||
|
handler = _LOGOUT_HANDLERS.get(spec.name)
|
||||||
|
if not handler:
|
||||||
|
console.print(f"[red]Logout not implemented for {spec.label}[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
if config:
|
||||||
|
from nanobot.config.loader import set_config_path
|
||||||
|
|
||||||
|
resolved_config_path = Path(config).expanduser().resolve()
|
||||||
|
set_config_path(resolved_config_path)
|
||||||
|
console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
|
||||||
|
|
||||||
|
console.print(f"{__logo__} OAuth Logout - {spec.label}\n")
|
||||||
|
handler()
|
||||||
|
|
||||||
|
|
||||||
|
def _login_openai_codex() -> None:
|
||||||
|
try:
|
||||||
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
|
|
||||||
|
get_token, login_oauth_interactive = _load_openai_oauth_client()
|
||||||
|
proxy = None
|
||||||
|
try:
|
||||||
|
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
||||||
|
except ValueError as e:
|
||||||
|
console.print(f"[red]{e}[/red]")
|
||||||
|
raise typer.Exit(1) from e
|
||||||
|
token = None
|
||||||
|
with suppress(Exception):
|
||||||
|
token = get_token(proxy=proxy)
|
||||||
|
if not (token and token.access):
|
||||||
|
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
|
||||||
|
token = login_oauth_interactive(
|
||||||
|
print_fn=lambda s: console.print(s),
|
||||||
|
prompt_fn=lambda s: typer.prompt(s),
|
||||||
|
proxy=proxy,
|
||||||
|
)
|
||||||
|
if not (token and token.access):
|
||||||
|
console.print("[red]✗ Authentication failed[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
console.print(
|
||||||
|
f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]"
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _logout_openai_codex() -> None:
|
||||||
|
"""Clear local OAuth credentials for OpenAI Codex."""
|
||||||
|
try:
|
||||||
|
provider_config, storage_factory = _load_openai_oauth_storage()
|
||||||
|
except ImportError:
|
||||||
|
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
storage = storage_factory(token_filename=provider_config.token_filename)
|
||||||
|
_delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["openai_codex"])
|
||||||
|
|
||||||
|
|
||||||
|
def _login_xai_grok() -> None:
|
||||||
|
"""Authenticate with xAI using the Grok subscription OAuth contract."""
|
||||||
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
|
from nanobot.providers.xai_oauth import get_xai_oauth_token, login_xai_oauth
|
||||||
|
|
||||||
|
try:
|
||||||
|
proxy = resolve_config_env_vars(load_config()).providers.xai_grok.proxy or None
|
||||||
|
except ValueError as exc:
|
||||||
|
console.print(f"[red]{exc}[/red]")
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
|
token = None
|
||||||
|
with suppress(Exception):
|
||||||
|
token = get_xai_oauth_token(proxy=proxy)
|
||||||
|
if not (token and token.access):
|
||||||
|
console.print(
|
||||||
|
"[cyan]Starting xAI browser sign-in for your X Premium / Grok subscription...[/cyan]\n"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
token = login_xai_oauth(
|
||||||
|
print_fn=lambda message: console.print(message),
|
||||||
|
prompt_fn=lambda prompt: typer.prompt(prompt),
|
||||||
|
proxy=proxy,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
console.print(f"[red]Authentication error: {exc}[/red]")
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
account = token.account_id or "xAI account"
|
||||||
|
console.print(f"[green]✓ Authenticated with xAI[/green] [dim]{account}[/dim]")
|
||||||
|
console.print(
|
||||||
|
"[dim]Hosted X Search is enabled automatically when the selected model supports it.[/dim]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _logout_xai_grok() -> None:
|
||||||
|
"""Clear local xAI OAuth credentials for this nanobot instance."""
|
||||||
|
from nanobot.providers.xai_oauth import get_xai_oauth_storage_path, logout_xai_oauth
|
||||||
|
|
||||||
|
token_path = get_xai_oauth_storage_path()
|
||||||
|
provider_label = _PROVIDER_DISPLAY["xai_grok"]
|
||||||
|
if logout_xai_oauth():
|
||||||
|
console.print(f"[green]✓ Logged out from {provider_label}[/green]")
|
||||||
|
console.print(f"[dim]Removed: {token_path}[/dim]")
|
||||||
|
else:
|
||||||
|
console.print(f"[yellow]! No local OAuth credentials found for {provider_label}[/yellow]")
|
||||||
|
|
||||||
|
|
||||||
|
def _logout_github_copilot() -> None:
|
||||||
|
"""Clear local OAuth credentials for GitHub Copilot."""
|
||||||
|
try:
|
||||||
|
from nanobot.providers.github_copilot_provider import get_storage
|
||||||
|
except ImportError:
|
||||||
|
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
storage = get_storage()
|
||||||
|
_delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["github_copilot"])
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_oauth_files(token_path: Path, provider_label: str) -> None:
|
||||||
|
"""Delete OAuth token and lock files, reporting the result."""
|
||||||
|
removed_paths: list[Path] = []
|
||||||
|
skipped: list[tuple[Path, OSError]] = []
|
||||||
|
for path in (token_path, token_path.with_suffix(".lock")):
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
except OSError as exc:
|
||||||
|
skipped.append((path, exc))
|
||||||
|
continue
|
||||||
|
removed_paths.append(path)
|
||||||
|
|
||||||
|
if not removed_paths and not skipped:
|
||||||
|
console.print(f"[yellow]! No local OAuth credentials found for {provider_label}[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
if removed_paths:
|
||||||
|
console.print(f"[green]✓ Logged out from {provider_label}[/green]")
|
||||||
|
for path in removed_paths:
|
||||||
|
console.print(f"[dim]Removed: {path}[/dim]")
|
||||||
|
for path, exc in skipped:
|
||||||
|
console.print(f"[yellow]! Could not remove {path}: {exc}[/yellow]")
|
||||||
|
|
||||||
|
|
||||||
|
def _login_github_copilot() -> None:
|
||||||
|
try:
|
||||||
|
from nanobot.providers.github_copilot_provider import login_github_copilot
|
||||||
|
|
||||||
|
console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n")
|
||||||
|
token = login_github_copilot(
|
||||||
|
print_fn=lambda s: console.print(s),
|
||||||
|
prompt_fn=lambda s: typer.prompt(s),
|
||||||
|
)
|
||||||
|
account = token.account_id or "GitHub"
|
||||||
|
console.print(
|
||||||
|
f"[green]✓ Authenticated with GitHub Copilot[/green] [dim]{account}[/dim]"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Authentication error: {e}[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
_LOGIN_HANDLERS: dict[str, Callable[[], None]] = {
|
||||||
|
"openai_codex": _login_openai_codex,
|
||||||
|
"xai_grok": _login_xai_grok,
|
||||||
|
"github_copilot": _login_github_copilot,
|
||||||
|
}
|
||||||
|
_LOGOUT_HANDLERS: dict[str, Callable[[], None]] = {
|
||||||
|
"openai_codex": _logout_openai_codex,
|
||||||
|
"xai_grok": _logout_xai_grok,
|
||||||
|
"github_copilot": _logout_github_copilot,
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""Configuration loading and diagnostics shared by CLI commands."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.markup import escape
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"_load_config_for_cli",
|
||||||
|
"_load_inspection_config",
|
||||||
|
"_load_runtime_config",
|
||||||
|
"_migrate_cron_store",
|
||||||
|
"_model_display",
|
||||||
|
"_print_agent_start_error",
|
||||||
|
"_print_config_error",
|
||||||
|
"_print_model_setup_steps",
|
||||||
|
"_print_runtime_config_validation_error",
|
||||||
|
"_provider_setup_error",
|
||||||
|
]
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
def _model_display(config: Config) -> tuple[str, str]:
|
||||||
|
"""Return (resolved_model_name, preset_tag) for display strings."""
|
||||||
|
resolved = config.resolve_preset()
|
||||||
|
name = config.agents.defaults.model_preset
|
||||||
|
tag = f" (preset: {name})" if name else ""
|
||||||
|
return resolved.model, tag
|
||||||
|
|
||||||
|
|
||||||
|
def _print_config_error(error: Exception) -> None:
|
||||||
|
"""Render a configuration failure without exposing traceback internals."""
|
||||||
|
from nanobot.config.errors import ConfigLoadError
|
||||||
|
|
||||||
|
console.print(Text(str(error), style="red"))
|
||||||
|
if isinstance(error, ConfigLoadError):
|
||||||
|
command = _status_command(error.path)
|
||||||
|
console.print(f"[dim]Check again after editing: {escape(command)}[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_runtime_config_validation_error(
|
||||||
|
error: ValidationError,
|
||||||
|
*,
|
||||||
|
config_path: Path,
|
||||||
|
summary: str,
|
||||||
|
path_prefix: tuple[str | int, ...],
|
||||||
|
retry_command: str,
|
||||||
|
) -> None:
|
||||||
|
"""Render a runtime-owned Pydantic config error without exposing input values."""
|
||||||
|
from nanobot.config.errors import ConfigIssue, ConfigLoadError, validation_issues
|
||||||
|
|
||||||
|
issues = tuple(
|
||||||
|
ConfigIssue(
|
||||||
|
path=(*path_prefix, *issue.path),
|
||||||
|
message=issue.message,
|
||||||
|
)
|
||||||
|
for issue in validation_issues(error)
|
||||||
|
)
|
||||||
|
diagnostic = ConfigLoadError(
|
||||||
|
config_path,
|
||||||
|
kind="invalid_schema",
|
||||||
|
summary=summary,
|
||||||
|
issues=issues,
|
||||||
|
)
|
||||||
|
console.print(Text(str(diagnostic), style="red"))
|
||||||
|
console.print(f"[dim]Fix the listed setting, then retry: {escape(retry_command)}[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
def _status_command(config_path: Path) -> str:
|
||||||
|
return f'nanobot status --config "{config_path}"'
|
||||||
|
|
||||||
|
|
||||||
|
def _print_model_setup_steps(config_path: Path) -> None:
|
||||||
|
"""Show the shortest setup routes shared by Status and Agent startup."""
|
||||||
|
config_arg = f'--config "{config_path}"'
|
||||||
|
console.print(
|
||||||
|
f" WebUI: run [cyan]nanobot webui {escape(config_arg)}[/cyan], "
|
||||||
|
"then open Settings → Models"
|
||||||
|
)
|
||||||
|
console.print(f" CLI: run [cyan]nanobot onboard --wizard {escape(config_arg)}[/cyan]")
|
||||||
|
console.print(f" Check: [cyan]{escape(_status_command(config_path))}[/cyan]")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_agent_start_error(error: ValueError) -> None:
|
||||||
|
from nanobot.config.loader import get_config_path
|
||||||
|
|
||||||
|
console.print(Text(f"Agent cannot start: {error}", style="red"))
|
||||||
|
console.print("Complete provider/model setup:")
|
||||||
|
_print_model_setup_steps(get_config_path())
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config_for_cli(
|
||||||
|
config_path: Path | None = None,
|
||||||
|
*,
|
||||||
|
resolve_env: bool = False,
|
||||||
|
) -> Config:
|
||||||
|
"""Load CLI configuration and turn expected failures into a clean exit."""
|
||||||
|
from nanobot.config.errors import ConfigLoadError
|
||||||
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
|
|
||||||
|
try:
|
||||||
|
loaded = load_config(config_path)
|
||||||
|
if resolve_env:
|
||||||
|
loaded = resolve_config_env_vars(loaded)
|
||||||
|
return loaded
|
||||||
|
except ConfigLoadError as exc:
|
||||||
|
_print_config_error(exc)
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
|
||||||
|
"""Load config and optionally override the active workspace."""
|
||||||
|
from nanobot.config.loader import set_config_path
|
||||||
|
|
||||||
|
config_path = None
|
||||||
|
if config:
|
||||||
|
config_path = Path(config).expanduser().resolve()
|
||||||
|
if not config_path.exists():
|
||||||
|
console.print(f"[red]Error: Config file not found: {config_path}[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
set_config_path(config_path)
|
||||||
|
console.print(f"[dim]Using config: {config_path}[/dim]")
|
||||||
|
|
||||||
|
loaded = _load_config_for_cli(config_path, resolve_env=True)
|
||||||
|
if workspace:
|
||||||
|
loaded.agents.defaults.workspace = workspace
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
def _load_inspection_config(
|
||||||
|
config: str | None = None,
|
||||||
|
workspace: str | None = None,
|
||||||
|
) -> tuple[Path, Config]:
|
||||||
|
"""Load config for diagnostic commands without resolving secret env refs."""
|
||||||
|
from nanobot.config.errors import ConfigLoadError
|
||||||
|
from nanobot.config.loader import get_config_path, load_config, set_config_path
|
||||||
|
|
||||||
|
config_path = None
|
||||||
|
if config:
|
||||||
|
config_path = Path(config).expanduser().resolve(strict=False)
|
||||||
|
set_config_path(config_path)
|
||||||
|
console.print(f"[dim]Using config: {config_path}[/dim]")
|
||||||
|
|
||||||
|
display_path = config_path or get_config_path()
|
||||||
|
try:
|
||||||
|
loaded = load_config(config_path)
|
||||||
|
except ConfigLoadError as exc:
|
||||||
|
_print_config_error(exc)
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
if workspace:
|
||||||
|
loaded.agents.defaults.workspace = workspace
|
||||||
|
return display_path, loaded
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_cron_store(config: "Config") -> None:
|
||||||
|
"""One-time migration: move legacy global cron store into the workspace."""
|
||||||
|
from nanobot.config.paths import get_cron_dir
|
||||||
|
|
||||||
|
legacy_path = get_cron_dir() / "jobs.json"
|
||||||
|
new_path = config.workspace_path / "cron" / "jobs.json"
|
||||||
|
if legacy_path.is_file() and not new_path.exists():
|
||||||
|
new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.move(str(legacy_path), str(new_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_setup_error(config: Config) -> str | None:
|
||||||
|
"""Return a local provider/model configuration error, or None."""
|
||||||
|
from nanobot.providers.factory import validate_provider_setup
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_provider_setup(config)
|
||||||
|
except ValueError as exc:
|
||||||
|
return str(exc)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
"""Terminal input and rendering helpers for the interactive CLI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
from contextlib import nullcontext, suppress
|
||||||
|
from typing import Any, Literal, cast
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from prompt_toolkit import PromptSession, print_formatted_text
|
||||||
|
from prompt_toolkit.application import run_in_terminal
|
||||||
|
from prompt_toolkit.formatted_text import ANSI, HTML
|
||||||
|
from prompt_toolkit.history import FileHistory
|
||||||
|
from prompt_toolkit.key_binding import KeyBindings
|
||||||
|
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
|
||||||
|
from prompt_toolkit.keys import Keys
|
||||||
|
from prompt_toolkit.patch_stdout import patch_stdout
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.markdown import Markdown
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from nanobot import __logo__
|
||||||
|
from nanobot.bus.outbound_events import (
|
||||||
|
ProgressEvent,
|
||||||
|
RetryWaitEvent,
|
||||||
|
outbound_event_from_message,
|
||||||
|
)
|
||||||
|
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
|
||||||
|
from nanobot.utils.helpers import sanitize_surrogates as _sanitize_surrogates
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"_ReasoningBuffer",
|
||||||
|
"_ensure_interactive_tty_mode",
|
||||||
|
"_flush_cli_reasoning",
|
||||||
|
"_flush_pending_tty_input",
|
||||||
|
"_init_prompt_session",
|
||||||
|
"_is_exit_command",
|
||||||
|
"_maybe_print_interactive_progress",
|
||||||
|
"_print_agent_response",
|
||||||
|
"_print_cli_progress_line",
|
||||||
|
"_print_cli_reasoning",
|
||||||
|
"_print_interactive_response",
|
||||||
|
"_read_interactive_input_async",
|
||||||
|
"_restore_terminal",
|
||||||
|
]
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
|
||||||
|
_REASONING_SENTENCE_ENDINGS = (".", "!", "?", "。", "!", "?")
|
||||||
|
_REASONING_FLUSH_CHARS = 60
|
||||||
|
_prompt_session: PromptSession[str] | None = None
|
||||||
|
_saved_term_attrs: list[Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_interactive_tty_mode() -> None:
|
||||||
|
"""Restore interactive line input after a raw-mode TTY leak."""
|
||||||
|
try:
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
if not os.isatty(fd):
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
with suppress(Exception):
|
||||||
|
import termios
|
||||||
|
|
||||||
|
attrs = termios.tcgetattr(fd)
|
||||||
|
required_lflag = termios.ISIG | termios.ICANON | termios.ECHO
|
||||||
|
blocked_input_flags = getattr(termios, "IGNCR", 0) | getattr(termios, "INLCR", 0)
|
||||||
|
if (
|
||||||
|
(attrs[3] & required_lflag) == required_lflag
|
||||||
|
and attrs[0] & termios.ICRNL
|
||||||
|
and not attrs[0] & blocked_input_flags
|
||||||
|
):
|
||||||
|
return
|
||||||
|
attrs[0] = (attrs[0] | termios.ICRNL) & ~blocked_input_flags
|
||||||
|
attrs[3] |= required_lflag
|
||||||
|
termios.tcsetattr(fd, termios.TCSANOW, attrs)
|
||||||
|
termios.tcflush(fd, termios.TCIFLUSH)
|
||||||
|
logger.debug("Restored foreground gateway TTY mode")
|
||||||
|
|
||||||
|
|
||||||
|
class SafeFileHistory(FileHistory):
|
||||||
|
"""FileHistory subclass that sanitizes surrogate characters on write.
|
||||||
|
|
||||||
|
On Windows, special Unicode input (emoji, mixed-script) can produce
|
||||||
|
surrogate characters that crash prompt_toolkit's file write.
|
||||||
|
See issue #2846.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def store_string(self, string: str) -> None:
|
||||||
|
super().store_string(_sanitize_surrogates(string))
|
||||||
|
|
||||||
|
|
||||||
|
def _flush_pending_tty_input() -> None:
|
||||||
|
"""Drop unread keypresses typed while the model was generating output."""
|
||||||
|
try:
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
if not os.isatty(fd):
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
with suppress(Exception):
|
||||||
|
import termios
|
||||||
|
|
||||||
|
termios.tcflush(fd, termios.TCIFLUSH)
|
||||||
|
return
|
||||||
|
|
||||||
|
with suppress(Exception):
|
||||||
|
while True:
|
||||||
|
ready, _, _ = select.select([fd], [], [], 0)
|
||||||
|
if not ready:
|
||||||
|
break
|
||||||
|
if not os.read(fd, 4096):
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_terminal() -> None:
|
||||||
|
"""Restore terminal to its original state (echo, line buffering, etc.)."""
|
||||||
|
if _saved_term_attrs is None:
|
||||||
|
return
|
||||||
|
with suppress(Exception):
|
||||||
|
import termios
|
||||||
|
|
||||||
|
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _saved_term_attrs)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cli_key_bindings() -> KeyBindings:
|
||||||
|
"""Key bindings for the interactive prompt.
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
* Enter -> submit the current input (keeps the familiar
|
||||||
|
single-line Enter-to-send feel even though the buffer
|
||||||
|
is multiline-capable).
|
||||||
|
* Alt+Enter -> insert a newline for multi-line input.
|
||||||
|
* Shift+Enter -> insert a newline on terminals that emit the CSI-u
|
||||||
|
(kitty / fixterms) keyboard-protocol encoding for it.
|
||||||
|
"""
|
||||||
|
# prompt_toolkit does not recognize CSI-u, so register its Shift+Enter
|
||||||
|
# sequence as a best-effort addition without overriding existing mappings.
|
||||||
|
with suppress(Exception):
|
||||||
|
from prompt_toolkit.input import ansi_escape_sequences as _aes
|
||||||
|
|
||||||
|
_aes.ANSI_SEQUENCES.setdefault("\x1b[13;2u", Keys.ControlF3)
|
||||||
|
|
||||||
|
kb = KeyBindings()
|
||||||
|
|
||||||
|
@kb.add("enter")
|
||||||
|
def _(event: KeyPressEvent) -> None:
|
||||||
|
event.current_buffer.validate_and_handle()
|
||||||
|
|
||||||
|
@kb.add("escape", "enter") # Alt+Enter / Meta+Enter (ESC + CR, "\x1b\r")
|
||||||
|
def _(event: KeyPressEvent) -> None:
|
||||||
|
event.current_buffer.insert_text("\n")
|
||||||
|
|
||||||
|
# LF-as-Enter terminals send Alt+Enter as ESC + LF rather than ESC + CR.
|
||||||
|
@kb.add("escape", Keys.ControlJ) # Alt+Enter on LF-as-Enter terminals
|
||||||
|
def _(event: KeyPressEvent) -> None:
|
||||||
|
event.current_buffer.insert_text("\n")
|
||||||
|
|
||||||
|
@kb.add(Keys.ControlF3) # Shift+Enter on CSI-u capable terminals
|
||||||
|
def _(event: KeyPressEvent) -> None:
|
||||||
|
event.current_buffer.insert_text("\n")
|
||||||
|
|
||||||
|
return kb
|
||||||
|
|
||||||
|
|
||||||
|
def _init_prompt_session() -> None:
|
||||||
|
"""Create the prompt_toolkit session with persistent file history."""
|
||||||
|
global _prompt_session, _saved_term_attrs
|
||||||
|
|
||||||
|
# Save terminal state so we can restore it on exit
|
||||||
|
with suppress(Exception):
|
||||||
|
import termios
|
||||||
|
|
||||||
|
_saved_term_attrs = termios.tcgetattr(sys.stdin.fileno())
|
||||||
|
|
||||||
|
from nanobot.config.paths import get_cli_history_path
|
||||||
|
|
||||||
|
history_file = get_cli_history_path()
|
||||||
|
history_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
_prompt_session = PromptSession(
|
||||||
|
history=SafeFileHistory(str(history_file)),
|
||||||
|
enable_open_in_editor=False,
|
||||||
|
# Multiline-capable buffer; Enter still submits via the custom key
|
||||||
|
# bindings, while Alt+Enter adds a newline.
|
||||||
|
multiline=True,
|
||||||
|
key_bindings=_build_cli_key_bindings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_console() -> Console:
|
||||||
|
return Console(file=sys.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_interactive_ansi(render_fn: Callable[[Console], None]) -> str:
|
||||||
|
"""Render Rich output to ANSI so prompt_toolkit can print it safely."""
|
||||||
|
ansi_console = Console(
|
||||||
|
force_terminal=sys.stdout.isatty(),
|
||||||
|
color_system=cast(
|
||||||
|
Literal["auto", "standard", "256", "truecolor", "windows"],
|
||||||
|
console.color_system or "standard",
|
||||||
|
),
|
||||||
|
width=console.width,
|
||||||
|
)
|
||||||
|
with ansi_console.capture() as capture:
|
||||||
|
render_fn(ansi_console)
|
||||||
|
return capture.get()
|
||||||
|
|
||||||
|
|
||||||
|
def _print_agent_response(
|
||||||
|
response: str,
|
||||||
|
render_markdown: bool,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
show_header: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""Render assistant response with consistent terminal styling."""
|
||||||
|
console = _make_console()
|
||||||
|
content = response or ""
|
||||||
|
body = _response_renderable(content, render_markdown, metadata)
|
||||||
|
if show_header:
|
||||||
|
console.print()
|
||||||
|
console.print(f"[cyan]{__logo__} nanobot[/cyan]")
|
||||||
|
console.print(body)
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def _response_renderable(
|
||||||
|
content: str, render_markdown: bool, metadata: dict[str, Any] | None = None
|
||||||
|
) -> Text | Markdown:
|
||||||
|
"""Render plain-text command output without markdown collapsing newlines."""
|
||||||
|
if not render_markdown:
|
||||||
|
return Text(content)
|
||||||
|
if (metadata or {}).get("render_as") == "text":
|
||||||
|
return Text(content)
|
||||||
|
return Markdown(content)
|
||||||
|
|
||||||
|
|
||||||
|
async def _print_interactive_line(text: str) -> None:
|
||||||
|
"""Print async interactive updates with prompt_toolkit-safe Rich styling."""
|
||||||
|
|
||||||
|
def _write() -> None:
|
||||||
|
ansi = _render_interactive_ansi(lambda c: c.print(f" [dim]↳ {text}[/dim]"))
|
||||||
|
print_formatted_text(ANSI(ansi), end="")
|
||||||
|
|
||||||
|
await run_in_terminal(_write)
|
||||||
|
|
||||||
|
|
||||||
|
async def _print_interactive_response(
|
||||||
|
response: str,
|
||||||
|
render_markdown: bool,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Print async interactive replies with prompt_toolkit-safe Rich styling."""
|
||||||
|
|
||||||
|
def _write() -> None:
|
||||||
|
content = response or ""
|
||||||
|
|
||||||
|
def _render(target: Console) -> None:
|
||||||
|
target.print()
|
||||||
|
target.print(f"[cyan]{__logo__} nanobot[/cyan]")
|
||||||
|
target.print(_response_renderable(content, render_markdown, metadata))
|
||||||
|
target.print()
|
||||||
|
|
||||||
|
ansi = _render_interactive_ansi(_render)
|
||||||
|
print_formatted_text(ANSI(ansi), end="")
|
||||||
|
|
||||||
|
await run_in_terminal(_write)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_cli_progress_line(
|
||||||
|
text: str,
|
||||||
|
thinking: ThinkingSpinner | None,
|
||||||
|
renderer: StreamRenderer | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Print a CLI progress line, pausing the spinner if needed."""
|
||||||
|
if not text.strip():
|
||||||
|
return
|
||||||
|
target = renderer.console if renderer else console
|
||||||
|
pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext())
|
||||||
|
with pause:
|
||||||
|
if renderer:
|
||||||
|
renderer.ensure_header()
|
||||||
|
target.print(f" [dim]↳ {text}[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
class _ReasoningBuffer:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._text = ""
|
||||||
|
|
||||||
|
def add(self, text: str) -> str | None:
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
self._text += text
|
||||||
|
if self._should_flush(text):
|
||||||
|
return self.flush()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def flush(self) -> str | None:
|
||||||
|
text = self._text.strip()
|
||||||
|
self._text = ""
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._text = ""
|
||||||
|
|
||||||
|
def _should_flush(self, text: str) -> bool:
|
||||||
|
stripped = text.rstrip()
|
||||||
|
return (
|
||||||
|
"\n" in text
|
||||||
|
or stripped.endswith(_REASONING_SENTENCE_ENDINGS)
|
||||||
|
or len(self._text) >= _REASONING_FLUSH_CHARS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_cli_reasoning(
|
||||||
|
text: str,
|
||||||
|
thinking: ThinkingSpinner | None,
|
||||||
|
renderer: StreamRenderer | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Print reasoning/thinking content in a distinct style."""
|
||||||
|
if not text.strip():
|
||||||
|
return
|
||||||
|
target = renderer.console if renderer else console
|
||||||
|
pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext())
|
||||||
|
with pause:
|
||||||
|
if renderer:
|
||||||
|
renderer.ensure_header()
|
||||||
|
target.print(f"[dim italic]✻ {text}[/dim italic]")
|
||||||
|
|
||||||
|
|
||||||
|
def _flush_cli_reasoning(
|
||||||
|
reasoning_buffer: _ReasoningBuffer,
|
||||||
|
thinking: ThinkingSpinner | None,
|
||||||
|
renderer: StreamRenderer | None = None,
|
||||||
|
) -> None:
|
||||||
|
text = reasoning_buffer.flush()
|
||||||
|
if text:
|
||||||
|
_print_cli_reasoning(text, thinking, renderer)
|
||||||
|
|
||||||
|
|
||||||
|
async def _print_interactive_progress_line(
|
||||||
|
text: str,
|
||||||
|
thinking: ThinkingSpinner | None,
|
||||||
|
renderer: StreamRenderer | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Print an interactive progress line, pausing the spinner if needed."""
|
||||||
|
if not text.strip():
|
||||||
|
return
|
||||||
|
if renderer:
|
||||||
|
with renderer.pause_spinner():
|
||||||
|
renderer.ensure_header()
|
||||||
|
renderer.console.print(f" [dim]↳ {text}[/dim]")
|
||||||
|
else:
|
||||||
|
with thinking.pause() if thinking else nullcontext():
|
||||||
|
await _print_interactive_line(text)
|
||||||
|
|
||||||
|
|
||||||
|
async def _maybe_print_interactive_progress(
|
||||||
|
msg: Any,
|
||||||
|
thinking: ThinkingSpinner | None,
|
||||||
|
channels_config: Any,
|
||||||
|
renderer: StreamRenderer | None = None,
|
||||||
|
reasoning_buffer: _ReasoningBuffer | None = None,
|
||||||
|
) -> bool:
|
||||||
|
event = outbound_event_from_message(msg)
|
||||||
|
if isinstance(event, RetryWaitEvent):
|
||||||
|
await _print_interactive_progress_line(msg.content, thinking, renderer)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not isinstance(event, ProgressEvent):
|
||||||
|
return False
|
||||||
|
|
||||||
|
reasoning_buffer = reasoning_buffer or _ReasoningBuffer()
|
||||||
|
|
||||||
|
if event.reasoning_end:
|
||||||
|
if channels_config and not channels_config.show_reasoning:
|
||||||
|
reasoning_buffer.clear()
|
||||||
|
else:
|
||||||
|
_flush_cli_reasoning(reasoning_buffer, thinking, renderer)
|
||||||
|
return True
|
||||||
|
|
||||||
|
is_tool_hint = event.tool_hint
|
||||||
|
is_reasoning = event.reasoning or event.reasoning_delta
|
||||||
|
if is_reasoning:
|
||||||
|
if channels_config and not channels_config.show_reasoning:
|
||||||
|
reasoning_buffer.clear()
|
||||||
|
return True
|
||||||
|
text = reasoning_buffer.add(msg.content)
|
||||||
|
if text:
|
||||||
|
_print_cli_reasoning(text, thinking, renderer)
|
||||||
|
return True
|
||||||
|
if channels_config and is_tool_hint and not channels_config.send_tool_hints:
|
||||||
|
return True
|
||||||
|
if channels_config and not is_tool_hint and not channels_config.send_progress:
|
||||||
|
return True
|
||||||
|
|
||||||
|
await _print_interactive_progress_line(msg.content, thinking, renderer)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _is_exit_command(command: str) -> bool:
|
||||||
|
"""Return True when input should end interactive chat."""
|
||||||
|
return command.lower() in EXIT_COMMANDS
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_interactive_input_async() -> str:
|
||||||
|
"""Read user input using prompt_toolkit (handles paste, history, display).
|
||||||
|
|
||||||
|
prompt_toolkit natively handles:
|
||||||
|
- Multiline paste (bracketed paste mode)
|
||||||
|
- History navigation (up/down arrows)
|
||||||
|
- Clean display (no ghost characters or artifacts)
|
||||||
|
"""
|
||||||
|
if _prompt_session is None:
|
||||||
|
raise RuntimeError("Call _init_prompt_session() first")
|
||||||
|
try:
|
||||||
|
with patch_stdout():
|
||||||
|
return await _prompt_session.prompt_async(
|
||||||
|
HTML("<b fg='ansiblue'>You:</b> "),
|
||||||
|
)
|
||||||
|
except EOFError as exc:
|
||||||
|
raise KeyboardInterrupt from exc
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"""WebUI CLI command."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from nanobot.cli import terminal as cli_terminal
|
||||||
|
from nanobot.cli.gateway_runtime import _run_gateway
|
||||||
|
from nanobot.cli.runtime_config import (
|
||||||
|
_load_runtime_config,
|
||||||
|
_print_config_error,
|
||||||
|
_print_runtime_config_validation_error,
|
||||||
|
_provider_setup_error,
|
||||||
|
)
|
||||||
|
from nanobot.cli.webui_support import (
|
||||||
|
_attach_to_background_gateway,
|
||||||
|
_confirm_webui_action,
|
||||||
|
_ensure_local_webui_channel,
|
||||||
|
_gateway_health_bind_note,
|
||||||
|
_gateway_health_ready,
|
||||||
|
_gateway_health_url,
|
||||||
|
_gateway_instance_command,
|
||||||
|
_host_for_local_browser,
|
||||||
|
_load_webui_setup_config,
|
||||||
|
_open_webui_browser,
|
||||||
|
_prepare_webui_bundle_for_gateway,
|
||||||
|
_print_foreground_port_conflict,
|
||||||
|
_print_webui_foreground_lifecycle,
|
||||||
|
_resolve_webui_config_path,
|
||||||
|
_run_quick_start_for_webui,
|
||||||
|
_tcp_endpoint_reachable,
|
||||||
|
_warn_webui_bind_scope,
|
||||||
|
_webui_browser_url,
|
||||||
|
_webui_build_mode_for_interactive,
|
||||||
|
_webui_display_url,
|
||||||
|
_webui_endpoint_reachable,
|
||||||
|
)
|
||||||
|
from nanobot.config.paths import get_workspace_path
|
||||||
|
from nanobot.utils.helpers import sync_workspace_templates
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
def webui(
|
||||||
|
port: int | None = typer.Option(None, "--port", "-p", help="WebUI port"),
|
||||||
|
gateway_port: int | None = typer.Option(
|
||||||
|
None,
|
||||||
|
"--gateway-port",
|
||||||
|
help="Gateway health port",
|
||||||
|
),
|
||||||
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||||
|
background: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--background",
|
||||||
|
help="Keep the gateway running after this command exits",
|
||||||
|
),
|
||||||
|
no_open: bool = typer.Option(False, "--no-open", help="Do not open a browser"),
|
||||||
|
yes: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--yes",
|
||||||
|
"-y",
|
||||||
|
help="Apply safe local WebUI defaults without prompting",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Prepare the local WebUI, start the gateway, and open the browser workbench."""
|
||||||
|
from nanobot.config.loader import resolve_config_env_vars, save_config
|
||||||
|
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
|
||||||
|
|
||||||
|
cli_terminal._ensure_interactive_tty_mode()
|
||||||
|
config_path = _resolve_webui_config_path(config)
|
||||||
|
created_config = not config_path.exists()
|
||||||
|
if created_config:
|
||||||
|
console.print(f"[yellow]No config found at {config_path}.[/yellow]")
|
||||||
|
_confirm_webui_action("Create a nanobot config and workspace now?", yes=yes)
|
||||||
|
|
||||||
|
setup_config = _load_webui_setup_config(config_path)
|
||||||
|
if workspace:
|
||||||
|
setup_config.agents.defaults.workspace = workspace
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved_setup_config = resolve_config_env_vars(
|
||||||
|
setup_config.model_copy(deep=True),
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
_print_config_error(exc)
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
|
provider_error = _provider_setup_error(resolved_setup_config)
|
||||||
|
settings_setup_error = provider_error if provider_error and created_config else None
|
||||||
|
if settings_setup_error:
|
||||||
|
console.print(f"[yellow]Model setup is incomplete: {provider_error}[/yellow]")
|
||||||
|
console.print("Configure a provider and model in WebUI Settings → Models.")
|
||||||
|
if background:
|
||||||
|
console.print(
|
||||||
|
"[red]First-time WebUI setup must run in the foreground. "
|
||||||
|
"Run `nanobot webui` without --background.[/red]"
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
elif provider_error:
|
||||||
|
console.print(f"[dim]Provider check: {provider_error}[/dim]")
|
||||||
|
setup_config = _run_quick_start_for_webui(
|
||||||
|
setup_config,
|
||||||
|
yes=yes,
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
if workspace:
|
||||||
|
setup_config.agents.defaults.workspace = workspace
|
||||||
|
|
||||||
|
try:
|
||||||
|
changed_webui, generated_bootstrap_secret = _ensure_local_webui_channel(
|
||||||
|
setup_config,
|
||||||
|
port=port,
|
||||||
|
yes=yes,
|
||||||
|
)
|
||||||
|
_warn_webui_bind_scope(setup_config)
|
||||||
|
webui_url = _webui_browser_url(setup_config)
|
||||||
|
except ValidationError as exc:
|
||||||
|
retry_command = f'nanobot webui --config "{config_path}"'
|
||||||
|
_print_runtime_config_validation_error(
|
||||||
|
exc,
|
||||||
|
config_path=config_path,
|
||||||
|
summary="WebUI configuration is invalid.",
|
||||||
|
path_prefix=("channels", "websocket"),
|
||||||
|
retry_command=retry_command,
|
||||||
|
)
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
console.print(f"[red]Error: invalid WebUI channel config: {exc}[/red]")
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
|
if created_config or provider_error or changed_webui or workspace:
|
||||||
|
save_config(setup_config, config_path)
|
||||||
|
console.print(f"[green]✓[/green] Saved config: {config_path}")
|
||||||
|
|
||||||
|
workspace_path = get_workspace_path(setup_config.workspace_path)
|
||||||
|
workspace_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
sync_workspace_templates(workspace_path)
|
||||||
|
|
||||||
|
runtime_config = _load_runtime_config(str(config_path), workspace)
|
||||||
|
effective_gateway_port = gateway_port if gateway_port is not None else runtime_config.gateway.port
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print(f"WebUI: [cyan]{_webui_display_url(webui_url)}[/cyan]")
|
||||||
|
gateway_health_url = _gateway_health_url(
|
||||||
|
runtime_config.gateway.host,
|
||||||
|
effective_gateway_port,
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
f"Gateway health: [cyan]{gateway_health_url}[/cyan]"
|
||||||
|
f"{_gateway_health_bind_note(runtime_config.gateway.host)}"
|
||||||
|
)
|
||||||
|
if no_open:
|
||||||
|
console.print("[dim]Browser opening disabled by --no-open.[/dim]")
|
||||||
|
if generated_bootstrap_secret:
|
||||||
|
console.print(
|
||||||
|
"[yellow]A WebUI bootstrap secret was generated and saved in this config.[/yellow]"
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
"[dim]Open the WebUI and enter channels.websocket.tokenIssueSecret from "
|
||||||
|
f"{config_path}, or rerun without --no-open to open the authenticated URL.[/dim]"
|
||||||
|
)
|
||||||
|
|
||||||
|
webui_bundle_mode = _webui_build_mode_for_interactive(yes=yes)
|
||||||
|
|
||||||
|
config_arg = str(config_path)
|
||||||
|
workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
|
||||||
|
runtime = GatewayRuntime(
|
||||||
|
paths=GatewayRuntimePaths.for_instance(
|
||||||
|
data_dir=config_path.parent,
|
||||||
|
workspace=workspace_arg,
|
||||||
|
config_path=config_arg,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
start_options = GatewayStartOptions(
|
||||||
|
port=effective_gateway_port,
|
||||||
|
workspace=workspace_arg,
|
||||||
|
config_path=config_arg,
|
||||||
|
)
|
||||||
|
|
||||||
|
if background:
|
||||||
|
_prepare_webui_bundle_for_gateway(runtime_config, mode=webui_bundle_mode)
|
||||||
|
result = runtime.start_background(start_options)
|
||||||
|
restarted = False
|
||||||
|
restart_attempted = False
|
||||||
|
if not result.ok and result.message == "gateway_already_running" and changed_webui:
|
||||||
|
restart_attempted = True
|
||||||
|
console.print("[yellow]WebUI config changed; restarting the background gateway.[/yellow]")
|
||||||
|
result = runtime.restart(start_options, timeout_s=20)
|
||||||
|
restarted = result.ok
|
||||||
|
if not result.ok and (restart_attempted or result.message != "gateway_already_running"):
|
||||||
|
action = "restarted" if restart_attempted else "started"
|
||||||
|
console.print(f"[yellow]Gateway was not {action}: {result.message}[/yellow]")
|
||||||
|
console.print(f"Logs: {result.status.log_path}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
if restarted:
|
||||||
|
console.print("[green]Gateway restarted in the background.[/green]")
|
||||||
|
elif result.ok:
|
||||||
|
console.print("[green]Gateway started in the background.[/green]")
|
||||||
|
else:
|
||||||
|
console.print("[yellow]Gateway is already running in the background.[/yellow]")
|
||||||
|
console.print(
|
||||||
|
"Manage this instance: "
|
||||||
|
f"[cyan]{_gateway_instance_command('status', config_path=config_path, workspace=workspace)}[/cyan]"
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
"View logs: "
|
||||||
|
f"[cyan]{_gateway_instance_command('logs', config_path=config_path, workspace=workspace)}[/cyan]"
|
||||||
|
)
|
||||||
|
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
|
||||||
|
console.print(
|
||||||
|
"Stop nanobot: "
|
||||||
|
f"[cyan]{_gateway_instance_command('stop', config_path=config_path, workspace=workspace)}[/cyan]"
|
||||||
|
)
|
||||||
|
if not no_open:
|
||||||
|
_open_webui_browser(webui_url)
|
||||||
|
return
|
||||||
|
|
||||||
|
gateway_ready = _gateway_health_ready(runtime_config.gateway.host, effective_gateway_port)
|
||||||
|
webui_ready = _webui_endpoint_reachable(webui_url)
|
||||||
|
if gateway_ready and webui_ready:
|
||||||
|
console.print("[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]")
|
||||||
|
console.print(
|
||||||
|
"Restart the gateway if you need it to pick up local source changes: "
|
||||||
|
f"[cyan]{_gateway_instance_command('restart', config_path=config_path, workspace=workspace)}[/cyan]"
|
||||||
|
)
|
||||||
|
if not no_open:
|
||||||
|
_open_webui_browser(webui_url, wait=False)
|
||||||
|
if runtime.status().running:
|
||||||
|
_attach_to_background_gateway(runtime)
|
||||||
|
else:
|
||||||
|
console.print(
|
||||||
|
"[yellow]This gateway is controlled by another foreground command. "
|
||||||
|
"Stop it from that terminal.[/yellow]"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
gateway_port_taken = gateway_ready or _tcp_endpoint_reachable(
|
||||||
|
_host_for_local_browser(runtime_config.gateway.host),
|
||||||
|
effective_gateway_port,
|
||||||
|
)
|
||||||
|
webui_port_taken = webui_ready
|
||||||
|
if gateway_port_taken or webui_port_taken:
|
||||||
|
_print_foreground_port_conflict(
|
||||||
|
webui_url=webui_url,
|
||||||
|
gateway_host=runtime_config.gateway.host,
|
||||||
|
gateway_port=effective_gateway_port,
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
_print_webui_foreground_lifecycle(attached=False)
|
||||||
|
_run_gateway(
|
||||||
|
runtime_config,
|
||||||
|
port=effective_gateway_port,
|
||||||
|
open_browser_url=None if no_open else webui_url,
|
||||||
|
webui_bundle_mode=webui_bundle_mode,
|
||||||
|
unconfigured_provider_error=settings_setup_error,
|
||||||
|
)
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
"""Shared WebUI setup, URL, health, and browser helpers."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.markup import escape
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from nanobot.cli.runtime_config import (
|
||||||
|
_load_config_for_cli,
|
||||||
|
_print_model_setup_steps,
|
||||||
|
_print_runtime_config_validation_error,
|
||||||
|
_provider_setup_error,
|
||||||
|
)
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.security.network import is_loopback_host
|
||||||
|
from nanobot.webui.build import (
|
||||||
|
BuildMode,
|
||||||
|
WebUIBuildError,
|
||||||
|
ensure_webui_bundle,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.gateway.runtime import GatewayRuntime
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"_attach_to_background_gateway",
|
||||||
|
"_confirm_webui_action",
|
||||||
|
"_ensure_local_webui_channel",
|
||||||
|
"_gateway_health_bind_note",
|
||||||
|
"_gateway_health_ready",
|
||||||
|
"_gateway_health_url",
|
||||||
|
"_gateway_instance_command",
|
||||||
|
"_host_for_local_browser",
|
||||||
|
"_load_webui_setup_config",
|
||||||
|
"_open_webui_browser",
|
||||||
|
"_prepare_webui_bundle_for_gateway",
|
||||||
|
"_print_foreground_port_conflict",
|
||||||
|
"_print_webui_foreground_lifecycle",
|
||||||
|
"_resolve_webui_config_path",
|
||||||
|
"_run_quick_start_for_webui",
|
||||||
|
"_tcp_endpoint_reachable",
|
||||||
|
"_validate_gateway_startup",
|
||||||
|
"_warn_webui_bind_scope",
|
||||||
|
"_webui_browser_url",
|
||||||
|
"_webui_build_mode_for_interactive",
|
||||||
|
"_webui_channel_enabled",
|
||||||
|
"_webui_display_url",
|
||||||
|
"_webui_endpoint_reachable",
|
||||||
|
]
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
def _confirm_webui_action(message: str, *, yes: bool) -> None:
|
||||||
|
"""Confirm a WebUI first-run mutation or fail clearly in non-interactive shells."""
|
||||||
|
if yes:
|
||||||
|
return
|
||||||
|
if not _cli_can_prompt():
|
||||||
|
console.print(
|
||||||
|
"[red]Error: WebUI setup needs confirmation. Re-run with --yes or use "
|
||||||
|
"`nanobot onboard --wizard`.[/red]"
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
if not typer.confirm(message, default=True):
|
||||||
|
console.print("[yellow]WebUI setup cancelled.[/yellow]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _cli_can_prompt() -> bool:
|
||||||
|
try:
|
||||||
|
return sys.stdin.isatty()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _webui_build_mode_for_interactive(*, yes: bool = False) -> BuildMode:
|
||||||
|
if yes:
|
||||||
|
return "auto"
|
||||||
|
return "prompt" if _cli_can_prompt() else "warn"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_webui_config_path(config: str | None) -> Path:
|
||||||
|
"""Resolve the config path used by ``nanobot webui`` and bind loader state."""
|
||||||
|
from nanobot.config.loader import get_config_path, set_config_path
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
return get_config_path()
|
||||||
|
config_path = Path(config).expanduser().resolve(strict=False)
|
||||||
|
set_config_path(config_path)
|
||||||
|
console.print(f"[dim]Using config: {config_path}[/dim]")
|
||||||
|
return config_path
|
||||||
|
|
||||||
|
|
||||||
|
def _load_webui_setup_config(config_path: Path) -> Config:
|
||||||
|
"""Load config for first-run mutation without resolving env-var placeholders."""
|
||||||
|
return _load_config_for_cli(config_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _webui_config_dict(config: Config) -> dict[str, Any]:
|
||||||
|
"""Return the current WebSocket config as a mutable alias-key dictionary."""
|
||||||
|
from nanobot.channels.websocket.runtime import WebSocketConfig
|
||||||
|
|
||||||
|
current: Any = getattr(config.channels, "websocket", None) or {}
|
||||||
|
model = WebSocketConfig.model_validate(current)
|
||||||
|
return model.model_dump(by_alias=True, exclude_none=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _webui_channel_enabled(config: Config) -> bool:
|
||||||
|
from nanobot.channels.websocket.runtime import WebSocketConfig
|
||||||
|
|
||||||
|
current: Any = getattr(config.channels, "websocket", None) or {}
|
||||||
|
return bool(WebSocketConfig.model_validate(current).enabled)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_gateway_startup(config: Config) -> str | None:
|
||||||
|
"""Validate gateway startup and return a provider error recoverable through WebUI."""
|
||||||
|
from nanobot.config.loader import get_config_path
|
||||||
|
|
||||||
|
config_path = get_config_path()
|
||||||
|
try:
|
||||||
|
webui_config = _webui_config_dict(config)
|
||||||
|
except ValidationError as exc:
|
||||||
|
retry_command = f'nanobot gateway --config "{config_path}"'
|
||||||
|
_print_runtime_config_validation_error(
|
||||||
|
exc,
|
||||||
|
config_path=config_path,
|
||||||
|
summary="Gateway configuration is invalid.",
|
||||||
|
path_prefix=("channels", "websocket"),
|
||||||
|
retry_command=retry_command,
|
||||||
|
)
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
|
provider_error = _provider_setup_error(config)
|
||||||
|
if not provider_error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if bool(webui_config["enabled"]):
|
||||||
|
console.print(
|
||||||
|
Text(f"Provider/model setup is incomplete: {provider_error}", style="yellow")
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
"Gateway will start so you can configure a provider and model "
|
||||||
|
"in WebUI Settings → Models."
|
||||||
|
)
|
||||||
|
browser_url = _webui_browser_url(config)
|
||||||
|
webui_url = browser_url.split("/#/", 1)[0]
|
||||||
|
console.print(Text(f"WebUI: {webui_url}", style="cyan"))
|
||||||
|
if browser_url != webui_url:
|
||||||
|
secret_key = (
|
||||||
|
"tokenIssueSecret"
|
||||||
|
if str(webui_config.get("tokenIssueSecret") or "").strip()
|
||||||
|
else "token"
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
Text(
|
||||||
|
f"If prompted, enter the configured channels.websocket.{secret_key} "
|
||||||
|
f"value (see {config_path}).",
|
||||||
|
style="dim",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return provider_error
|
||||||
|
|
||||||
|
console.print(Text(f"Gateway cannot start: {provider_error}", style="red"))
|
||||||
|
console.print("Complete provider/model setup:")
|
||||||
|
_print_model_setup_steps(config_path)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_webui_bundle_for_gateway(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
mode: BuildMode,
|
||||||
|
webui_static_dist: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""Refresh or warn about stale bundled WebUI assets before gateway startup."""
|
||||||
|
if not webui_static_dist or not _webui_channel_enabled(config):
|
||||||
|
return
|
||||||
|
|
||||||
|
def _print(message: str) -> None:
|
||||||
|
console.print(f"[yellow]{escape(message)}[/yellow]")
|
||||||
|
|
||||||
|
def _confirm(message: str) -> bool:
|
||||||
|
return typer.confirm(message, default=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ensure_webui_bundle(
|
||||||
|
mode=mode,
|
||||||
|
confirm=_confirm if mode == "prompt" else None,
|
||||||
|
output=_print,
|
||||||
|
)
|
||||||
|
except WebUIBuildError as exc:
|
||||||
|
if mode == "warn":
|
||||||
|
console.print(f"[yellow]Warning: {escape(str(exc))}[/yellow]")
|
||||||
|
return
|
||||||
|
console.print(f"[red]Error: {escape(str(exc))}[/red]")
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _host_for_local_browser(host: str) -> str:
|
||||||
|
"""Map bind hosts to a browser-openable local host."""
|
||||||
|
if host in {"0.0.0.0", ""}:
|
||||||
|
return "127.0.0.1"
|
||||||
|
if host == "::":
|
||||||
|
return "[::1]"
|
||||||
|
if ":" in host and not host.startswith("["):
|
||||||
|
return f"[{host}]"
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
def _gateway_health_url(host: str, port: int) -> str:
|
||||||
|
"""Return a health URL that can be opened from this device."""
|
||||||
|
return f"http://{_host_for_local_browser(host)}:{port}/health"
|
||||||
|
|
||||||
|
|
||||||
|
def _gateway_health_bind_note(host: str) -> str:
|
||||||
|
"""Describe a non-local bind without presenting it as a usable URL."""
|
||||||
|
return "" if is_loopback_host(host) else f" [dim](listening on {host})[/dim]"
|
||||||
|
|
||||||
|
|
||||||
|
def _webui_bootstrap_secret(config: Config) -> str:
|
||||||
|
ws_cfg = _webui_config_dict(config)
|
||||||
|
return str(ws_cfg.get("tokenIssueSecret") or ws_cfg.get("token") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _webui_browser_url(config: Config) -> str:
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
ws_cfg = _webui_config_dict(config)
|
||||||
|
host = _host_for_local_browser(str(ws_cfg.get("host") or "127.0.0.1"))
|
||||||
|
port = int(ws_cfg.get("port") or 8765)
|
||||||
|
base_url = f"http://{host}:{port}"
|
||||||
|
secret = _webui_bootstrap_secret(config)
|
||||||
|
if not secret:
|
||||||
|
return base_url
|
||||||
|
return f"{base_url}/#/?bootstrapSecret={quote(secret, safe='')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _webui_display_url(url: str) -> str:
|
||||||
|
marker = "bootstrapSecret="
|
||||||
|
if marker not in url:
|
||||||
|
return url
|
||||||
|
prefix, _ = url.split(marker, 1)
|
||||||
|
return f"{prefix}{marker}<redacted>"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_local_webui_channel(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
port: int | None,
|
||||||
|
yes: bool,
|
||||||
|
) -> tuple[bool, bool]:
|
||||||
|
"""Enable the local WebUI channel with safe localhost defaults."""
|
||||||
|
from nanobot.channels.websocket.runtime import WebSocketConfig
|
||||||
|
|
||||||
|
current: Any = getattr(config.channels, "websocket", None) or {}
|
||||||
|
model = WebSocketConfig.model_validate(current)
|
||||||
|
changed = False
|
||||||
|
generated_secret = False
|
||||||
|
|
||||||
|
needs_enable = not model.enabled
|
||||||
|
needs_port = port is not None and model.port != port
|
||||||
|
needs_secret = not model.token_issue_secret.strip() and not model.token.strip()
|
||||||
|
if not needs_enable and not needs_port and not needs_secret:
|
||||||
|
return False, False
|
||||||
|
|
||||||
|
target_port = port if port is not None else model.port
|
||||||
|
console.print()
|
||||||
|
console.print("[bold]Local WebUI setup[/bold]")
|
||||||
|
console.print(f" URL: [cyan]http://127.0.0.1:{target_port}[/cyan]")
|
||||||
|
console.print(" Bind: [cyan]127.0.0.1 only[/cyan] (not exposed to your LAN)")
|
||||||
|
console.print(" Auth: generated WebUI bootstrap secret stored in config")
|
||||||
|
console.print(
|
||||||
|
" LAN access requires an explicit host change plus a WebUI password in config."
|
||||||
|
)
|
||||||
|
_confirm_webui_action("Update the local WebUI channel in this config?", yes=yes)
|
||||||
|
|
||||||
|
if not model.enabled:
|
||||||
|
model.enabled = True
|
||||||
|
changed = True
|
||||||
|
if model.host != "127.0.0.1":
|
||||||
|
model.host = "127.0.0.1"
|
||||||
|
changed = True
|
||||||
|
if port is not None and model.port != port:
|
||||||
|
model.port = port
|
||||||
|
changed = True
|
||||||
|
if not model.websocket_requires_token:
|
||||||
|
model.websocket_requires_token = True
|
||||||
|
changed = True
|
||||||
|
if needs_secret:
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
model.token_issue_secret = secrets.token_urlsafe(32)
|
||||||
|
changed = True
|
||||||
|
generated_secret = True
|
||||||
|
|
||||||
|
setattr(config.channels, "websocket", model.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
return changed, generated_secret
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_webui_bind_scope(config: Config) -> None:
|
||||||
|
ws_cfg = _webui_config_dict(config)
|
||||||
|
host = str(ws_cfg.get("host") or "127.0.0.1")
|
||||||
|
if host in {"127.0.0.1", "localhost", "::1"}:
|
||||||
|
return
|
||||||
|
console.print(
|
||||||
|
"[yellow]Warning: WebUI is configured to bind outside localhost. "
|
||||||
|
"Keep tokenIssueSecret set and use this only on trusted networks.[/yellow]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_webui(url: str, *, timeout_s: float = 5.0) -> None:
|
||||||
|
"""Best-effort wait for the WebUI listener before opening a browser."""
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = parsed.hostname or "127.0.0.1"
|
||||||
|
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if _tcp_endpoint_reachable(host, port, timeout_s=0.2):
|
||||||
|
return
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def _tcp_endpoint_reachable(host: str, port: int, *, timeout_s: float = 0.25) -> bool:
|
||||||
|
"""Return whether a local TCP endpoint accepts connections."""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=timeout_s):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _gateway_health_ready(host: str, port: int, *, timeout_s: float = 0.4) -> bool:
|
||||||
|
"""Return whether the nanobot gateway health endpoint responds OK."""
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
browser_host = _host_for_local_browser(host)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
f"http://{browser_host}:{port}/health",
|
||||||
|
timeout=timeout_s,
|
||||||
|
) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
return False
|
||||||
|
body = response.read(1024)
|
||||||
|
except (OSError, urllib.error.URLError, TimeoutError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(body.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
return False
|
||||||
|
return payload.get("status") == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def _webui_endpoint_reachable(url: str, *, timeout_s: float = 0.25) -> bool:
|
||||||
|
"""Return whether the WebUI URL's TCP endpoint is already listening."""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = parsed.hostname or "127.0.0.1"
|
||||||
|
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||||
|
return _tcp_endpoint_reachable(host, port, timeout_s=timeout_s)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_foreground_port_conflict(
|
||||||
|
*,
|
||||||
|
webui_url: str,
|
||||||
|
gateway_host: str,
|
||||||
|
gateway_port: int,
|
||||||
|
) -> None:
|
||||||
|
console.print(
|
||||||
|
"[red]Error: nanobot cannot start because one of its local ports is already in use.[/red]"
|
||||||
|
)
|
||||||
|
console.print(f" WebUI: [cyan]{webui_url}[/cyan]")
|
||||||
|
console.print(
|
||||||
|
f" Gateway health: "
|
||||||
|
f"[cyan]http://{_host_for_local_browser(gateway_host)}:{gateway_port}/health[/cyan]"
|
||||||
|
)
|
||||||
|
console.print()
|
||||||
|
console.print("If this is an existing nanobot instance, use it or stop it first:")
|
||||||
|
console.print(" [cyan]nanobot gateway status[/cyan]")
|
||||||
|
console.print(" [cyan]nanobot gateway stop[/cyan]")
|
||||||
|
console.print(
|
||||||
|
"Or choose different ports with [cyan]--port[/cyan] "
|
||||||
|
"and [cyan]--gateway-port[/cyan]."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _open_webui_browser(url: str, *, wait: bool = True) -> None:
|
||||||
|
"""Open the WebUI in the user's default browser, with a copyable fallback."""
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
if wait:
|
||||||
|
_wait_for_webui(url)
|
||||||
|
display_url = _webui_display_url(url)
|
||||||
|
try:
|
||||||
|
webbrowser.open(url)
|
||||||
|
console.print(f"[green]✓[/green] Opened WebUI: [cyan]{display_url}[/cyan]")
|
||||||
|
except Exception as exc:
|
||||||
|
console.print(f"[yellow]Could not open browser ({exc}); visit {display_url}[/yellow]")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
|
||||||
|
"""Explain how the browser and gateway lifecycles differ."""
|
||||||
|
console.print()
|
||||||
|
if attached:
|
||||||
|
console.print("[green]nanobot is attached to the existing gateway.[/green]")
|
||||||
|
else:
|
||||||
|
console.print("[green]nanobot is running in this terminal.[/green]")
|
||||||
|
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
|
||||||
|
console.print("[dim]Press Ctrl+C here to stop nanobot.[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_to_background_gateway(runtime: "GatewayRuntime") -> None:
|
||||||
|
"""Keep a foreground WebUI command attached to a managed gateway."""
|
||||||
|
_print_webui_foreground_lifecycle(attached=True)
|
||||||
|
try:
|
||||||
|
while runtime.status().running:
|
||||||
|
time.sleep(0.5)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
console.print("\n[yellow]Stopping nanobot...[/yellow]")
|
||||||
|
result = runtime.stop()
|
||||||
|
if result.ok or result.message == "gateway_not_running":
|
||||||
|
console.print("[green]Gateway stopped.[/green]")
|
||||||
|
return
|
||||||
|
console.print(f"[red]Gateway could not be stopped: {result.message}[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
console.print("[yellow]Gateway stopped.[/yellow]")
|
||||||
|
|
||||||
|
|
||||||
|
def _gateway_instance_command(
|
||||||
|
subcommand: str,
|
||||||
|
*,
|
||||||
|
config_path: Path,
|
||||||
|
workspace: str | None,
|
||||||
|
) -> str:
|
||||||
|
"""Return a copyable gateway command for the same config/workspace instance."""
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
parts = ["nanobot", "gateway", subcommand, "--config", str(config_path)]
|
||||||
|
if workspace:
|
||||||
|
workspace_path = str(Path(workspace).expanduser().resolve(strict=False))
|
||||||
|
parts.extend(["--workspace", workspace_path])
|
||||||
|
return " ".join(shlex.quote(part) for part in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_quick_start_for_webui(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
yes: bool,
|
||||||
|
config_path: Path,
|
||||||
|
) -> Config:
|
||||||
|
"""Offer the existing Quick Start flow when provider setup is missing."""
|
||||||
|
if yes:
|
||||||
|
console.print(
|
||||||
|
"[red]Error: provider/model setup is incomplete, and --yes cannot answer "
|
||||||
|
"provider credentials.[/red]"
|
||||||
|
)
|
||||||
|
console.print("Complete provider/model setup:")
|
||||||
|
_print_model_setup_steps(config_path)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]Model provider setup is not ready.[/yellow]")
|
||||||
|
console.print(
|
||||||
|
"Quick Start will ask for provider, API key/base URL, model, and WebUI password."
|
||||||
|
)
|
||||||
|
_confirm_webui_action("Run Quick Start now?", yes=False)
|
||||||
|
|
||||||
|
from nanobot.cli.onboard import run_quick_start_onboard
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = run_quick_start_onboard(config)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
|
console.print(
|
||||||
|
"[yellow]Run `nanobot onboard --wizard` "
|
||||||
|
"after installing wizard dependencies.[/yellow]"
|
||||||
|
)
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
if not result.should_save:
|
||||||
|
console.print("[yellow]Quick Start cancelled. No changes were saved.[/yellow]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
return result.config
|
||||||
@@ -311,7 +311,7 @@ async def cmd_new(ctx: CommandContext) -> OutboundMessage:
|
|||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
loop.sessions.invalidate(session.key)
|
loop.sessions.invalidate(session.key)
|
||||||
if snapshot and runtime is not None:
|
if snapshot and runtime is not None:
|
||||||
loop._schedule_background( # pyright: ignore[reportPrivateUsage]
|
loop.schedule_background(
|
||||||
loop.consolidator.archive( # pyright: ignore[reportUnknownMemberType]
|
loop.consolidator.archive( # pyright: ignore[reportUnknownMemberType]
|
||||||
snapshot,
|
snapshot,
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
|
|||||||
+3
-37
@@ -5,9 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import AsyncIterator, Mapping
|
from collections.abc import AsyncIterator, Mapping
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from nanobot.agent.hook import AgentHook, SDKCaptureHook
|
from nanobot.agent.hook import AgentHook, SDKCaptureHook
|
||||||
from nanobot.agent.hooks import create_file_edit_activity_hook
|
from nanobot.agent.hooks import create_file_edit_activity_hook
|
||||||
@@ -41,9 +39,6 @@ from nanobot.sdk.types import (
|
|||||||
)
|
)
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from nanobot.resource_links import ResourceView
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Nanobot",
|
"Nanobot",
|
||||||
"RunResult",
|
"RunResult",
|
||||||
@@ -66,28 +61,6 @@ __all__ = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _prepare_resource_view(config: Config, config_path: Path) -> ResourceView | None:
|
|
||||||
"""Best-effort resource aliases scoped to this SDK instance's config."""
|
|
||||||
from nanobot.resource_links import ensure_resource_view
|
|
||||||
|
|
||||||
try:
|
|
||||||
# CLI entry points synchronize workspace templates before this step.
|
|
||||||
# The SDK has no equivalent bootstrap phase, so ensure the link target
|
|
||||||
# exists before preparing its alias.
|
|
||||||
config.workspace_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
view = ensure_resource_view(
|
|
||||||
data_dir=config_path.parent,
|
|
||||||
config_path=config_path,
|
|
||||||
agent_workspace=config.workspace_path,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Could not prepare the nanobot resource view: {}", exc)
|
|
||||||
return None
|
|
||||||
for warning in view.warnings:
|
|
||||||
logger.warning("Resource view: {}", warning)
|
|
||||||
return view
|
|
||||||
|
|
||||||
|
|
||||||
class Nanobot:
|
class Nanobot:
|
||||||
"""Programmatic facade for running the nanobot agent.
|
"""Programmatic facade for running the nanobot agent.
|
||||||
|
|
||||||
@@ -123,7 +96,7 @@ class Nanobot:
|
|||||||
model: Override the instance default model.
|
model: Override the instance default model.
|
||||||
model_preset: Override the instance default model preset.
|
model_preset: Override the instance default model preset.
|
||||||
"""
|
"""
|
||||||
from nanobot.config.loader import get_config_path, load_config, resolve_config_env_vars
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
|
|
||||||
ensure_single_model_selector(model=model, model_preset=model_preset)
|
ensure_single_model_selector(model=model, model_preset=model_preset)
|
||||||
resolved: Path | None = None
|
resolved: Path | None = None
|
||||||
@@ -132,14 +105,9 @@ class Nanobot:
|
|||||||
if not resolved.exists():
|
if not resolved.exists():
|
||||||
raise FileNotFoundError(f"Config not found: {resolved}")
|
raise FileNotFoundError(f"Config not found: {resolved}")
|
||||||
|
|
||||||
effective_config_path = (
|
|
||||||
resolved
|
|
||||||
if resolved is not None
|
|
||||||
else get_config_path().expanduser().resolve(strict=False)
|
|
||||||
)
|
|
||||||
config: Config = resolve_config_env_vars(
|
config: Config = resolve_config_env_vars(
|
||||||
load_config(resolved),
|
load_config(resolved),
|
||||||
config_path=effective_config_path,
|
config_path=resolved,
|
||||||
)
|
)
|
||||||
if workspace is not None:
|
if workspace is not None:
|
||||||
config.agents.defaults.workspace = str(
|
config.agents.defaults.workspace = str(
|
||||||
@@ -152,12 +120,10 @@ class Nanobot:
|
|||||||
elif model_preset is not None:
|
elif model_preset is not None:
|
||||||
config.agents.defaults.model_preset = model_preset
|
config.agents.defaults.model_preset = model_preset
|
||||||
|
|
||||||
resource_view = _prepare_resource_view(config, effective_config_path)
|
|
||||||
loop = AgentLoop.from_config(
|
loop = AgentLoop.from_config(
|
||||||
config,
|
config,
|
||||||
image_generation_provider_configs=image_gen_provider_configs(config),
|
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||||
hook_factories=[create_file_edit_activity_hook],
|
hook_factories=[create_file_edit_activity_hook],
|
||||||
resource_view=resource_view,
|
|
||||||
)
|
)
|
||||||
return cls(loop, config=config)
|
return cls(loop, config=config)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"""WebUI adapter around oauth-cli-kit's interactive Codex login."""
|
||||||
|
|
||||||
|
# oauth-cli-kit does not publish type stubs.
|
||||||
|
# pyright: reportMissingTypeStubs=false
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import queue
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import Future
|
||||||
|
from contextlib import suppress
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
from oauth_cli_kit import login_oauth_interactive
|
||||||
|
from oauth_cli_kit.models import OAuthToken
|
||||||
|
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
|
||||||
|
|
||||||
|
_AUTHORIZATION_URL_TIMEOUT_S = 5.0
|
||||||
|
_CALLBACK = urlsplit(OPENAI_CODEX_PROVIDER.redirect_uri)
|
||||||
|
_CALLBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||||
|
_TOKEN_EXCHANGE_STATUS = re.compile(r"Token exchange failed:\s*(\d{3})\b")
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAICodexOAuthError(RuntimeError):
|
||||||
|
"""An actionable Codex OAuth failure that contains no credential material."""
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAICodexOAuthInputError(OpenAICodexOAuthError):
|
||||||
|
"""A recoverable error in a callback URL pasted by the user."""
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAICodexOAuthLoginFlow:
|
||||||
|
"""Expose oauth-cli-kit's blocking prompt as a two-stage WebUI flow."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
proxy: str | None,
|
||||||
|
timeout_s: float,
|
||||||
|
open_browser: bool,
|
||||||
|
) -> None:
|
||||||
|
self.authorization_url = ""
|
||||||
|
self._expected_state = ""
|
||||||
|
self._proxy = proxy
|
||||||
|
self._open_browser = open_browser
|
||||||
|
self._expires_at = time.monotonic() + timeout_s
|
||||||
|
self._callback_input: queue.Queue[str] = queue.Queue(maxsize=1)
|
||||||
|
self._result: Future[OAuthToken] = Future()
|
||||||
|
self._ready = threading.Event()
|
||||||
|
self._submission_lock = threading.Lock()
|
||||||
|
self._submitted = False
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._run,
|
||||||
|
name="nanobot-openai-codex-oauth",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expired(self) -> bool:
|
||||||
|
return time.monotonic() >= self._expires_at
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remaining_seconds(self) -> int:
|
||||||
|
return max(0, int(self._expires_at - time.monotonic()))
|
||||||
|
|
||||||
|
def start(self) -> OpenAICodexOAuthLoginFlow:
|
||||||
|
self._thread.start()
|
||||||
|
wait_s = min(
|
||||||
|
_AUTHORIZATION_URL_TIMEOUT_S,
|
||||||
|
max(0.0, self._expires_at - time.monotonic()),
|
||||||
|
)
|
||||||
|
if not self._ready.wait(wait_s):
|
||||||
|
error = OpenAICodexOAuthError(
|
||||||
|
"OpenAI Codex sign-in could not create an authorization URL."
|
||||||
|
)
|
||||||
|
self._fail(error)
|
||||||
|
raise error
|
||||||
|
if self._result.done():
|
||||||
|
self._result.result()
|
||||||
|
if self.authorization_url:
|
||||||
|
return self
|
||||||
|
error = OpenAICodexOAuthError(
|
||||||
|
"OpenAI Codex sign-in returned no authorization URL."
|
||||||
|
)
|
||||||
|
self._fail(error)
|
||||||
|
raise error
|
||||||
|
|
||||||
|
def complete(self, callback_url: str | None = None) -> OAuthToken | None:
|
||||||
|
"""Submit a full callback URL, or return ``None`` while waiting for one."""
|
||||||
|
if self._result.done():
|
||||||
|
return self._result.result()
|
||||||
|
if self.expired:
|
||||||
|
error = OpenAICodexOAuthError(
|
||||||
|
"OpenAI Codex sign-in expired. Start a new sign-in flow."
|
||||||
|
)
|
||||||
|
self._fail(error)
|
||||||
|
raise error
|
||||||
|
if callback_url is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
callback_state, authorization_failed = _validate_callback_url(callback_url)
|
||||||
|
if not hmac.compare_digest(callback_state, self._expected_state):
|
||||||
|
raise OpenAICodexOAuthInputError(
|
||||||
|
"The callback URL does not belong to this sign-in flow. Copy the latest URL."
|
||||||
|
)
|
||||||
|
if authorization_failed:
|
||||||
|
error = OpenAICodexOAuthError(
|
||||||
|
"OpenAI Codex sign-in was not completed by the authorization server."
|
||||||
|
)
|
||||||
|
self._fail(error)
|
||||||
|
raise error
|
||||||
|
|
||||||
|
with self._submission_lock:
|
||||||
|
if self._submitted:
|
||||||
|
return None
|
||||||
|
self._submitted = True
|
||||||
|
try:
|
||||||
|
self._callback_input.put_nowait(callback_url.strip())
|
||||||
|
except queue.Full:
|
||||||
|
return None
|
||||||
|
return self._result.result() if self._result.done() else None
|
||||||
|
|
||||||
|
def cancel(self) -> None:
|
||||||
|
"""Unblock an abandoned interactive login."""
|
||||||
|
self._fail(OpenAICodexOAuthError("OpenAI Codex sign-in was cancelled."))
|
||||||
|
if threading.current_thread() is not self._thread:
|
||||||
|
self._thread.join(timeout=0.5)
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
try:
|
||||||
|
token = login_oauth_interactive(
|
||||||
|
print_fn=self._capture_output,
|
||||||
|
prompt_fn=self._prompt_for_callback,
|
||||||
|
provider=OPENAI_CODEX_PROVIDER,
|
||||||
|
proxy=self._proxy,
|
||||||
|
open_browser=self._open_browser,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
with suppress(Exception):
|
||||||
|
self._result.set_exception(_safe_login_error(exc))
|
||||||
|
else:
|
||||||
|
with suppress(Exception):
|
||||||
|
self._result.set_result(token)
|
||||||
|
finally:
|
||||||
|
self._ready.set()
|
||||||
|
|
||||||
|
def _capture_output(self, message: str) -> None:
|
||||||
|
raw = str(message)
|
||||||
|
start = raw.find(OPENAI_CODEX_PROVIDER.authorize_url)
|
||||||
|
if start < 0:
|
||||||
|
return
|
||||||
|
candidate = raw[start:].split(maxsplit=1)[0]
|
||||||
|
state = _first(parse_qs(urlsplit(candidate).query), "state")
|
||||||
|
if not state:
|
||||||
|
return
|
||||||
|
self.authorization_url = candidate
|
||||||
|
self._expected_state = state
|
||||||
|
self._ready.set()
|
||||||
|
|
||||||
|
def _prompt_for_callback(self, _prompt: str) -> str:
|
||||||
|
remaining = max(0.0, self._expires_at - time.monotonic())
|
||||||
|
try:
|
||||||
|
value = self._callback_input.get(timeout=remaining)
|
||||||
|
except queue.Empty as exc:
|
||||||
|
raise OpenAICodexOAuthError(
|
||||||
|
"OpenAI Codex sign-in expired. Start a new sign-in flow."
|
||||||
|
) from exc
|
||||||
|
if not value:
|
||||||
|
error = self._result.exception() if self._result.done() else None
|
||||||
|
if error is not None:
|
||||||
|
raise error
|
||||||
|
raise OpenAICodexOAuthError("OpenAI Codex sign-in was cancelled.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _fail(self, error: OpenAICodexOAuthError) -> None:
|
||||||
|
try:
|
||||||
|
self._result.set_exception(error)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
with suppress(queue.Full):
|
||||||
|
self._callback_input.put_nowait("")
|
||||||
|
self._ready.set()
|
||||||
|
|
||||||
|
|
||||||
|
def start_openai_codex_oauth_login(
|
||||||
|
*,
|
||||||
|
proxy: str | None = None,
|
||||||
|
timeout_s: float = 600,
|
||||||
|
open_browser: bool = True,
|
||||||
|
) -> OpenAICodexOAuthLoginFlow:
|
||||||
|
"""Start a non-blocking wrapper around oauth-cli-kit's Codex login."""
|
||||||
|
return OpenAICodexOAuthLoginFlow(
|
||||||
|
proxy=proxy,
|
||||||
|
timeout_s=timeout_s,
|
||||||
|
open_browser=open_browser,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
|
||||||
|
def complete_openai_codex_oauth_login(
|
||||||
|
flow: OpenAICodexOAuthLoginFlow,
|
||||||
|
callback_url: str | None = None,
|
||||||
|
) -> OAuthToken | None:
|
||||||
|
"""Complete a pending Codex login from a full callback URL."""
|
||||||
|
return flow.complete(callback_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_callback_url(raw: str) -> tuple[str, bool]:
|
||||||
|
value = raw.strip()
|
||||||
|
if not value:
|
||||||
|
raise OpenAICodexOAuthInputError("Paste the full callback URL from your browser.")
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError as exc:
|
||||||
|
raise OpenAICodexOAuthInputError(
|
||||||
|
"The callback URL is invalid. Copy the full URL from your browser's address bar."
|
||||||
|
) from exc
|
||||||
|
if (
|
||||||
|
parsed.scheme != _CALLBACK.scheme
|
||||||
|
or parsed.hostname not in _CALLBACK_HOSTS
|
||||||
|
or port != _CALLBACK.port
|
||||||
|
or parsed.path != _CALLBACK.path
|
||||||
|
or parsed.username is not None
|
||||||
|
or parsed.password is not None
|
||||||
|
):
|
||||||
|
raise OpenAICodexOAuthInputError(
|
||||||
|
f"Paste the full callback URL from your browser ({OPENAI_CODEX_PROVIDER.redirect_uri}?...)."
|
||||||
|
)
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
code = _first(params, "code")
|
||||||
|
state = _first(params, "state")
|
||||||
|
error = _first(params, "error")
|
||||||
|
if not state:
|
||||||
|
raise OpenAICodexOAuthInputError(
|
||||||
|
"The callback URL is missing OAuth state. Copy the entire browser address."
|
||||||
|
)
|
||||||
|
if not code and not error:
|
||||||
|
raise OpenAICodexOAuthInputError(
|
||||||
|
"The callback URL has no authorization result. Finish signing in, then copy it again."
|
||||||
|
)
|
||||||
|
return state, error is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_login_error(exc: Exception) -> OpenAICodexOAuthError:
|
||||||
|
if isinstance(exc, OpenAICodexOAuthError):
|
||||||
|
return exc
|
||||||
|
message = str(exc).strip()
|
||||||
|
if message == "State validation failed.":
|
||||||
|
return OpenAICodexOAuthError(
|
||||||
|
"OpenAI Codex sign-in failed because the OAuth state did not match."
|
||||||
|
)
|
||||||
|
if message == "Authorization code not found.":
|
||||||
|
return OpenAICodexOAuthError(
|
||||||
|
"OpenAI Codex sign-in returned no authorization code."
|
||||||
|
)
|
||||||
|
status = _TOKEN_EXCHANGE_STATUS.search(message)
|
||||||
|
if status:
|
||||||
|
return OpenAICodexOAuthError(
|
||||||
|
f"OpenAI Codex OAuth token exchange failed with HTTP {status.group(1)}."
|
||||||
|
)
|
||||||
|
return OpenAICodexOAuthError(
|
||||||
|
f"OpenAI Codex sign-in failed ({type(exc).__name__})."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _first(params: dict[str, list[str]], key: str) -> str | None:
|
||||||
|
values = params.get(key)
|
||||||
|
return values[0] if values else None
|
||||||
@@ -1,443 +0,0 @@
|
|||||||
"""Stable filesystem aliases for resources exposed to the agent.
|
|
||||||
|
|
||||||
The aliases in this module are a compatibility view, not a new source of
|
|
||||||
filesystem permissions. Callers should keep canonical paths for persistence
|
|
||||||
and authorization, and use a non-None alias only when presenting a shorter
|
|
||||||
path to the model.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
from filelock import FileLock, Timeout
|
|
||||||
|
|
||||||
_LOCK_TIMEOUT_SECONDS = 2
|
|
||||||
_JUNCTION_TIMEOUT_SECONDS = 2
|
|
||||||
_NAMESPACE_MARKER = ".nanobot-resource-views.json"
|
|
||||||
_VIEW_MARKER = ".nanobot-resource-view.json"
|
|
||||||
_MARKER_VERSION = 1
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ResourceView:
|
|
||||||
"""The healthy aliases in one immutable resource view."""
|
|
||||||
|
|
||||||
root: Path | None = None
|
|
||||||
agent: Path | None = None
|
|
||||||
media: Path | None = None
|
|
||||||
package: Path | None = None
|
|
||||||
warnings: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_resource_view(
|
|
||||||
*,
|
|
||||||
data_dir: Path,
|
|
||||||
config_path: Path,
|
|
||||||
agent_workspace: Path,
|
|
||||||
package_root: Path | None = None,
|
|
||||||
) -> ResourceView:
|
|
||||||
"""Create, or validate, a stable resource view.
|
|
||||||
|
|
||||||
Expected filesystem failures are deliberately non-fatal. A caller can
|
|
||||||
use each non-None alias and fall back to its canonical path for any alias
|
|
||||||
that could not be prepared.
|
|
||||||
"""
|
|
||||||
|
|
||||||
warnings: list[str] = []
|
|
||||||
try:
|
|
||||||
canonical_data_dir = _canonical(data_dir)
|
|
||||||
canonical_config_path = _canonical(config_path)
|
|
||||||
canonical_agent_workspace = _canonical(agent_workspace)
|
|
||||||
canonical_package_root = _canonical(
|
|
||||||
package_root if package_root is not None else Path(__file__).parent
|
|
||||||
)
|
|
||||||
except (OSError, RuntimeError) as exc:
|
|
||||||
return ResourceView(warnings=(f"Could not resolve resource paths: {_error_text(exc)}",))
|
|
||||||
|
|
||||||
view_id = _resource_view_id(
|
|
||||||
config_path=canonical_config_path,
|
|
||||||
agent_workspace=canonical_agent_workspace,
|
|
||||||
package_root=canonical_package_root,
|
|
||||||
)
|
|
||||||
namespace_root = canonical_data_dir / "resources"
|
|
||||||
view_root = namespace_root / view_id
|
|
||||||
media_root = canonical_data_dir / "media"
|
|
||||||
|
|
||||||
for label, target in (
|
|
||||||
("agent", canonical_agent_workspace),
|
|
||||||
("package", canonical_package_root),
|
|
||||||
):
|
|
||||||
if _paths_overlap(target, view_root):
|
|
||||||
warnings.append(
|
|
||||||
f"Resource view overlaps the {label} target and would make recursive "
|
|
||||||
f"traversal unsafe: {view_root}"
|
|
||||||
)
|
|
||||||
return ResourceView(warnings=tuple(warnings))
|
|
||||||
|
|
||||||
try:
|
|
||||||
canonical_data_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
if not canonical_data_dir.is_dir():
|
|
||||||
warnings.append(f"Resource data directory is not a directory: {canonical_data_dir}")
|
|
||||||
return ResourceView(warnings=tuple(warnings))
|
|
||||||
except OSError as exc:
|
|
||||||
warnings.append(
|
|
||||||
f"Could not prepare resource data directory {canonical_data_dir}: {_error_text(exc)}"
|
|
||||||
)
|
|
||||||
return ResourceView(warnings=tuple(warnings))
|
|
||||||
|
|
||||||
lock_path = canonical_data_dir / ".nanobot-resource-links.lock"
|
|
||||||
try:
|
|
||||||
with FileLock(str(lock_path), timeout=_LOCK_TIMEOUT_SECONDS):
|
|
||||||
return _ensure_resource_view_locked(
|
|
||||||
namespace_root=namespace_root,
|
|
||||||
view_root=view_root,
|
|
||||||
view_id=view_id,
|
|
||||||
config_path=canonical_config_path,
|
|
||||||
agent_workspace=canonical_agent_workspace,
|
|
||||||
media_root=media_root,
|
|
||||||
package_root=canonical_package_root,
|
|
||||||
warnings=warnings,
|
|
||||||
)
|
|
||||||
except Timeout:
|
|
||||||
warnings.append(f"Timed out waiting for resource view lock: {lock_path}")
|
|
||||||
except OSError as exc:
|
|
||||||
warnings.append(f"Could not lock resource view {lock_path}: {_error_text(exc)}")
|
|
||||||
|
|
||||||
return ResourceView(warnings=tuple(warnings))
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_resource_view_locked(
|
|
||||||
*,
|
|
||||||
namespace_root: Path,
|
|
||||||
view_root: Path,
|
|
||||||
view_id: str,
|
|
||||||
config_path: Path,
|
|
||||||
agent_workspace: Path,
|
|
||||||
media_root: Path,
|
|
||||||
package_root: Path,
|
|
||||||
warnings: list[str],
|
|
||||||
) -> ResourceView:
|
|
||||||
namespace_marker = {
|
|
||||||
"kind": "nanobot-resource-views",
|
|
||||||
"version": _MARKER_VERSION,
|
|
||||||
}
|
|
||||||
if not _ensure_owned_directory(
|
|
||||||
namespace_root,
|
|
||||||
marker_name=_NAMESPACE_MARKER,
|
|
||||||
marker_payload=namespace_marker,
|
|
||||||
label="resource namespace",
|
|
||||||
warnings=warnings,
|
|
||||||
):
|
|
||||||
return ResourceView(warnings=tuple(warnings))
|
|
||||||
|
|
||||||
view_marker = {
|
|
||||||
"kind": "nanobot-resource-view",
|
|
||||||
"version": _MARKER_VERSION,
|
|
||||||
"view_id": view_id,
|
|
||||||
"config_path": _path_identity(config_path),
|
|
||||||
"targets": {
|
|
||||||
"agent": _path_identity(agent_workspace),
|
|
||||||
"media": _path_identity(media_root),
|
|
||||||
"package": _path_identity(package_root),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if not _ensure_owned_directory(
|
|
||||||
view_root,
|
|
||||||
marker_name=_VIEW_MARKER,
|
|
||||||
marker_payload=view_marker,
|
|
||||||
label="resource view",
|
|
||||||
warnings=warnings,
|
|
||||||
):
|
|
||||||
return ResourceView(warnings=tuple(warnings))
|
|
||||||
|
|
||||||
try:
|
|
||||||
media_root.mkdir(parents=True, exist_ok=True)
|
|
||||||
except OSError as exc:
|
|
||||||
warnings.append(f"Could not prepare media target {media_root}: {_error_text(exc)}")
|
|
||||||
|
|
||||||
agent_alias = _ensure_alias(
|
|
||||||
view_root / "agent",
|
|
||||||
target=agent_workspace,
|
|
||||||
view_root=view_root,
|
|
||||||
label="agent",
|
|
||||||
warnings=warnings,
|
|
||||||
)
|
|
||||||
media_alias = _ensure_alias(
|
|
||||||
view_root / "media",
|
|
||||||
target=media_root,
|
|
||||||
view_root=view_root,
|
|
||||||
label="media",
|
|
||||||
warnings=warnings,
|
|
||||||
)
|
|
||||||
package_alias = _ensure_alias(
|
|
||||||
view_root / "package",
|
|
||||||
target=package_root,
|
|
||||||
view_root=view_root,
|
|
||||||
label="package",
|
|
||||||
warnings=warnings,
|
|
||||||
)
|
|
||||||
return ResourceView(
|
|
||||||
root=view_root,
|
|
||||||
agent=agent_alias,
|
|
||||||
media=media_alias,
|
|
||||||
package=package_alias,
|
|
||||||
warnings=tuple(warnings),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _resource_view_id(
|
|
||||||
*,
|
|
||||||
config_path: Path,
|
|
||||||
agent_workspace: Path,
|
|
||||||
package_root: Path,
|
|
||||||
) -> str:
|
|
||||||
identities = (
|
|
||||||
_path_identity(config_path),
|
|
||||||
_path_identity(agent_workspace),
|
|
||||||
_path_identity(package_root),
|
|
||||||
)
|
|
||||||
digest = hashlib.sha256(
|
|
||||||
"\0".join(identities).encode("utf-8", errors="surrogatepass")
|
|
||||||
).hexdigest()
|
|
||||||
return digest[:16]
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical(path: Path) -> Path:
|
|
||||||
return Path(path).expanduser().resolve(strict=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _path_identity(path: Path) -> str:
|
|
||||||
return os.path.normcase(os.path.normpath(str(path)))
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_owned_directory(
|
|
||||||
directory: Path,
|
|
||||||
*,
|
|
||||||
marker_name: str,
|
|
||||||
marker_payload: dict[str, Any],
|
|
||||||
label: str,
|
|
||||||
warnings: list[str],
|
|
||||||
) -> bool:
|
|
||||||
created = False
|
|
||||||
try:
|
|
||||||
if os.path.lexists(directory):
|
|
||||||
if _is_link_like(directory) or not directory.is_dir():
|
|
||||||
warnings.append(f"Unmanaged {label} collision at {directory}")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
directory.mkdir()
|
|
||||||
created = True
|
|
||||||
except OSError as exc:
|
|
||||||
warnings.append(f"Could not prepare {label} {directory}: {_error_text(exc)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
marker_path = directory / marker_name
|
|
||||||
if not created:
|
|
||||||
actual = _read_marker(marker_path, label=label, warnings=warnings)
|
|
||||||
if actual is None:
|
|
||||||
return False
|
|
||||||
if actual != marker_payload:
|
|
||||||
warnings.append(f"Ownership marker does not match expected {label}: {marker_path}")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
_write_marker(marker_path, marker_payload)
|
|
||||||
except OSError as exc:
|
|
||||||
warnings.append(f"Could not write {label} marker {marker_path}: {_error_text(exc)}")
|
|
||||||
# Only an empty directory can be removed here. Never recursively
|
|
||||||
# clean a path that another process may have populated.
|
|
||||||
try:
|
|
||||||
directory.rmdir()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _read_marker(
|
|
||||||
marker_path: Path,
|
|
||||||
*,
|
|
||||||
label: str,
|
|
||||||
warnings: list[str],
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
try:
|
|
||||||
if not os.path.lexists(marker_path):
|
|
||||||
warnings.append(f"Unmanaged {label} at {marker_path.parent}: ownership marker missing")
|
|
||||||
return None
|
|
||||||
if _is_link_like(marker_path) or not stat.S_ISREG(marker_path.lstat().st_mode):
|
|
||||||
warnings.append(f"Invalid {label} ownership marker: {marker_path}")
|
|
||||||
return None
|
|
||||||
payload = json.loads(marker_path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
||||||
warnings.append(f"Could not read {label} marker {marker_path}: {_error_text(exc)}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
warnings.append(f"Invalid {label} ownership marker: {marker_path}")
|
|
||||||
return None
|
|
||||||
return cast(dict[str, Any], payload)
|
|
||||||
|
|
||||||
|
|
||||||
def _write_marker(marker_path: Path, payload: dict[str, Any]) -> None:
|
|
||||||
serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
|
||||||
with marker_path.open("x", encoding="utf-8", newline="\n") as marker_file:
|
|
||||||
marker_file.write(serialized)
|
|
||||||
marker_file.flush()
|
|
||||||
os.fsync(marker_file.fileno())
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_alias(
|
|
||||||
alias: Path,
|
|
||||||
*,
|
|
||||||
target: Path,
|
|
||||||
view_root: Path,
|
|
||||||
label: str,
|
|
||||||
warnings: list[str],
|
|
||||||
) -> Path | None:
|
|
||||||
try:
|
|
||||||
if not target.is_dir():
|
|
||||||
warnings.append(f"Resource target for {label} is not a directory: {target}")
|
|
||||||
return None
|
|
||||||
except OSError as exc:
|
|
||||||
warnings.append(f"Could not inspect resource target for {label} {target}: {_error_text(exc)}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if _paths_overlap(target, view_root):
|
|
||||||
warnings.append(
|
|
||||||
f"Resource target for {label} overlaps its view and would create a cycle: {target}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
if os.path.lexists(alias):
|
|
||||||
if _is_directory_link(alias) and _link_points_to(alias, target):
|
|
||||||
return alias
|
|
||||||
warnings.append(f"Resource alias collision for {label} at {alias}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
_create_directory_link(alias, target)
|
|
||||||
if not _is_directory_link(alias) or not _link_points_to(alias, target):
|
|
||||||
warnings.append(f"Created resource alias for {label} could not be verified: {alias}")
|
|
||||||
_remove_created_link(alias, label=label, warnings=warnings)
|
|
||||||
return None
|
|
||||||
except OSError as exc:
|
|
||||||
warnings.append(f"Could not create resource alias for {label} at {alias}: {_error_text(exc)}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return alias
|
|
||||||
|
|
||||||
|
|
||||||
def _paths_overlap(first: Path, second: Path) -> bool:
|
|
||||||
return first.is_relative_to(second) or second.is_relative_to(first)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_link_like(path: Path) -> bool:
|
|
||||||
try:
|
|
||||||
if path.is_symlink():
|
|
||||||
return True
|
|
||||||
attributes = getattr(path.lstat(), "st_file_attributes", 0)
|
|
||||||
reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
||||||
return bool(attributes & reparse_point)
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _is_directory_link(path: Path) -> bool:
|
|
||||||
if not _is_link_like(path):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
return path.is_dir()
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _link_points_to(alias: Path, target: Path) -> bool:
|
|
||||||
try:
|
|
||||||
resolved_alias = alias.resolve(strict=True)
|
|
||||||
resolved_target = target.resolve(strict=True)
|
|
||||||
except (OSError, RuntimeError):
|
|
||||||
return False
|
|
||||||
return _path_identity(resolved_alias) == _path_identity(resolved_target)
|
|
||||||
|
|
||||||
|
|
||||||
def _remove_created_link(alias: Path, *, label: str, warnings: list[str]) -> None:
|
|
||||||
"""Remove only a link-like entry created during the current call."""
|
|
||||||
|
|
||||||
if not os.path.lexists(alias) or not _is_link_like(alias):
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
alias.unlink()
|
|
||||||
return
|
|
||||||
except OSError:
|
|
||||||
# Directory junctions on Python 3.11 may require rmdir. os.rmdir on a
|
|
||||||
# reparse point removes the junction itself and does not traverse it.
|
|
||||||
try:
|
|
||||||
os.rmdir(alias)
|
|
||||||
return
|
|
||||||
except OSError as exc:
|
|
||||||
warnings.append(
|
|
||||||
f"Could not remove unverified resource alias for {label} at "
|
|
||||||
f"{alias}: {_error_text(exc)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_directory_link(alias: Path, target: Path) -> None:
|
|
||||||
try:
|
|
||||||
alias.symlink_to(target, target_is_directory=True)
|
|
||||||
return
|
|
||||||
except OSError:
|
|
||||||
if not _is_windows():
|
|
||||||
raise
|
|
||||||
_create_windows_junction(alias, target)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_windows() -> bool:
|
|
||||||
return os.name == "nt"
|
|
||||||
|
|
||||||
|
|
||||||
def _create_windows_junction(alias: Path, target: Path) -> None:
|
|
||||||
alias_text = str(alias)
|
|
||||||
target_text = str(target)
|
|
||||||
if any(character in alias_text + target_text for character in ('"', "\r", "\n")):
|
|
||||||
raise OSError("Path cannot be safely passed to the Windows junction command")
|
|
||||||
|
|
||||||
# Keep user-controlled paths out of the command string. Expanding fixed,
|
|
||||||
# quoted environment variables also protects cmd metacharacters in paths.
|
|
||||||
command_env = os.environ.copy()
|
|
||||||
command_env["NANOBOT_RESOURCE_ALIAS"] = alias_text
|
|
||||||
command_env["NANOBOT_RESOURCE_TARGET"] = target_text
|
|
||||||
command = 'mklink /J "%NANOBOT_RESOURCE_ALIAS%" "%NANOBOT_RESOURCE_TARGET%"'
|
|
||||||
try:
|
|
||||||
completed = subprocess.run(
|
|
||||||
f"cmd.exe /d /v:off /c {command}",
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
errors="replace",
|
|
||||||
env=command_env,
|
|
||||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
||||||
timeout=_JUNCTION_TIMEOUT_SECONDS,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired as exc:
|
|
||||||
raise OSError(
|
|
||||||
f"Timed out creating Windows junction after {_JUNCTION_TIMEOUT_SECONDS}s"
|
|
||||||
) from exc
|
|
||||||
if completed.returncode == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
details = (completed.stderr or completed.stdout or "").strip()
|
|
||||||
suffix = f": {details}" if details else ""
|
|
||||||
raise OSError(f"mklink /J failed with exit code {completed.returncode}{suffix}")
|
|
||||||
|
|
||||||
|
|
||||||
def _error_text(exc: BaseException) -> str:
|
|
||||||
return str(exc) or exc.__class__.__name__
|
|
||||||
@@ -12,6 +12,7 @@ from urllib.parse import urlparse
|
|||||||
from urllib.request import getproxies, proxy_bypass
|
from urllib.request import getproxies, proxy_bypass
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import httpx2
|
||||||
|
|
||||||
_BLOCKED_NETWORKS = [
|
_BLOCKED_NETWORKS = [
|
||||||
ipaddress.ip_network("0.0.0.0/8"),
|
ipaddress.ip_network("0.0.0.0/8"),
|
||||||
@@ -29,6 +30,7 @@ _BLOCKED_NETWORKS = [
|
|||||||
|
|
||||||
_URL_RE = re.compile(r"https?://[^\s\"'`;|<>]+", re.IGNORECASE)
|
_URL_RE = re.compile(r"https?://[^\s\"'`;|<>]+", re.IGNORECASE)
|
||||||
_allowed_networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
|
_allowed_networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
|
||||||
|
_DNS_PIN_RESOLVER_LOCK = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
def is_loopback_host(host: str) -> bool:
|
def is_loopback_host(host: str) -> bool:
|
||||||
@@ -195,6 +197,30 @@ def httpx_env_proxy_mounts() -> dict[str, httpx.AsyncBaseTransport | None]:
|
|||||||
return mounts
|
return mounts
|
||||||
|
|
||||||
|
|
||||||
|
def httpx2_env_proxy_mounts() -> dict[str, httpx2.AsyncBaseTransport | None]:
|
||||||
|
"""Build HTTPX2 proxy mounts while leaving direct routes to the base transport."""
|
||||||
|
proxies = getproxies()
|
||||||
|
mounts: dict[str, httpx2.AsyncBaseTransport | None] = {}
|
||||||
|
for scheme in ("http", "https", "all"):
|
||||||
|
proxy_url = proxies.get(scheme)
|
||||||
|
if proxy_url:
|
||||||
|
if "://" not in proxy_url:
|
||||||
|
proxy_url = f"http://{proxy_url}"
|
||||||
|
mounts[f"{scheme}://"] = httpx2.AsyncHTTPTransport(proxy=httpx2.Proxy(proxy_url))
|
||||||
|
|
||||||
|
if not mounts:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
no_proxy = proxies.get("no", "")
|
||||||
|
if no_proxy == "*":
|
||||||
|
return {}
|
||||||
|
for entry in no_proxy.split(","):
|
||||||
|
pattern = _no_proxy_mount_pattern(entry.strip())
|
||||||
|
if pattern:
|
||||||
|
mounts[pattern] = None
|
||||||
|
return mounts
|
||||||
|
|
||||||
|
|
||||||
def _no_proxy_mount_pattern(hostname: str) -> str | None:
|
def _no_proxy_mount_pattern(hostname: str) -> str | None:
|
||||||
if not hostname:
|
if not hostname:
|
||||||
return None
|
return None
|
||||||
@@ -264,7 +290,7 @@ class UnsafeURLRequestError(httpx.RequestError):
|
|||||||
class PinnedDNSAsyncTransport(httpx.AsyncBaseTransport):
|
class PinnedDNSAsyncTransport(httpx.AsyncBaseTransport):
|
||||||
"""HTTPX transport that pins each request to the IPs validated for its URL."""
|
"""HTTPX transport that pins each request to the IPs validated for its URL."""
|
||||||
|
|
||||||
_resolver_lock = asyncio.Lock()
|
_resolver_lock = _DNS_PIN_RESOLVER_LOCK
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -288,6 +314,37 @@ class PinnedDNSAsyncTransport(httpx.AsyncBaseTransport):
|
|||||||
await self._inner.aclose()
|
await self._inner.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
class Httpx2UnsafeURLRequestError(httpx2.RequestError):
|
||||||
|
"""Raised when an HTTPX2 request is rejected by URL safety validation."""
|
||||||
|
|
||||||
|
|
||||||
|
class Httpx2PinnedDNSAsyncTransport(httpx2.AsyncBaseTransport):
|
||||||
|
"""HTTPX2 transport that pins each request to the IPs validated for its URL."""
|
||||||
|
|
||||||
|
_resolver_lock = _DNS_PIN_RESOLVER_LOCK
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
allow_loopback: bool = False,
|
||||||
|
inner: httpx2.AsyncBaseTransport | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._allow_loopback = allow_loopback
|
||||||
|
self._inner = inner or httpx2.AsyncHTTPTransport()
|
||||||
|
|
||||||
|
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
|
||||||
|
url = str(request.url)
|
||||||
|
ok, error, resolved_ips = resolve_url_target(url, allow_loopback=self._allow_loopback)
|
||||||
|
if not ok:
|
||||||
|
raise Httpx2UnsafeURLRequestError(error, request=request)
|
||||||
|
async with self._resolver_lock:
|
||||||
|
with pin_resolved_url_dns(url, resolved_ips):
|
||||||
|
return await self._inner.handle_async_request(request)
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
await self._inner.aclose()
|
||||||
|
|
||||||
|
|
||||||
def validate_resolved_url(url: str) -> tuple[bool, str]:
|
def validate_resolved_url(url: str) -> tuple[bool, str]:
|
||||||
"""Validate an already-fetched URL (e.g. after redirect). Only checks the IP, skips DNS."""
|
"""Validate an already-fetched URL (e.g. after redirect). Only checks the IP, skips DNS."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
+503
-396
@@ -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, 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
|
||||||
@@ -427,17 +427,492 @@ class Session:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SessionManager:
|
class SessionPayload(TypedDict):
|
||||||
"""
|
key: str
|
||||||
Manages conversation sessions.
|
created_at: str | None
|
||||||
|
updated_at: str | None
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
messages: list[dict[str, Any]]
|
||||||
|
|
||||||
Sessions are stored as JSONL files in the sessions directory.
|
|
||||||
"""
|
class SessionMetadataPayload(TypedDict):
|
||||||
|
key: str
|
||||||
|
created_at: str | None
|
||||||
|
updated_at: str | None
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class SessionInfo(TypedDict):
|
||||||
|
key: str
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
title: str
|
||||||
|
preview: str
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStore(Protocol):
|
||||||
|
def load(self, key: str) -> Session | None: ...
|
||||||
|
|
||||||
|
def save(self, session: Session, *, fsync: bool = False) -> None: ...
|
||||||
|
|
||||||
|
def delete(self, key: str) -> bool: ...
|
||||||
|
|
||||||
|
def read(self, key: str) -> SessionPayload | None: ...
|
||||||
|
|
||||||
|
def read_metadata(self, key: str) -> SessionMetadataPayload | None: ...
|
||||||
|
|
||||||
|
def list_sessions(self) -> list[SessionInfo]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class JsonlSessionStore:
|
||||||
|
"""JSONL implementation of session persistence."""
|
||||||
|
|
||||||
def __init__(self, workspace: Path):
|
def __init__(self, workspace: Path):
|
||||||
self.workspace = workspace
|
self.sessions_dir = ensure_dir(workspace / "sessions")
|
||||||
self.sessions_dir = ensure_dir(self.workspace / "sessions")
|
|
||||||
self.legacy_sessions_dir = get_legacy_sessions_dir()
|
self.legacy_sessions_dir = get_legacy_sessions_dir()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def safe_key(key: str) -> str:
|
||||||
|
return safe_filename(key.replace(":", "_"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def storage_key(key: str) -> str:
|
||||||
|
return base64.urlsafe_b64encode(key.encode()).decode().rstrip("=")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decode_storage_key(stem: str) -> str | None:
|
||||||
|
try:
|
||||||
|
padding = 4 - len(stem) % 4
|
||||||
|
if padding != 4:
|
||||||
|
stem += "=" * padding
|
||||||
|
return base64.urlsafe_b64decode(stem).decode("utf-8")
|
||||||
|
except _SESSION_DATA_ERRORS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def session_key_from_path(cls, path: Path) -> str | None:
|
||||||
|
key = cls.decode_storage_key(path.stem)
|
||||||
|
if key is None or cls.storage_key(key) != path.stem:
|
||||||
|
return None
|
||||||
|
return key
|
||||||
|
|
||||||
|
def get_session_path(self, key: str) -> Path:
|
||||||
|
return self.sessions_dir / f"{self.storage_key(key)}.jsonl"
|
||||||
|
|
||||||
|
def get_legacy_lossy_path(self, key: str) -> Path:
|
||||||
|
return self.sessions_dir / f"{safe_filename(key.replace(':', '_'))}.jsonl"
|
||||||
|
|
||||||
|
def get_legacy_session_path(self, key: str) -> Path:
|
||||||
|
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
|
||||||
|
|
||||||
|
def load(self, key: str) -> Session | None:
|
||||||
|
path = self.get_session_path(key)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
messages: list[dict[str, Any]] = []
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
last_consolidated = 0
|
||||||
|
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_data: object = json.loads(line)
|
||||||
|
data = _json_object(raw_data)
|
||||||
|
|
||||||
|
if data.get("_type") == "metadata":
|
||||||
|
metadata_value = cast(object, data.get("metadata", {}))
|
||||||
|
metadata = (
|
||||||
|
cast(dict[str, Any], metadata_value)
|
||||||
|
if isinstance(metadata_value, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
created_at_value = cast(object, data.get("created_at"))
|
||||||
|
updated_at_value = cast(object, data.get("updated_at"))
|
||||||
|
created_at = (
|
||||||
|
datetime.fromisoformat(created_at_value)
|
||||||
|
if isinstance(created_at_value, str) and created_at_value
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
updated_at = (
|
||||||
|
datetime.fromisoformat(updated_at_value)
|
||||||
|
if isinstance(updated_at_value, str) and updated_at_value
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
offset = cast(object, data.get("last_consolidated", 0))
|
||||||
|
last_consolidated = (
|
||||||
|
offset
|
||||||
|
if isinstance(offset, int) and not isinstance(offset, bool)
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.append(data)
|
||||||
|
|
||||||
|
return Session(
|
||||||
|
key=key,
|
||||||
|
messages=messages,
|
||||||
|
created_at=created_at or datetime.now(),
|
||||||
|
updated_at=updated_at or datetime.now(),
|
||||||
|
metadata=metadata,
|
||||||
|
last_consolidated=last_consolidated,
|
||||||
|
)
|
||||||
|
except _SESSION_DATA_ERRORS as e:
|
||||||
|
logger.warning("Failed to load session {}: {}", key, e)
|
||||||
|
repaired = self.repair(key)
|
||||||
|
if repaired is not None:
|
||||||
|
logger.info(
|
||||||
|
"Recovered session {} from corrupt file ({} messages)",
|
||||||
|
key,
|
||||||
|
len(repaired.messages),
|
||||||
|
)
|
||||||
|
return repaired
|
||||||
|
|
||||||
|
def repair(self, key: str, *, path: Path | None = None) -> Session | None:
|
||||||
|
if path is None:
|
||||||
|
path = self.get_session_path(key)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
messages: list[dict[str, Any]] = []
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
last_consolidated = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
raw_data: object = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
if not isinstance(raw_data, dict):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
data = cast(dict[str, Any], raw_data)
|
||||||
|
|
||||||
|
if data.get("_type") == "metadata":
|
||||||
|
metadata_value = cast(object, data.get("metadata", {}))
|
||||||
|
metadata = (
|
||||||
|
cast(dict[str, Any], metadata_value)
|
||||||
|
if isinstance(metadata_value, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
created_at_value = cast(object, data.get("created_at"))
|
||||||
|
if isinstance(created_at_value, str) and created_at_value:
|
||||||
|
with suppress(ValueError):
|
||||||
|
created_at = datetime.fromisoformat(created_at_value)
|
||||||
|
updated_at_value = cast(object, data.get("updated_at"))
|
||||||
|
if isinstance(updated_at_value, str) and updated_at_value:
|
||||||
|
with suppress(ValueError):
|
||||||
|
updated_at = datetime.fromisoformat(updated_at_value)
|
||||||
|
offset = cast(object, data.get("last_consolidated", 0))
|
||||||
|
last_consolidated = (
|
||||||
|
offset
|
||||||
|
if isinstance(offset, int) and not isinstance(offset, bool)
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.append(data)
|
||||||
|
|
||||||
|
if skipped:
|
||||||
|
logger.warning("Skipped {} corrupt lines in session {}", skipped, key)
|
||||||
|
|
||||||
|
if not messages and not metadata:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return Session(
|
||||||
|
key=key,
|
||||||
|
messages=messages,
|
||||||
|
created_at=created_at or datetime.now(),
|
||||||
|
updated_at=updated_at or datetime.now(),
|
||||||
|
metadata=metadata,
|
||||||
|
last_consolidated=last_consolidated,
|
||||||
|
)
|
||||||
|
except _SESSION_DATA_ERRORS as e:
|
||||||
|
logger.warning("Repair failed for session {}: {}", key, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def session_payload(session: Session) -> SessionPayload:
|
||||||
|
return {
|
||||||
|
"key": session.key,
|
||||||
|
"created_at": session.created_at.isoformat(),
|
||||||
|
"updated_at": session.updated_at.isoformat(),
|
||||||
|
"metadata": session.metadata,
|
||||||
|
"messages": session.messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
def save(self, session: Session, *, fsync: bool = False) -> None:
|
||||||
|
path = self.get_session_path(session.key)
|
||||||
|
tmp_path = path.with_suffix(".jsonl.tmp")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
|
metadata_line = {
|
||||||
|
"_type": "metadata",
|
||||||
|
"key": session.key,
|
||||||
|
"created_at": session.created_at.isoformat(),
|
||||||
|
"updated_at": session.updated_at.isoformat(),
|
||||||
|
"metadata": session.metadata,
|
||||||
|
"last_consolidated": session.last_consolidated,
|
||||||
|
}
|
||||||
|
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
|
||||||
|
for msg in session.messages:
|
||||||
|
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
||||||
|
if fsync:
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
|
||||||
|
os.replace(tmp_path, path)
|
||||||
|
|
||||||
|
if fsync:
|
||||||
|
with suppress(PermissionError):
|
||||||
|
fd = os.open(str(path.parent), os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(fd)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno != errno.EINVAL:
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
except BaseException:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def delete(self, key: str) -> bool:
|
||||||
|
paths = [
|
||||||
|
self.get_session_path(key),
|
||||||
|
self.get_legacy_lossy_path(key),
|
||||||
|
self.get_legacy_session_path(key),
|
||||||
|
]
|
||||||
|
deleted = False
|
||||||
|
for path in paths:
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
deleted = True
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("Failed to delete session file {}: {}", path, e)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def read(self, key: str) -> SessionPayload | None:
|
||||||
|
path = self.get_session_path(key)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
messages: list[dict[str, Any]] = []
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
stored_key: str | None = None
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
raw_data: object = json.loads(line)
|
||||||
|
data = _json_object(raw_data)
|
||||||
|
if data.get("_type") == "metadata":
|
||||||
|
metadata_value = cast(object, data.get("metadata", {}))
|
||||||
|
metadata = (
|
||||||
|
cast(dict[str, Any], metadata_value)
|
||||||
|
if isinstance(metadata_value, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
created_at_value = cast(object, data.get("created_at"))
|
||||||
|
updated_at_value = cast(object, data.get("updated_at"))
|
||||||
|
stored_key_value = cast(object, data.get("key"))
|
||||||
|
created_at = (
|
||||||
|
created_at_value if isinstance(created_at_value, str) else None
|
||||||
|
)
|
||||||
|
updated_at = (
|
||||||
|
updated_at_value if isinstance(updated_at_value, str) else None
|
||||||
|
)
|
||||||
|
stored_key = (
|
||||||
|
stored_key_value if isinstance(stored_key_value, str) else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.append(data)
|
||||||
|
return {
|
||||||
|
"key": stored_key or key,
|
||||||
|
"created_at": created_at,
|
||||||
|
"updated_at": updated_at,
|
||||||
|
"metadata": metadata,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
except _SESSION_DATA_ERRORS as e:
|
||||||
|
logger.warning("Failed to read session {}: {}", key, e)
|
||||||
|
repaired = self.repair(key, path=path)
|
||||||
|
if repaired is not None:
|
||||||
|
logger.info("Recovered read-only session view {} from corrupt file", key)
|
||||||
|
return self.session_payload(repaired)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def read_metadata(self, key: str) -> SessionMetadataPayload | None:
|
||||||
|
path = self.get_session_path(key)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
raw_data: object = json.loads(line)
|
||||||
|
data = _json_object(raw_data)
|
||||||
|
if data.get("_type") != "metadata":
|
||||||
|
return None
|
||||||
|
metadata_value = cast(object, data.get("metadata", {}))
|
||||||
|
key_value = cast(object, data.get("key"))
|
||||||
|
created_at_value = cast(object, data.get("created_at"))
|
||||||
|
updated_at_value = cast(object, data.get("updated_at"))
|
||||||
|
return {
|
||||||
|
"key": key_value if isinstance(key_value, str) and key_value else key,
|
||||||
|
"created_at": (
|
||||||
|
created_at_value if isinstance(created_at_value, str) else None
|
||||||
|
),
|
||||||
|
"updated_at": (
|
||||||
|
updated_at_value if isinstance(updated_at_value, str) else None
|
||||||
|
),
|
||||||
|
"metadata": (
|
||||||
|
cast(dict[str, Any], metadata_value)
|
||||||
|
if isinstance(metadata_value, dict)
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
except _SESSION_DATA_ERRORS as e:
|
||||||
|
logger.warning("Failed to read session metadata {}: {}", key, e)
|
||||||
|
repaired = self.repair(key, path=path)
|
||||||
|
if repaired is not None:
|
||||||
|
logger.info("Recovered read-only session metadata {} from corrupt file", key)
|
||||||
|
return {
|
||||||
|
"key": repaired.key,
|
||||||
|
"created_at": repaired.created_at.isoformat(),
|
||||||
|
"updated_at": repaired.updated_at.isoformat(),
|
||||||
|
"metadata": repaired.metadata,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_sessions(self) -> list[SessionInfo]:
|
||||||
|
sessions: list[SessionInfo] = []
|
||||||
|
|
||||||
|
for path in self.sessions_dir.glob("*.jsonl"):
|
||||||
|
storage_key = self.session_key_from_path(path)
|
||||||
|
if storage_key is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
first_line = f.readline().strip()
|
||||||
|
if first_line:
|
||||||
|
raw_data: object = json.loads(first_line)
|
||||||
|
data = _json_object(raw_data)
|
||||||
|
if data.get("_type") == "metadata":
|
||||||
|
key_value = cast(object, data.get("key"))
|
||||||
|
key = (
|
||||||
|
key_value
|
||||||
|
if isinstance(key_value, str) and key_value
|
||||||
|
else storage_key
|
||||||
|
)
|
||||||
|
metadata = cast(object, data.get("metadata", {}))
|
||||||
|
title = _metadata_title(metadata)
|
||||||
|
preview = ""
|
||||||
|
fallback_preview = ""
|
||||||
|
scanned_records = 0
|
||||||
|
scanned_chars = 0
|
||||||
|
for line in f:
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
scanned_records += 1
|
||||||
|
scanned_chars += len(line)
|
||||||
|
if (
|
||||||
|
scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS
|
||||||
|
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
|
||||||
|
):
|
||||||
|
break
|
||||||
|
raw_item: object = json.loads(line)
|
||||||
|
item = _json_object(raw_item)
|
||||||
|
if item.get("_type") == "metadata":
|
||||||
|
continue
|
||||||
|
text = _message_preview_text(item)
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
if item.get("role") == "user":
|
||||||
|
preview = text
|
||||||
|
break
|
||||||
|
if not fallback_preview and item.get("role") == "assistant":
|
||||||
|
fallback_preview = text
|
||||||
|
preview = preview or fallback_preview
|
||||||
|
fallback_time = datetime.fromtimestamp(path.stat().st_mtime).isoformat()
|
||||||
|
created_at = cast(object, data.get("created_at"))
|
||||||
|
updated_at = cast(object, data.get("updated_at"))
|
||||||
|
sessions.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"created_at": (
|
||||||
|
created_at
|
||||||
|
if isinstance(created_at, str) and created_at
|
||||||
|
else fallback_time
|
||||||
|
),
|
||||||
|
"updated_at": (
|
||||||
|
updated_at
|
||||||
|
if isinstance(updated_at, str) and updated_at
|
||||||
|
else fallback_time
|
||||||
|
),
|
||||||
|
"title": title,
|
||||||
|
"preview": preview,
|
||||||
|
"path": str(path),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
except _SESSION_DATA_ERRORS:
|
||||||
|
repaired = self.repair(storage_key, path=path)
|
||||||
|
if repaired is not None:
|
||||||
|
sessions.append(
|
||||||
|
{
|
||||||
|
"key": repaired.key,
|
||||||
|
"created_at": repaired.created_at.isoformat(),
|
||||||
|
"updated_at": repaired.updated_at.isoformat(),
|
||||||
|
"title": _metadata_title(repaired.metadata),
|
||||||
|
"preview": next(
|
||||||
|
(
|
||||||
|
text
|
||||||
|
for msg in repaired.messages
|
||||||
|
if (text := _message_preview_text(msg))
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
"path": str(path),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
return sorted(sessions, key=lambda item: item["updated_at"], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManager:
|
||||||
|
"""Manage session identity, caching, retention, and persistence."""
|
||||||
|
|
||||||
|
def __init__(self, workspace: Path, *, store: SessionStore | None = None):
|
||||||
|
self.workspace = workspace
|
||||||
|
self._jsonl_store = JsonlSessionStore(workspace)
|
||||||
|
self._store: SessionStore = store if store is not None else self._jsonl_store
|
||||||
|
self.sessions_dir = self._jsonl_store.sessions_dir
|
||||||
|
self.legacy_sessions_dir = self._jsonl_store.legacy_sessions_dir
|
||||||
self._cache: OrderedDict[str, Session] = OrderedDict()
|
self._cache: OrderedDict[str, Session] = OrderedDict()
|
||||||
# Preserve identity for sessions held by active callers without retaining idle ones.
|
# Preserve identity for sessions held by active callers without retaining idle ones.
|
||||||
self._overflow_cache: WeakValueDictionary[str, Session] = WeakValueDictionary()
|
self._overflow_cache: WeakValueDictionary[str, Session] = WeakValueDictionary()
|
||||||
@@ -475,24 +950,17 @@ class SessionManager:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def safe_key(key: str) -> str:
|
def safe_key(key: str) -> str:
|
||||||
"""Public helper used by HTTP handlers to map an arbitrary key to a stable filename stem."""
|
"""Public helper used by HTTP handlers to map an arbitrary key to a stable filename stem."""
|
||||||
return safe_filename(key.replace(":", "_"))
|
return JsonlSessionStore.safe_key(key)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _storage_key(key: str) -> str:
|
def _storage_key(key: str) -> str:
|
||||||
"""Collision-resistant encoding for internal session storage filenames."""
|
"""Collision-resistant encoding for internal session storage filenames."""
|
||||||
return base64.urlsafe_b64encode(key.encode()).decode().rstrip("=")
|
return JsonlSessionStore.storage_key(key)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _decode_storage_key(stem: str) -> str | None:
|
def _decode_storage_key(stem: str) -> str | None:
|
||||||
"""Reverse _storage_key(): decode a base64url (no-padding) stem back to the original key."""
|
"""Reverse _storage_key(): decode a base64url (no-padding) stem back to the original key."""
|
||||||
try:
|
return JsonlSessionStore.decode_storage_key(stem)
|
||||||
# Restore padding stripped by rstrip("=")
|
|
||||||
padding = 4 - len(stem) % 4
|
|
||||||
if padding != 4:
|
|
||||||
stem += "=" * padding
|
|
||||||
return base64.urlsafe_b64decode(stem).decode("utf-8")
|
|
||||||
except _SESSION_DATA_ERRORS:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def decode_storage_key(stem: str) -> str | None:
|
def decode_storage_key(stem: str) -> str | None:
|
||||||
@@ -502,22 +970,19 @@ class SessionManager:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _session_key_from_path(cls, path: Path) -> str | None:
|
def _session_key_from_path(cls, path: Path) -> str | None:
|
||||||
"""Decode a session key only from a canonical collision-resistant filename."""
|
"""Decode a session key only from a canonical collision-resistant filename."""
|
||||||
key = cls._decode_storage_key(path.stem)
|
return JsonlSessionStore.session_key_from_path(path)
|
||||||
if key is None or cls._storage_key(key) != path.stem:
|
|
||||||
return None
|
|
||||||
return key
|
|
||||||
|
|
||||||
def _get_session_path(self, key: str) -> Path:
|
def _get_session_path(self, key: str) -> Path:
|
||||||
"""Get the collision-resistant workspace path for a session."""
|
"""Get the collision-resistant workspace path for a session."""
|
||||||
return self.sessions_dir / f"{self._storage_key(key)}.jsonl"
|
return self._jsonl_store.get_session_path(key)
|
||||||
|
|
||||||
def _get_legacy_lossy_path(self, key: str) -> Path:
|
def _get_legacy_lossy_path(self, key: str) -> Path:
|
||||||
"""Previous workspace session path using lossy ':' to '_' replacement."""
|
"""Previous workspace session path using lossy ':' to '_' replacement."""
|
||||||
return self.sessions_dir / f"{safe_filename(key.replace(':', '_'))}.jsonl"
|
return self._jsonl_store.get_legacy_lossy_path(key)
|
||||||
|
|
||||||
def _get_legacy_session_path(self, key: str) -> Path:
|
def _get_legacy_session_path(self, key: str) -> Path:
|
||||||
"""Legacy global session path (~/.nanobot/sessions/)."""
|
"""Legacy global session path (~/.nanobot/sessions/)."""
|
||||||
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
|
return self._jsonl_store.get_legacy_session_path(key)
|
||||||
|
|
||||||
def get_or_create(self, key: str) -> Session:
|
def get_or_create(self, key: str) -> Session:
|
||||||
"""
|
"""
|
||||||
@@ -541,152 +1006,18 @@ class SessionManager:
|
|||||||
return session
|
return session
|
||||||
|
|
||||||
def _load(self, key: str) -> Session | None:
|
def _load(self, key: str) -> Session | None:
|
||||||
"""Load a session from disk."""
|
return self._store.load(key)
|
||||||
path = self._get_session_path(key)
|
|
||||||
if not path.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
messages: list[dict[str, Any]] = []
|
|
||||||
metadata: object = {}
|
|
||||||
created_at: datetime | None = None
|
|
||||||
updated_at: datetime | None = None
|
|
||||||
last_consolidated: object = 0
|
|
||||||
|
|
||||||
with open(path, encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
|
|
||||||
raw_data: object = json.loads(line)
|
|
||||||
data = _json_object(raw_data)
|
|
||||||
|
|
||||||
if data.get("_type") == "metadata":
|
|
||||||
metadata = cast(object, data.get("metadata", {}))
|
|
||||||
created_at_value = cast(object, data.get("created_at"))
|
|
||||||
updated_at_value = cast(object, data.get("updated_at"))
|
|
||||||
created_at = (
|
|
||||||
datetime.fromisoformat(cast(str, created_at_value))
|
|
||||||
if created_at_value
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
updated_at = (
|
|
||||||
datetime.fromisoformat(cast(str, updated_at_value))
|
|
||||||
if updated_at_value
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
last_consolidated = cast(
|
|
||||||
object,
|
|
||||||
data.get("last_consolidated", 0),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
messages.append(data)
|
|
||||||
|
|
||||||
return Session(
|
|
||||||
key=key,
|
|
||||||
messages=messages,
|
|
||||||
created_at=created_at or datetime.now(),
|
|
||||||
updated_at=updated_at or datetime.now(),
|
|
||||||
metadata=cast(dict[str, Any], metadata),
|
|
||||||
last_consolidated=cast(int, last_consolidated),
|
|
||||||
)
|
|
||||||
except _SESSION_DATA_ERRORS as e:
|
|
||||||
logger.warning("Failed to load session {}: {}", key, e)
|
|
||||||
repaired = self._repair(key)
|
|
||||||
if repaired is not None:
|
|
||||||
logger.info("Recovered session {} from corrupt file ({} messages)", key, len(repaired.messages))
|
|
||||||
return repaired
|
|
||||||
|
|
||||||
def _repair(self, key: str, *, path: Path | None = None) -> Session | None:
|
def _repair(self, key: str, *, path: Path | None = None) -> Session | None:
|
||||||
"""Attempt to recover a session from a corrupt JSONL file."""
|
"""Attempt to recover a session from a corrupt JSONL file."""
|
||||||
if path is None:
|
return self._jsonl_store.repair(key, path=path)
|
||||||
path = self._get_session_path(key)
|
|
||||||
if not path.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
messages: list[dict[str, Any]] = []
|
|
||||||
metadata: object = {}
|
|
||||||
created_at: datetime | None = None
|
|
||||||
updated_at: datetime | None = None
|
|
||||||
last_consolidated: object = 0
|
|
||||||
skipped = 0
|
|
||||||
|
|
||||||
with open(path, encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
raw_data: object = json.loads(line)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
if not isinstance(raw_data, dict):
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
data = cast(dict[str, Any], raw_data)
|
|
||||||
|
|
||||||
if data.get("_type") == "metadata":
|
|
||||||
metadata = cast(object, data.get("metadata", {}))
|
|
||||||
created_at_value = cast(object, data.get("created_at"))
|
|
||||||
if created_at_value:
|
|
||||||
with suppress(ValueError, TypeError):
|
|
||||||
created_at = datetime.fromisoformat(
|
|
||||||
cast(str, created_at_value)
|
|
||||||
)
|
|
||||||
updated_at_value = cast(object, data.get("updated_at"))
|
|
||||||
if updated_at_value:
|
|
||||||
with suppress(ValueError, TypeError):
|
|
||||||
updated_at = datetime.fromisoformat(
|
|
||||||
cast(str, updated_at_value)
|
|
||||||
)
|
|
||||||
last_consolidated = cast(
|
|
||||||
object,
|
|
||||||
data.get("last_consolidated", 0),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
messages.append(data)
|
|
||||||
|
|
||||||
if skipped:
|
|
||||||
logger.warning("Skipped {} corrupt lines in session {}", skipped, key)
|
|
||||||
|
|
||||||
if not messages and not metadata:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return Session(
|
|
||||||
key=key,
|
|
||||||
messages=messages,
|
|
||||||
created_at=created_at or datetime.now(),
|
|
||||||
updated_at=updated_at or datetime.now(),
|
|
||||||
metadata=cast(dict[str, Any], metadata),
|
|
||||||
last_consolidated=cast(int, last_consolidated),
|
|
||||||
)
|
|
||||||
except _SESSION_DATA_ERRORS as e:
|
|
||||||
logger.warning("Repair failed for session {}: {}", key, e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _session_payload(session: Session) -> dict[str, Any]:
|
def _session_payload(session: Session) -> SessionPayload:
|
||||||
return {
|
return JsonlSessionStore.session_payload(session)
|
||||||
"key": session.key,
|
|
||||||
"created_at": session.created_at.isoformat(),
|
|
||||||
"updated_at": session.updated_at.isoformat(),
|
|
||||||
"metadata": session.metadata,
|
|
||||||
"messages": session.messages,
|
|
||||||
}
|
|
||||||
|
|
||||||
def save(self, session: Session, *, fsync: bool = False) -> None:
|
def save(self, session: Session, *, fsync: bool = False) -> None:
|
||||||
"""Save a session to disk atomically.
|
"""Persist a session and retain it in the cache."""
|
||||||
|
|
||||||
When *fsync* is ``True`` the final file and its parent directory are
|
|
||||||
explicitly flushed to durable storage. This is intentionally off by
|
|
||||||
default (the OS page-cache is sufficient for normal operation) but
|
|
||||||
should be enabled during graceful shutdown so that filesystems with
|
|
||||||
write-back caching (e.g. rclone VFS, NFS, FUSE mounts) do not lose
|
|
||||||
the most recent writes.
|
|
||||||
"""
|
|
||||||
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(
|
||||||
@@ -696,46 +1027,7 @@ class SessionManager:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
path = self._get_session_path(session.key)
|
self._store.save(session, fsync=fsync)
|
||||||
tmp_path = path.with_suffix(".jsonl.tmp")
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
||||||
metadata_line = {
|
|
||||||
"_type": "metadata",
|
|
||||||
"key": session.key,
|
|
||||||
"created_at": session.created_at.isoformat(),
|
|
||||||
"updated_at": session.updated_at.isoformat(),
|
|
||||||
"metadata": session.metadata,
|
|
||||||
"last_consolidated": session.last_consolidated
|
|
||||||
}
|
|
||||||
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
|
|
||||||
for msg in session.messages:
|
|
||||||
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
|
||||||
if fsync:
|
|
||||||
f.flush()
|
|
||||||
os.fsync(f.fileno())
|
|
||||||
|
|
||||||
os.replace(tmp_path, path)
|
|
||||||
|
|
||||||
if fsync:
|
|
||||||
# fsync the directory so the rename is durable.
|
|
||||||
# On Windows, opening a directory with O_RDONLY raises
|
|
||||||
# PermissionError; some shared filesystems allow the open but
|
|
||||||
# reject directory fsync with EINVAL.
|
|
||||||
with suppress(PermissionError):
|
|
||||||
fd = os.open(str(path.parent), os.O_RDONLY)
|
|
||||||
try:
|
|
||||||
os.fsync(fd)
|
|
||||||
except OSError as exc:
|
|
||||||
if exc.errno != errno.EINVAL:
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
except BaseException:
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
self._remember(session)
|
self._remember(session)
|
||||||
|
|
||||||
def flush_all(self) -> int:
|
def flush_all(self) -> int:
|
||||||
@@ -762,26 +1054,9 @@ class SessionManager:
|
|||||||
self._overflow_cache.pop(key, None)
|
self._overflow_cache.pop(key, None)
|
||||||
|
|
||||||
def delete_session(self, key: str) -> bool:
|
def delete_session(self, key: str) -> bool:
|
||||||
"""Remove a session from disk (both workspace and legacy locations) and cache.
|
"""Delete a persisted session and invalidate its cache entry."""
|
||||||
|
|
||||||
Returns True if at least one JSONL file was found and unlinked.
|
|
||||||
"""
|
|
||||||
paths = [
|
|
||||||
self._get_session_path(key),
|
|
||||||
self._get_legacy_lossy_path(key),
|
|
||||||
self._get_legacy_session_path(key),
|
|
||||||
]
|
|
||||||
self.invalidate(key)
|
self.invalidate(key)
|
||||||
deleted = False
|
return self._store.delete(key)
|
||||||
for path in paths:
|
|
||||||
if not path.exists():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
path.unlink()
|
|
||||||
deleted = True
|
|
||||||
except OSError as e:
|
|
||||||
logger.warning("Failed to delete session file {}: {}", path, e)
|
|
||||||
return deleted
|
|
||||||
|
|
||||||
def fork_session_before_user_index(
|
def fork_session_before_user_index(
|
||||||
self,
|
self,
|
||||||
@@ -840,180 +1115,12 @@ class SessionManager:
|
|||||||
return target
|
return target
|
||||||
|
|
||||||
def read_session_file(self, key: str) -> dict[str, Any] | None:
|
def read_session_file(self, key: str) -> dict[str, Any] | None:
|
||||||
"""Load a session from disk without caching; intended for read-only HTTP endpoints.
|
"""Read a session without populating the cache."""
|
||||||
|
return cast(dict[str, Any] | None, self._store.read(key))
|
||||||
Returns ``{"key", "created_at", "updated_at", "metadata", "messages"}`` or
|
|
||||||
``None`` when the session file does not exist or fails to parse.
|
|
||||||
"""
|
|
||||||
path = self._get_session_path(key)
|
|
||||||
if not path.exists():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
messages: list[dict[str, Any]] = []
|
|
||||||
metadata: object = {}
|
|
||||||
created_at: object = None
|
|
||||||
updated_at: object = None
|
|
||||||
stored_key: object = None
|
|
||||||
with open(path, encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
raw_data: object = json.loads(line)
|
|
||||||
data = _json_object(raw_data)
|
|
||||||
if data.get("_type") == "metadata":
|
|
||||||
metadata = cast(object, data.get("metadata", {}))
|
|
||||||
created_at = cast(object, data.get("created_at"))
|
|
||||||
updated_at = cast(object, data.get("updated_at"))
|
|
||||||
stored_key = cast(object, data.get("key"))
|
|
||||||
else:
|
|
||||||
messages.append(data)
|
|
||||||
return {
|
|
||||||
"key": stored_key or key,
|
|
||||||
"created_at": created_at,
|
|
||||||
"updated_at": updated_at,
|
|
||||||
"metadata": metadata,
|
|
||||||
"messages": messages,
|
|
||||||
}
|
|
||||||
except _SESSION_DATA_ERRORS as e:
|
|
||||||
logger.warning("Failed to read session {}: {}", key, e)
|
|
||||||
repaired = self._repair(key, path=path)
|
|
||||||
if repaired is not None:
|
|
||||||
logger.info("Recovered read-only session view {} from corrupt file", key)
|
|
||||||
return self._session_payload(repaired)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def read_session_metadata(self, key: str) -> dict[str, Any] | None:
|
def read_session_metadata(self, key: str) -> dict[str, Any] | None:
|
||||||
"""Load only the metadata record from a session file.
|
"""Read session metadata without loading the transcript."""
|
||||||
|
return cast(dict[str, Any] | None, self._store.read_metadata(key))
|
||||||
This is used by WebUI routes that need session-level metadata but not the
|
|
||||||
full conversation transcript.
|
|
||||||
"""
|
|
||||||
path = self._get_session_path(key)
|
|
||||||
if not path.exists():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
with open(path, encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
raw_data: object = json.loads(line)
|
|
||||||
data = _json_object(raw_data)
|
|
||||||
if data.get("_type") != "metadata":
|
|
||||||
return None
|
|
||||||
metadata = cast(object, data.get("metadata", {}))
|
|
||||||
return {
|
|
||||||
"key": data.get("key") or key,
|
|
||||||
"created_at": data.get("created_at"),
|
|
||||||
"updated_at": data.get("updated_at"),
|
|
||||||
"metadata": (
|
|
||||||
cast(dict[str, Any], metadata)
|
|
||||||
if isinstance(metadata, dict)
|
|
||||||
else {}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return None
|
|
||||||
except _SESSION_DATA_ERRORS as e:
|
|
||||||
logger.warning("Failed to read session metadata {}: {}", key, e)
|
|
||||||
repaired = self._repair(key, path=path)
|
|
||||||
if repaired is not None:
|
|
||||||
logger.info("Recovered read-only session metadata {} from corrupt file", key)
|
|
||||||
return {
|
|
||||||
"key": repaired.key,
|
|
||||||
"created_at": repaired.created_at.isoformat(),
|
|
||||||
"updated_at": repaired.updated_at.isoformat(),
|
|
||||||
"metadata": repaired.metadata,
|
|
||||||
}
|
|
||||||
return None
|
|
||||||
|
|
||||||
def list_sessions(self) -> list[dict[str, Any]]:
|
def list_sessions(self) -> list[dict[str, Any]]:
|
||||||
"""
|
return cast(list[dict[str, Any]], self._store.list_sessions())
|
||||||
List all sessions.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of session info dicts.
|
|
||||||
"""
|
|
||||||
sessions: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
for path in self.sessions_dir.glob("*.jsonl"):
|
|
||||||
storage_key = self._session_key_from_path(path)
|
|
||||||
if storage_key is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
# Read the metadata line and a small preview for session lists.
|
|
||||||
with open(path, encoding="utf-8") as f:
|
|
||||||
first_line = f.readline().strip()
|
|
||||||
if first_line:
|
|
||||||
raw_data: object = json.loads(first_line)
|
|
||||||
data = _json_object(raw_data)
|
|
||||||
if data.get("_type") == "metadata":
|
|
||||||
key = cast(object, data.get("key")) or storage_key
|
|
||||||
metadata = cast(object, data.get("metadata", {}))
|
|
||||||
title = _metadata_title(metadata)
|
|
||||||
preview = ""
|
|
||||||
fallback_preview = ""
|
|
||||||
scanned_records = 0
|
|
||||||
scanned_chars = 0
|
|
||||||
for line in f:
|
|
||||||
if not line.strip():
|
|
||||||
continue
|
|
||||||
scanned_records += 1
|
|
||||||
scanned_chars += len(line)
|
|
||||||
if (
|
|
||||||
scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS
|
|
||||||
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
|
|
||||||
):
|
|
||||||
break
|
|
||||||
raw_item: object = json.loads(line)
|
|
||||||
item = _json_object(raw_item)
|
|
||||||
if item.get("_type") == "metadata":
|
|
||||||
continue
|
|
||||||
text = _message_preview_text(item)
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
if item.get("role") == "user":
|
|
||||||
preview = text
|
|
||||||
break
|
|
||||||
if not fallback_preview and item.get("role") == "assistant":
|
|
||||||
fallback_preview = text
|
|
||||||
preview = preview or fallback_preview
|
|
||||||
fallback_time = datetime.fromtimestamp(path.stat().st_mtime).isoformat()
|
|
||||||
sessions.append(
|
|
||||||
{
|
|
||||||
"key": key,
|
|
||||||
"created_at": data.get("created_at") or fallback_time,
|
|
||||||
"updated_at": data.get("updated_at") or fallback_time,
|
|
||||||
"title": title,
|
|
||||||
"preview": preview,
|
|
||||||
"path": str(path),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
continue
|
|
||||||
except _SESSION_DATA_ERRORS:
|
|
||||||
repaired = self._repair(storage_key, path=path)
|
|
||||||
if repaired is not None:
|
|
||||||
sessions.append(
|
|
||||||
{
|
|
||||||
"key": repaired.key,
|
|
||||||
"created_at": repaired.created_at.isoformat(),
|
|
||||||
"updated_at": repaired.updated_at.isoformat(),
|
|
||||||
"title": _metadata_title(repaired.metadata),
|
|
||||||
"preview": next(
|
|
||||||
(
|
|
||||||
text
|
|
||||||
for msg in repaired.messages
|
|
||||||
if (text := _message_preview_text(msg))
|
|
||||||
),
|
|
||||||
"",
|
|
||||||
),
|
|
||||||
"path": str(path),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
return sorted(
|
|
||||||
sessions,
|
|
||||||
key=lambda item: cast(str, item.get("updated_at", "")),
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
## Runtime
|
## Runtime
|
||||||
{{ runtime }}
|
{{ runtime }}
|
||||||
|
|
||||||
{% set resource_path = agent_resource_path | default(agent_workspace_path) %}
|
|
||||||
## Workspace
|
## Workspace
|
||||||
Your current project workspace is at: {{ workspace_path }}
|
Your current project workspace is at: {{ workspace_path }}
|
||||||
{% if agent_workspace_path != workspace_path %}
|
{% if agent_workspace_path != workspace_path %}
|
||||||
Nanobot's agent workspace is at: {{ agent_workspace_path }}
|
Nanobot's agent workspace is at: {{ agent_workspace_path }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
- Agent profile: {{ resource_path }}/SOUL.md and {{ resource_path }}/USER.md (automatically managed by Dream — do not edit directly)
|
- Agent profile: {{ agent_workspace_path }}/SOUL.md and {{ agent_workspace_path }}/USER.md (automatically managed by Dream — do not edit directly)
|
||||||
- Long-term memory: {{ resource_path }}/memory/MEMORY.md (automatically managed by Dream — do not edit directly)
|
- Long-term memory: {{ agent_workspace_path }}/memory/MEMORY.md (automatically managed by Dream — do not edit directly)
|
||||||
- History log: {{ resource_path }}/memory/history.jsonl (append-only JSONL; prefer built-in `grep` for search).
|
- History log: {{ agent_workspace_path }}/memory/history.jsonl (append-only JSONL; prefer built-in `grep` for search).
|
||||||
- Custom skills: {{ resource_path }}/skills/{% raw %}{skill-name}{% endraw %}/SKILL.md
|
- Custom skills: {{ agent_workspace_path }}/skills/{% raw %}{skill-name}{% endraw %}/SKILL.md
|
||||||
|
|
||||||
{{ platform_policy }}
|
{{ platform_policy }}
|
||||||
{% if channel == 'telegram' or channel == 'qq' or channel == 'discord' %}
|
{% if channel == 'telegram' or channel == 'qq' or channel == 'discord' %}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
## Resource Aliases
|
|
||||||
|
|
||||||
These stable filesystem aliases are available:
|
|
||||||
{% for label, path in aliases %}
|
|
||||||
- {{ label }}: `{{ path }}`
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
Aliases are alternative path names only; they do not grant additional file or shell permissions. A sandboxed shell may not expose an alias even when a file tool can use it. Continue to use paths relative to the current project workspace for project files.
|
|
||||||
@@ -11,10 +11,6 @@ Current project workspace: {{ workspace }}
|
|||||||
Nanobot's agent workspace: {{ agent_workspace }}
|
Nanobot's agent workspace: {{ agent_workspace }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
History log: {{ history_log }}
|
History log: {{ history_log }}
|
||||||
{% if resource_aliases %}
|
|
||||||
|
|
||||||
{{ resource_aliases }}
|
|
||||||
{% endif %}
|
|
||||||
{% if skills_summary %}
|
{% if skills_summary %}
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import binascii
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
@@ -126,17 +127,33 @@ def sign_or_stage_media_path(
|
|||||||
signed = sign_media_path(path, secret=secret, media_dir=media_dir)
|
signed = sign_media_path(path, secret=secret, media_dir=media_dir)
|
||||||
if signed is not None:
|
if signed is not None:
|
||||||
return {"url": signed, "name": path.name}
|
return {"url": signed, "name": path.name}
|
||||||
|
staged_tmp: Path | None = None
|
||||||
try:
|
try:
|
||||||
if not path.is_file():
|
resolved = path.resolve(strict=True)
|
||||||
|
if not resolved.is_file():
|
||||||
return None
|
return None
|
||||||
|
source_stat = resolved.stat()
|
||||||
target_dir = media_dir("websocket")
|
target_dir = media_dir("websocket")
|
||||||
safe_name = safe_filename(path.name) or "attachment"
|
safe_name = safe_filename(path.name) or "attachment"
|
||||||
staged = target_dir / f"{uuid.uuid4().hex[:12]}-{safe_name}"
|
source_version = "\0".join((
|
||||||
shutil.copyfile(path, staged)
|
os.path.normcase(str(resolved)),
|
||||||
|
str(source_stat.st_size),
|
||||||
|
str(source_stat.st_mtime_ns),
|
||||||
|
str(source_stat.st_ctime_ns),
|
||||||
|
))
|
||||||
|
source_digest = hashlib.sha256(source_version.encode("utf-8")).hexdigest()[:20]
|
||||||
|
staged = target_dir / f"{source_digest}-{safe_name}"
|
||||||
|
if not staged.is_file() or staged.stat().st_size != source_stat.st_size:
|
||||||
|
staged_tmp = target_dir / f".{source_digest}-{uuid.uuid4().hex}.tmp"
|
||||||
|
shutil.copyfile(resolved, staged_tmp)
|
||||||
|
staged_tmp.replace(staged)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
if logger is not None:
|
if logger is not None:
|
||||||
logger.warning("failed to stage outbound media {}: {}", path, exc)
|
logger.warning("failed to stage outbound media {}: {}", path, exc)
|
||||||
return None
|
return None
|
||||||
|
finally:
|
||||||
|
if staged_tmp is not None:
|
||||||
|
staged_tmp.unlink(missing_ok=True)
|
||||||
signed = sign_media_path(staged, secret=secret, media_dir=media_dir)
|
signed = sign_media_path(staged, secret=secret, media_dir=media_dir)
|
||||||
if signed is None:
|
if signed is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Shared WebUI metadata keys."""
|
"""Shared WebUI metadata keys."""
|
||||||
|
|
||||||
WEBUI_TURN_METADATA_KEY = "webui_turn_id"
|
WEBUI_TURN_METADATA_KEY = "webui_turn_id"
|
||||||
|
WEBUI_SYSTEM_COMMAND_TURN_PREFIX = "webui-system:"
|
||||||
WEBSOCKET_TURN_OWNER_METADATA_KEY = "_websocket_turn_owner"
|
WEBSOCKET_TURN_OWNER_METADATA_KEY = "_websocket_turn_owner"
|
||||||
WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source"
|
WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source"
|
||||||
|
|||||||
@@ -131,10 +131,10 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
|
|||||||
}
|
}
|
||||||
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 500_000, 1_048_576}
|
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 500_000, 1_048_576}
|
||||||
_OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"}
|
_OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"}
|
||||||
_XAI_WEBUI_OAUTH_TIMEOUT_S = 600
|
_WEBUI_OAUTH_TIMEOUT_S = 600
|
||||||
_XAI_WEBUI_OAUTH_MAX_FLOWS = 8
|
_WEBUI_OAUTH_MAX_FLOWS = 8
|
||||||
_xai_webui_oauth_flows: dict[str, Any] = {}
|
_webui_oauth_flows: dict[str, tuple[str, Any]] = {}
|
||||||
_xai_webui_oauth_flows_lock = threading.Lock()
|
_webui_oauth_flows_lock = threading.Lock()
|
||||||
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
||||||
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||||
|
|
||||||
@@ -1810,7 +1810,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
|
|
||||||
if spec.name == "openai_codex":
|
if spec.name == "openai_codex":
|
||||||
try:
|
try:
|
||||||
from oauth_cli_kit import get_token, login_oauth_interactive
|
from nanobot.providers.openai_codex_oauth import start_openai_codex_oauth_login
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise WebUISettingsError(
|
raise WebUISettingsError(
|
||||||
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
|
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
|
||||||
@@ -1820,19 +1820,30 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise WebUISettingsError(str(e), status=400) from e
|
raise WebUISettingsError(str(e), status=400) from e
|
||||||
token = None
|
remote_browser_value = _query_first(query, "remote_browser")
|
||||||
with suppress(Exception):
|
remote_browser = (
|
||||||
token = get_token(proxy=proxy)
|
_parse_bool(remote_browser_value, "remote_browser")
|
||||||
if not (token and token.access):
|
if remote_browser_value is not None
|
||||||
messages: list[str] = []
|
else False
|
||||||
token = login_oauth_interactive(
|
)
|
||||||
print_fn=lambda message: messages.append(str(message)),
|
try:
|
||||||
prompt_fn=lambda _prompt: "",
|
flow = start_openai_codex_oauth_login(
|
||||||
proxy=proxy,
|
proxy=proxy,
|
||||||
|
timeout_s=_WEBUI_OAUTH_TIMEOUT_S,
|
||||||
|
open_browser=not remote_browser,
|
||||||
)
|
)
|
||||||
if not (token and token.access):
|
except Exception as e:
|
||||||
raise WebUISettingsError("OAuth login failed", status=401)
|
raise WebUISettingsError(f"OpenAI Codex OAuth login failed: {e}", status=502) from e
|
||||||
return settings_payload()
|
flow_id = secrets.token_urlsafe(24)
|
||||||
|
_register_webui_oauth_flow(spec.name, flow_id, flow)
|
||||||
|
return {
|
||||||
|
"status": "authorization_required",
|
||||||
|
"provider": spec.name,
|
||||||
|
"flow_id": flow_id,
|
||||||
|
"authorization_url": flow.authorization_url,
|
||||||
|
"expires_in": flow.remaining_seconds,
|
||||||
|
"completion_input": "callback_url",
|
||||||
|
}
|
||||||
|
|
||||||
if spec.name == "github_copilot":
|
if spec.name == "github_copilot":
|
||||||
try:
|
try:
|
||||||
@@ -1862,18 +1873,19 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
try:
|
try:
|
||||||
flow = start_xai_oauth_login(
|
flow = start_xai_oauth_login(
|
||||||
proxy=proxy,
|
proxy=proxy,
|
||||||
timeout_s=_XAI_WEBUI_OAUTH_TIMEOUT_S,
|
timeout_s=_WEBUI_OAUTH_TIMEOUT_S,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e
|
raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e
|
||||||
flow_id = secrets.token_urlsafe(24)
|
flow_id = secrets.token_urlsafe(24)
|
||||||
_register_xai_webui_oauth_flow(flow_id, flow)
|
_register_webui_oauth_flow(spec.name, flow_id, flow)
|
||||||
return {
|
return {
|
||||||
"status": "authorization_required",
|
"status": "authorization_required",
|
||||||
"provider": spec.name,
|
"provider": spec.name,
|
||||||
"flow_id": flow_id,
|
"flow_id": flow_id,
|
||||||
"authorization_url": flow.authorization_url,
|
"authorization_url": flow.authorization_url,
|
||||||
"expires_in": flow.remaining_seconds,
|
"expires_in": flow.remaining_seconds,
|
||||||
|
"completion_input": "authorization_code",
|
||||||
}
|
}
|
||||||
|
|
||||||
raise WebUISettingsError("OAuth login is not supported for this provider")
|
raise WebUISettingsError("OAuth login is not supported for this provider")
|
||||||
@@ -1881,34 +1893,47 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
|
|
||||||
def complete_oauth_provider(
|
def complete_oauth_provider(
|
||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
authorization_code: str | None = None,
|
authorization_response: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
provider_name = (_query_first(query, "provider") or "").strip()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
flow_id = (_query_first(query, "flow_id") or "").strip()
|
flow_id = (_query_first(query, "flow_id") or "").strip()
|
||||||
spec = find_by_name(provider_name)
|
spec = find_by_name(provider_name)
|
||||||
if spec is None or spec.name != "xai_grok":
|
if spec is None or spec.name not in {"openai_codex", "xai_grok"}:
|
||||||
raise WebUISettingsError("OAuth completion is not supported for this provider")
|
raise WebUISettingsError("OAuth completion is not supported for this provider")
|
||||||
if not flow_id:
|
if not flow_id:
|
||||||
raise WebUISettingsError("flow_id is required")
|
raise WebUISettingsError("flow_id is required")
|
||||||
|
|
||||||
flow = _get_xai_webui_oauth_flow(flow_id)
|
flow = _get_webui_oauth_flow(spec.name, flow_id)
|
||||||
if flow is None:
|
if flow is None:
|
||||||
raise WebUISettingsError("xAI sign-in expired. Start again.", status=410)
|
raise WebUISettingsError(f"{spec.label} sign-in expired. Start again.", status=410)
|
||||||
|
|
||||||
from nanobot.providers.xai_oauth import complete_xai_oauth_login
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
token = complete_xai_oauth_login(flow, authorization_code)
|
if spec.name == "openai_codex":
|
||||||
|
from nanobot.providers.openai_codex_oauth import (
|
||||||
|
OpenAICodexOAuthInputError,
|
||||||
|
complete_openai_codex_oauth_login,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = complete_openai_codex_oauth_login(flow, authorization_response)
|
||||||
|
except OpenAICodexOAuthInputError as e:
|
||||||
|
raise WebUISettingsError(str(e), status=400) from e
|
||||||
|
else:
|
||||||
|
from nanobot.providers.xai_oauth import complete_xai_oauth_login
|
||||||
|
|
||||||
|
token = complete_xai_oauth_login(flow, authorization_response)
|
||||||
|
except WebUISettingsError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_remove_xai_webui_oauth_flow(flow_id, flow)
|
_remove_webui_oauth_flow(spec.name, flow_id, flow)
|
||||||
raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e
|
raise WebUISettingsError(f"{spec.label} OAuth login failed: {e}", status=502) from e
|
||||||
if token is None:
|
if token is None:
|
||||||
return {
|
return {
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"provider": spec.name,
|
"provider": spec.name,
|
||||||
"flow_id": flow_id,
|
"flow_id": flow_id,
|
||||||
}
|
}
|
||||||
_remove_xai_webui_oauth_flow(flow_id, flow, cancel=False)
|
_remove_webui_oauth_flow(spec.name, flow_id, flow, cancel=False)
|
||||||
if not token.access:
|
if not token.access:
|
||||||
raise WebUISettingsError("OAuth login failed", status=401)
|
raise WebUISettingsError("OAuth login failed", status=401)
|
||||||
return settings_payload()
|
return settings_payload()
|
||||||
@@ -1930,6 +1955,7 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
raise WebUISettingsError(
|
raise WebUISettingsError(
|
||||||
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
|
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
|
||||||
) from None
|
) from None
|
||||||
|
_clear_webui_oauth_flows(spec.name)
|
||||||
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
|
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
|
||||||
elif spec.name == "github_copilot":
|
elif spec.name == "github_copilot":
|
||||||
try:
|
try:
|
||||||
@@ -1942,7 +1968,7 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
elif spec.name == "xai_grok":
|
elif spec.name == "xai_grok":
|
||||||
from nanobot.providers.xai_oauth import logout_xai_oauth
|
from nanobot.providers.xai_oauth import logout_xai_oauth
|
||||||
|
|
||||||
_clear_xai_webui_oauth_flows()
|
_clear_webui_oauth_flows(spec.name)
|
||||||
logout_xai_oauth()
|
logout_xai_oauth()
|
||||||
return settings_payload()
|
return settings_payload()
|
||||||
else:
|
else:
|
||||||
@@ -1954,47 +1980,60 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
return settings_payload()
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def _register_xai_webui_oauth_flow(flow_id: str, flow: Any) -> None:
|
def _register_webui_oauth_flow(provider_name: str, flow_id: str, flow: Any) -> None:
|
||||||
discarded: list[Any] = []
|
discarded: list[Any] = []
|
||||||
with _xai_webui_oauth_flows_lock:
|
with _webui_oauth_flows_lock:
|
||||||
for existing_id, existing in list(_xai_webui_oauth_flows.items()):
|
for existing_id, (_provider_name, existing) in list(_webui_oauth_flows.items()):
|
||||||
if existing.expired:
|
if existing.expired:
|
||||||
discarded.append(_xai_webui_oauth_flows.pop(existing_id))
|
discarded.append(_webui_oauth_flows.pop(existing_id)[1])
|
||||||
while len(_xai_webui_oauth_flows) >= _XAI_WEBUI_OAUTH_MAX_FLOWS:
|
while len(_webui_oauth_flows) >= _WEBUI_OAUTH_MAX_FLOWS:
|
||||||
oldest_id = next(iter(_xai_webui_oauth_flows))
|
oldest_id = next(iter(_webui_oauth_flows))
|
||||||
discarded.append(_xai_webui_oauth_flows.pop(oldest_id))
|
discarded.append(_webui_oauth_flows.pop(oldest_id)[1])
|
||||||
_xai_webui_oauth_flows[flow_id] = flow
|
_webui_oauth_flows[flow_id] = (provider_name, flow)
|
||||||
for existing in discarded:
|
for existing in discarded:
|
||||||
existing.cancel()
|
existing.cancel()
|
||||||
|
|
||||||
|
|
||||||
def _get_xai_webui_oauth_flow(flow_id: str) -> Any | None:
|
def _get_webui_oauth_flow(provider_name: str, flow_id: str) -> Any | None:
|
||||||
with _xai_webui_oauth_flows_lock:
|
with _webui_oauth_flows_lock:
|
||||||
flow = _xai_webui_oauth_flows.get(flow_id)
|
registered = _webui_oauth_flows.get(flow_id)
|
||||||
if flow is None or not flow.expired:
|
if registered is None or registered[0] != provider_name:
|
||||||
|
return None
|
||||||
|
flow = registered[1]
|
||||||
|
if not flow.expired:
|
||||||
return flow
|
return flow
|
||||||
_xai_webui_oauth_flows.pop(flow_id, None)
|
_webui_oauth_flows.pop(flow_id, None)
|
||||||
flow.cancel()
|
flow.cancel()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _remove_xai_webui_oauth_flow(
|
def _remove_webui_oauth_flow(
|
||||||
|
provider_name: str,
|
||||||
flow_id: str,
|
flow_id: str,
|
||||||
flow: Any,
|
flow: Any,
|
||||||
*,
|
*,
|
||||||
cancel: bool = True,
|
cancel: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
with _xai_webui_oauth_flows_lock:
|
with _webui_oauth_flows_lock:
|
||||||
if _xai_webui_oauth_flows.get(flow_id) is flow:
|
registered = _webui_oauth_flows.get(flow_id)
|
||||||
_xai_webui_oauth_flows.pop(flow_id)
|
if (
|
||||||
|
registered is not None
|
||||||
|
and registered[0] == provider_name
|
||||||
|
and registered[1] is flow
|
||||||
|
):
|
||||||
|
_webui_oauth_flows.pop(flow_id)
|
||||||
if cancel:
|
if cancel:
|
||||||
flow.cancel()
|
flow.cancel()
|
||||||
|
|
||||||
|
|
||||||
def _clear_xai_webui_oauth_flows() -> None:
|
def _clear_webui_oauth_flows(provider_name: str) -> None:
|
||||||
with _xai_webui_oauth_flows_lock:
|
with _webui_oauth_flows_lock:
|
||||||
flows = list(_xai_webui_oauth_flows.values())
|
flow_ids = [
|
||||||
_xai_webui_oauth_flows.clear()
|
flow_id
|
||||||
|
for flow_id, (registered_provider, _flow) in _webui_oauth_flows.items()
|
||||||
|
if registered_provider == provider_name
|
||||||
|
]
|
||||||
|
flows = [_webui_oauth_flows.pop(flow_id)[1] for flow_id in flow_ids]
|
||||||
for flow in flows:
|
for flow in flows:
|
||||||
flow.cancel()
|
flow.cancel()
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ _CHANNEL_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
|||||||
_API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"
|
_API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"
|
||||||
_API_SERVICE_VALUES_HEADER_MAX_BYTES = 8 * 1024
|
_API_SERVICE_VALUES_HEADER_MAX_BYTES = 8 * 1024
|
||||||
_OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code"
|
_OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code"
|
||||||
_OAUTH_CODE_HEADER_MAX_BYTES = 8 * 1024
|
_OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback"
|
||||||
|
_OAUTH_RESPONSE_HEADER_MAX_BYTES = 8 * 1024
|
||||||
|
|
||||||
_SKIP_FIELD = object()
|
_SKIP_FIELD = object()
|
||||||
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
|
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
|
||||||
@@ -471,16 +472,22 @@ class WebUISettingsRouter:
|
|||||||
if action == "login":
|
if action == "login":
|
||||||
payload = await asyncio.to_thread(login_oauth_provider, query)
|
payload = await asyncio.to_thread(login_oauth_provider, query)
|
||||||
elif action == "complete":
|
elif action == "complete":
|
||||||
authorization_code = case_insensitive_header(
|
authorization_response = case_insensitive_header(
|
||||||
|
request.headers,
|
||||||
|
_OAUTH_CALLBACK_HEADER,
|
||||||
|
) or case_insensitive_header(
|
||||||
request.headers,
|
request.headers,
|
||||||
_OAUTH_CODE_HEADER,
|
_OAUTH_CODE_HEADER,
|
||||||
)
|
)
|
||||||
if len(authorization_code.encode("utf-8")) > _OAUTH_CODE_HEADER_MAX_BYTES:
|
if (
|
||||||
raise WebUISettingsError("OAuth authorization code is too large")
|
len(authorization_response.encode("utf-8"))
|
||||||
|
> _OAUTH_RESPONSE_HEADER_MAX_BYTES
|
||||||
|
):
|
||||||
|
raise WebUISettingsError("OAuth authorization response is too large")
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(
|
||||||
complete_oauth_provider,
|
complete_oauth_provider,
|
||||||
query,
|
query,
|
||||||
authorization_code or None,
|
authorization_response or None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
payload = await asyncio.to_thread(logout_oauth_provider, query)
|
payload = await asyncio.to_thread(logout_oauth_provider, query)
|
||||||
|
|||||||
+104
-3
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -34,6 +35,7 @@ _TRANSCRIPT_SEGMENT_RE = re.compile(r"^\d{6}\.jsonl$")
|
|||||||
_DEFAULT_TRANSCRIPT_PAGE_LIMIT = 160
|
_DEFAULT_TRANSCRIPT_PAGE_LIMIT = 160
|
||||||
_MAX_TRANSCRIPT_PAGE_LIMIT = 1000
|
_MAX_TRANSCRIPT_PAGE_LIMIT = 1000
|
||||||
_WEBUI_TURN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
_WEBUI_TURN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
||||||
|
_WEBUI_REPLAY_IDENTITY_KEY = "_webui_replay_identity"
|
||||||
_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
|
_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
|
||||||
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
|
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
|
||||||
)
|
)
|
||||||
@@ -194,6 +196,20 @@ def _flatten_turns(turns: list[list[dict[str, Any]]]) -> list[dict[str, Any]]:
|
|||||||
return [record for turn in turns for record in turn]
|
return [record for turn in turns for record in turn]
|
||||||
|
|
||||||
|
|
||||||
|
def _records_with_replay_identity(
|
||||||
|
records: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
turn_ordinal: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
**record,
|
||||||
|
_WEBUI_REPLAY_IDENTITY_KEY: f"turn:{turn_ordinal}:record:{record_index}",
|
||||||
|
}
|
||||||
|
for record_index, record in enumerate(records)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _write_records_to_path(path: Path, rows: list[dict[str, Any]]) -> None:
|
def _write_records_to_path(path: Path, rows: list[dict[str, Any]]) -> None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||||||
@@ -543,7 +559,14 @@ def _select_transcript_page(
|
|||||||
break
|
break
|
||||||
|
|
||||||
selected_chronological = list(reversed(selected))
|
selected_chronological = list(reversed(selected))
|
||||||
lines = [record for ref in selected_chronological for record in ref.records]
|
lines = [
|
||||||
|
record
|
||||||
|
for ref in selected_chronological
|
||||||
|
for record in _records_with_replay_identity(
|
||||||
|
ref.records,
|
||||||
|
turn_ordinal=ref.ordinal,
|
||||||
|
)
|
||||||
|
]
|
||||||
if not selected_chronological:
|
if not selected_chronological:
|
||||||
return [], {
|
return [], {
|
||||||
"before_cursor": None,
|
"before_cursor": None,
|
||||||
@@ -1030,6 +1053,74 @@ def _split_transcript_turns(lines: list[dict[str, Any]]) -> list[list[dict[str,
|
|||||||
return turns
|
return turns
|
||||||
|
|
||||||
|
|
||||||
|
def _annotate_replay_identities(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
record
|
||||||
|
for turn_ordinal, turn in enumerate(_split_transcript_turns(lines))
|
||||||
|
for record in _records_with_replay_identity(
|
||||||
|
turn,
|
||||||
|
turn_ordinal=turn_ordinal,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _stable_record_digest(record: dict[str, Any]) -> str:
|
||||||
|
persisted = {
|
||||||
|
key: value
|
||||||
|
for key, value in record.items()
|
||||||
|
if key != _WEBUI_REPLAY_IDENTITY_KEY
|
||||||
|
}
|
||||||
|
raw = json.dumps(
|
||||||
|
persisted,
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
sort_keys=True,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_replay_identities(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Give backfilled/recovered rows a stable identity beside persisted rows."""
|
||||||
|
annotated: list[dict[str, Any]] = []
|
||||||
|
for fallback_turn_index, turn in enumerate(_split_transcript_turns(lines)):
|
||||||
|
anchor = next(
|
||||||
|
(
|
||||||
|
value
|
||||||
|
for record in turn
|
||||||
|
if isinstance(
|
||||||
|
value := record.get(_WEBUI_REPLAY_IDENTITY_KEY),
|
||||||
|
str,
|
||||||
|
)
|
||||||
|
and value
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if anchor and ":record:" in anchor:
|
||||||
|
turn_identity = anchor.rsplit(":record:", 1)[0]
|
||||||
|
else:
|
||||||
|
turn_digest = hashlib.sha256(
|
||||||
|
"\n".join(_stable_record_digest(record) for record in turn).encode("ascii")
|
||||||
|
).hexdigest()[:16]
|
||||||
|
turn_identity = f"legacy:{fallback_turn_index}:{turn_digest}"
|
||||||
|
synthetic_occurrences: dict[str, int] = {}
|
||||||
|
for record in turn:
|
||||||
|
identity = record.get(_WEBUI_REPLAY_IDENTITY_KEY)
|
||||||
|
if isinstance(identity, str) and identity:
|
||||||
|
annotated.append(record)
|
||||||
|
continue
|
||||||
|
digest = _stable_record_digest(record)
|
||||||
|
occurrence = synthetic_occurrences.get(digest, 0)
|
||||||
|
synthetic_occurrences[digest] = occurrence + 1
|
||||||
|
annotated.append({
|
||||||
|
**record,
|
||||||
|
_WEBUI_REPLAY_IDENTITY_KEY: (
|
||||||
|
f"{turn_identity}:synthetic:{digest}:{occurrence}"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return annotated
|
||||||
|
|
||||||
|
|
||||||
def _transcript_turn_signature(records: list[dict[str, Any]]) -> tuple[str, ...]:
|
def _transcript_turn_signature(records: list[dict[str, Any]]) -> tuple[str, ...]:
|
||||||
texts: list[str] = []
|
texts: list[str] = []
|
||||||
for message in replay_transcript_to_ui_messages(records):
|
for message in replay_transcript_to_ui_messages(records):
|
||||||
@@ -1464,9 +1555,18 @@ def replay_transcript_to_ui_messages(
|
|||||||
_ts_base = _now_ms()
|
_ts_base = _now_ms()
|
||||||
closed_turn_ids: set[str] = set()
|
closed_turn_ids: set[str] = set()
|
||||||
replay_turn_aliases: dict[str, str] = {}
|
replay_turn_aliases: dict[str, str] = {}
|
||||||
|
generated_id_occurrences: dict[str, int] = {}
|
||||||
|
|
||||||
def _new_id(prefix: str, idx: int) -> str:
|
def _new_id(prefix: str, idx: int) -> str:
|
||||||
return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}"
|
record = lines[idx] if 0 <= idx < len(lines) else {}
|
||||||
|
identity = record.get(_WEBUI_REPLAY_IDENTITY_KEY)
|
||||||
|
if not isinstance(identity, str) or not identity:
|
||||||
|
identity = f"direct:{idx}:{_stable_record_digest(record)}"
|
||||||
|
digest = hashlib.sha256(f"{prefix}\0{identity}".encode("utf-8")).hexdigest()[:16]
|
||||||
|
base = f"{prefix}-{digest}"
|
||||||
|
occurrence = generated_id_occurrences.get(base, 0)
|
||||||
|
generated_id_occurrences[base] = occurrence + 1
|
||||||
|
return base if occurrence == 0 else f"{base}-{occurrence}"
|
||||||
|
|
||||||
def _created_at_ms(rec: dict[str, Any], idx: int) -> int:
|
def _created_at_ms(rec: dict[str, Any], idx: int) -> int:
|
||||||
created_at_ms = _valid_created_at_ms(rec.get("created_at_ms"))
|
created_at_ms = _valid_created_at_ms(rec.get("created_at_ms"))
|
||||||
@@ -2255,7 +2355,7 @@ def build_webui_thread_response(
|
|||||||
if paginated:
|
if paginated:
|
||||||
lines, page = _select_transcript_page(session_key, limit=limit, before=before)
|
lines, page = _select_transcript_page(session_key, limit=limit, before=before)
|
||||||
else:
|
else:
|
||||||
lines = read_transcript_lines(session_key)
|
lines = _annotate_replay_identities(read_transcript_lines(session_key))
|
||||||
if not lines and active_turn_started_at is None:
|
if not lines and active_turn_started_at is None:
|
||||||
return None
|
return None
|
||||||
lines = inject_missing_user_events_from_session(session_key, lines, session_messages)
|
lines = inject_missing_user_events_from_session(session_key, lines, session_messages)
|
||||||
@@ -2264,6 +2364,7 @@ def build_webui_thread_response(
|
|||||||
session_messages,
|
session_messages,
|
||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
)
|
)
|
||||||
|
lines = _ensure_replay_identities(lines)
|
||||||
fork_boundary = fork_boundary_message_count(lines)
|
fork_boundary = fork_boundary_message_count(lines)
|
||||||
msgs = replay_transcript_to_ui_messages(
|
msgs = replay_transcript_to_ui_messages(
|
||||||
lines,
|
lines,
|
||||||
|
|||||||
+3
-1
@@ -31,6 +31,8 @@ dependencies = [
|
|||||||
"websockets>=15.0,<17.0",
|
"websockets>=15.0,<17.0",
|
||||||
"websocket-client>=1.9.0,<2.0.0",
|
"websocket-client>=1.9.0,<2.0.0",
|
||||||
"httpx>=0.28.0,<1.0.0",
|
"httpx>=0.28.0,<1.0.0",
|
||||||
|
# MCP v2 uses the independently versioned httpx2 package for HTTP transports.
|
||||||
|
"httpx2>=2.5.0,<3.0.0",
|
||||||
"ddgs>=9.5.5,<10.0.0",
|
"ddgs>=9.5.5,<10.0.0",
|
||||||
"oauth-cli-kit>=0.1.6,<1.0.0",
|
"oauth-cli-kit>=0.1.6,<1.0.0",
|
||||||
"loguru>=0.7.3,<1.0.0",
|
"loguru>=0.7.3,<1.0.0",
|
||||||
@@ -40,7 +42,7 @@ dependencies = [
|
|||||||
"croniter>=6.0.0,<7.0.0",
|
"croniter>=6.0.0,<7.0.0",
|
||||||
"prompt-toolkit>=3.0.50,<4.0.0",
|
"prompt-toolkit>=3.0.50,<4.0.0",
|
||||||
"questionary>=2.0.0,<3.0.0",
|
"questionary>=2.0.0,<3.0.0",
|
||||||
"mcp>=1.26.0,<2.0.0",
|
"mcp>=2.0.0,<3.0.0",
|
||||||
"json-repair>=0.57.0,<1.0.0",
|
"json-repair>=0.57.0,<1.0.0",
|
||||||
"chardet>=3.0.2,<6.0.0",
|
"chardet>=3.0.2,<6.0.0",
|
||||||
"openai>=2.8.0",
|
"openai>=2.8.0",
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ def _make_fake_compact(
|
|||||||
track_archived: list | None = None,
|
track_archived: list | None = None,
|
||||||
track_count: bool = False,
|
track_count: bool = False,
|
||||||
):
|
):
|
||||||
"""Return a fake compact_idle_session that mirrors the real method's session mutation."""
|
|
||||||
from nanobot.session.manager import Session as _Session
|
from nanobot.session.manager import Session as _Session
|
||||||
|
|
||||||
state = {"count": 0}
|
state = {"count": 0}
|
||||||
@@ -106,21 +105,20 @@ def _make_fake_compact(
|
|||||||
max_suffix,
|
max_suffix,
|
||||||
extend_to_user=True,
|
extend_to_user=True,
|
||||||
)
|
)
|
||||||
kept = probe.messages
|
visible_suffix = probe.messages
|
||||||
archive_msgs = result.dropped[result.already_consolidated_count:]
|
archive_msgs = result.dropped
|
||||||
|
|
||||||
if not archive_msgs and not kept:
|
if not archive_msgs:
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
last_active = session.updated_at
|
last_active = session.updated_at
|
||||||
s = summary
|
s = summary
|
||||||
if archive_msgs:
|
if on_archive:
|
||||||
if on_archive:
|
result = on_archive(archive_msgs)
|
||||||
result = on_archive(archive_msgs)
|
s = result if isinstance(result, str) else summary
|
||||||
s = result if isinstance(result, str) else summary
|
if track_archived is not None:
|
||||||
if track_archived is not None:
|
track_archived.extend(archive_msgs)
|
||||||
track_archived.extend(archive_msgs)
|
|
||||||
|
|
||||||
if s and s != "(nothing)":
|
if s and s != "(nothing)":
|
||||||
session.metadata["_last_summary"] = {
|
session.metadata["_last_summary"] = {
|
||||||
@@ -128,8 +126,7 @@ def _make_fake_compact(
|
|||||||
"last_active": last_active.isoformat(),
|
"last_active": last_active.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
session.messages = kept
|
session.last_consolidated = len(session.messages) - len(visible_suffix)
|
||||||
session.last_consolidated = 0
|
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
@@ -359,7 +356,7 @@ class TestAutoCompact:
|
|||||||
loop.sessions.save(s2)
|
loop.sessions.save(s2)
|
||||||
|
|
||||||
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
|
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
|
||||||
loop.auto_compact.check_expired(loop._schedule_background, loop.runtime_for_session)
|
loop.auto_compact.check_expired(loop.schedule_background, loop.runtime_for_session)
|
||||||
await _drain_background_tasks(loop)
|
await _drain_background_tasks(loop)
|
||||||
|
|
||||||
active_after = loop.sessions.get_or_create("cli:active")
|
active_after = loop.sessions.get_or_create("cli:active")
|
||||||
@@ -368,8 +365,7 @@ class TestAutoCompact:
|
|||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_archives_prefix_and_keeps_recent_suffix(self, tmp_path):
|
async def test_auto_compact_archives_prefix_without_deleting_history(self, tmp_path):
|
||||||
"""_archive should summarize the old prefix and keep a recent legal suffix."""
|
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6)
|
_add_turns(session, 6)
|
||||||
@@ -384,9 +380,12 @@ class TestAutoCompact:
|
|||||||
|
|
||||||
assert len(archived_messages) == 4
|
assert len(archived_messages) == 4
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
assert len(session_after.messages) == 12
|
||||||
assert session_after.messages[0]["content"] == "msg user 2"
|
assert session_after.messages[0]["content"] == "msg user 0"
|
||||||
assert session_after.messages[-1]["content"] == "msg assistant 5"
|
visible = session_after.get_history(max_messages=12)
|
||||||
|
assert len(visible) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||||
|
assert visible[0]["content"] == "msg user 2"
|
||||||
|
assert visible[-1]["content"] == "msg assistant 5"
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -403,17 +402,19 @@ class TestAutoCompact:
|
|||||||
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) > loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
assert session_after.messages[0]["content"] == "old user 0"
|
||||||
assert session_after.messages[0]["content"] == "record this"
|
visible = session_after.get_history(max_messages=len(session_after.messages))
|
||||||
assert session_after.messages[-1]["content"] == "done"
|
assert len(visible) > loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||||
|
assert visible[0]["content"] == "record this"
|
||||||
|
assert visible[-1]["content"] == "done"
|
||||||
tool_results = {
|
tool_results = {
|
||||||
m.get("tool_call_id")
|
m.get("tool_call_id")
|
||||||
for m in session_after.messages
|
for m in visible
|
||||||
if m.get("role") == "tool"
|
if m.get("role") == "tool"
|
||||||
}
|
}
|
||||||
assert all(
|
assert all(
|
||||||
tc["id"] in tool_results
|
tc["id"] in tool_results
|
||||||
for m in session_after.messages
|
for m in visible
|
||||||
for tc in (m.get("tool_calls") or [])
|
for tc in (m.get("tool_calls") or [])
|
||||||
)
|
)
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
@@ -436,7 +437,10 @@ class TestAutoCompact:
|
|||||||
assert entry is not None
|
assert entry is not None
|
||||||
assert entry[0] == "User said hello."
|
assert entry[0] == "User said hello."
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
assert len(session_after.messages) == 12
|
||||||
|
assert len(session_after.get_history(max_messages=12)) == (
|
||||||
|
loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||||
|
)
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -474,11 +478,10 @@ class TestAutoCompact:
|
|||||||
|
|
||||||
|
|
||||||
class TestAutoCompactIdleDetection:
|
class TestAutoCompactIdleDetection:
|
||||||
"""Test idle detection triggers auto-new in _process_message."""
|
"""Idle detection tests."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_auto_compact_when_ttl_disabled(self, tmp_path):
|
async def test_no_auto_compact_when_ttl_disabled(self, tmp_path):
|
||||||
"""No auto-new should happen when TTL is 0 (disabled)."""
|
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=0)
|
loop = _make_loop(tmp_path, session_ttl_minutes=0)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
session.add_message("user", "old message")
|
session.add_message("user", "old message")
|
||||||
@@ -494,7 +497,6 @@ class TestAutoCompactIdleDetection:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_triggers_on_idle(self, tmp_path):
|
async def test_auto_compact_triggers_on_idle(self, tmp_path):
|
||||||
"""Proactive auto-new archives expired session; _process_message reloads it."""
|
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6, prefix="old")
|
_add_turns(session, 6, prefix="old")
|
||||||
@@ -514,13 +516,16 @@ class TestAutoCompactIdleDetection:
|
|||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(archived_messages) == 4
|
assert len(archived_messages) == 4
|
||||||
assert not any(m["content"] == "old user 0" for m in session_after.messages)
|
assert any(m["content"] == "old user 0" for m in session_after.messages)
|
||||||
|
assert not any(
|
||||||
|
m["content"] == "old user 0"
|
||||||
|
for m in session_after.get_history(max_messages=len(session_after.messages))
|
||||||
|
)
|
||||||
assert any(m["content"] == "new msg" for m in session_after.messages)
|
assert any(m["content"] == "new msg" for m in session_after.messages)
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_auto_compact_when_active(self, tmp_path):
|
async def test_no_auto_compact_when_active(self, tmp_path):
|
||||||
"""No auto-new should happen when session is recently active."""
|
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
session.add_message("user", "recent message")
|
session.add_message("user", "recent message")
|
||||||
@@ -558,7 +563,6 @@ class TestAutoCompactIdleDetection:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_with_slash_new(self, tmp_path):
|
async def test_auto_compact_with_slash_new(self, tmp_path):
|
||||||
"""Auto-new fires before /new dispatches; session is cleared twice but idempotent."""
|
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
for i in range(4):
|
for i in range(4):
|
||||||
@@ -576,7 +580,6 @@ class TestAutoCompactIdleDetection:
|
|||||||
assert "new session started" in response.content.lower()
|
assert "new session started" in response.content.lower()
|
||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
# Session is empty (auto-new archived and cleared, /new cleared again)
|
|
||||||
assert len(session_after.messages) == 0
|
assert len(session_after.messages) == 0
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@@ -617,11 +620,10 @@ class TestAutoCompactIdleDetection:
|
|||||||
|
|
||||||
|
|
||||||
class TestAutoCompactSystemMessages:
|
class TestAutoCompactSystemMessages:
|
||||||
"""Test that auto-new also works for system messages."""
|
"""System-message idle compaction tests."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_triggers_for_system_messages(self, tmp_path):
|
async def test_auto_compact_triggers_for_system_messages(self, tmp_path):
|
||||||
"""Proactive auto-new archives expired session; system messages reload it."""
|
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6, prefix="old")
|
_add_turns(session, 6, prefix="old")
|
||||||
@@ -640,9 +642,10 @@ class TestAutoCompactSystemMessages:
|
|||||||
await loop._process_message(msg)
|
await loop._process_message(msg)
|
||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
|
assert any(m["content"] == "old user 0" for m in session_after.messages)
|
||||||
assert not any(
|
assert not any(
|
||||||
m["content"] == "old user 0"
|
m["content"] == "old user 0"
|
||||||
for m in session_after.messages
|
for m in session_after.get_history(max_messages=len(session_after.messages))
|
||||||
)
|
)
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@@ -652,7 +655,6 @@ class TestAutoCompactEdgeCases:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_with_nothing_summary(self, tmp_path):
|
async def test_auto_compact_with_nothing_summary(self, tmp_path):
|
||||||
"""Auto-new should not inject when archive produces '(nothing)'."""
|
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6, prefix="thanks")
|
_add_turns(session, 6, prefix="thanks")
|
||||||
@@ -666,15 +668,17 @@ class TestAutoCompactEdgeCases:
|
|||||||
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
assert len(session_after.messages) == 12
|
||||||
|
assert len(session_after.get_history(max_messages=12)) == (
|
||||||
|
loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||||
|
)
|
||||||
# "(nothing)" summary should not be stored
|
# "(nothing)" summary should not be stored
|
||||||
assert "cli:test" not in loop.auto_compact._summaries
|
assert "cli:test" not in loop.auto_compact._summaries
|
||||||
|
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_archive_failure_still_keeps_recent_suffix(self, tmp_path):
|
async def test_auto_compact_archive_failure_preserves_raw_history(self, tmp_path):
|
||||||
"""Auto-new should keep the recent suffix even if LLM archive falls back to raw dump."""
|
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6, prefix="important")
|
_add_turns(session, 6, prefix="important")
|
||||||
@@ -687,7 +691,10 @@ class TestAutoCompactEdgeCases:
|
|||||||
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
assert len(session_after.messages) == 12
|
||||||
|
assert len(session_after.get_history(max_messages=12)) == (
|
||||||
|
loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||||
|
)
|
||||||
|
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@@ -725,13 +732,10 @@ class TestAutoCompactEdgeCases:
|
|||||||
|
|
||||||
|
|
||||||
class TestAutoCompactIntegration:
|
class TestAutoCompactIntegration:
|
||||||
"""End-to-end test of auto session new feature."""
|
"""Idle compaction integration tests."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_full_lifecycle(self, tmp_path):
|
async def test_full_lifecycle(self, tmp_path):
|
||||||
"""
|
|
||||||
Full lifecycle: messages -> idle -> auto-new -> archive -> clear -> summary injected as runtime context.
|
|
||||||
"""
|
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
|
|
||||||
@@ -759,6 +763,7 @@ class TestAutoCompactIntegration:
|
|||||||
tool_calls=[],
|
tool_calls=[],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
||||||
|
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
channel="cli", sender_id="user", chat_id="test",
|
channel="cli", sender_id="user", chat_id="test",
|
||||||
@@ -769,9 +774,13 @@ class TestAutoCompactIntegration:
|
|||||||
# Phase 4: Verify
|
# Phase 4: Verify
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
|
|
||||||
# The oldest messages should be trimmed from live session history
|
assert any(
|
||||||
|
"past tense is used" in str(m.get("content", "")).lower()
|
||||||
|
for m in session_after.messages
|
||||||
|
)
|
||||||
assert not any(
|
assert not any(
|
||||||
"past tense is used" in str(m.get("content", "")) for m in session_after.messages
|
"past tense is used" in str(m.get("content", "")).lower()
|
||||||
|
for m in session_after.get_history(max_messages=len(session_after.messages))
|
||||||
)
|
)
|
||||||
|
|
||||||
# Summary should NOT be persisted in session (ephemeral, one-shot)
|
# Summary should NOT be persisted in session (ephemeral, one-shot)
|
||||||
@@ -821,13 +830,13 @@ class TestAutoCompactIntegration:
|
|||||||
|
|
||||||
|
|
||||||
class TestProactiveAutoCompact:
|
class TestProactiveAutoCompact:
|
||||||
"""Test proactive auto-new on idle ticks (TimeoutError path in run loop)."""
|
"""Proactive idle compaction tests."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _run_check_expired(loop, active_session_keys=()):
|
async def _run_check_expired(loop, active_session_keys=()):
|
||||||
"""Helper: run check_expired via callback and wait for background tasks."""
|
"""Helper: run check_expired via callback and wait for background tasks."""
|
||||||
loop.auto_compact.check_expired(
|
loop.auto_compact.check_expired(
|
||||||
loop._schedule_background,
|
loop.schedule_background,
|
||||||
loop.runtime_for_session,
|
loop.runtime_for_session,
|
||||||
active_session_keys=active_session_keys,
|
active_session_keys=active_session_keys,
|
||||||
)
|
)
|
||||||
@@ -899,7 +908,10 @@ class TestProactiveAutoCompact:
|
|||||||
await self._run_check_expired(loop)
|
await self._run_check_expired(loop)
|
||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
assert len(session_after.messages) == 10
|
||||||
|
assert len(session_after.get_history(max_messages=10)) == (
|
||||||
|
loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||||
|
)
|
||||||
assert len(archived_messages) == 2
|
assert len(archived_messages) == 2
|
||||||
entry = loop.auto_compact._summaries.get("cli:test")
|
entry = loop.auto_compact._summaries.get("cli:test")
|
||||||
assert entry is not None
|
assert entry is not None
|
||||||
@@ -964,12 +976,12 @@ class TestProactiveAutoCompact:
|
|||||||
loop.consolidator.compact_idle_session = _slow_compact
|
loop.consolidator.compact_idle_session = _slow_compact
|
||||||
|
|
||||||
# First call starts archiving via callback
|
# First call starts archiving via callback
|
||||||
loop.auto_compact.check_expired(loop._schedule_background, loop.runtime_for_session)
|
loop.auto_compact.check_expired(loop.schedule_background, loop.runtime_for_session)
|
||||||
await started.wait()
|
await started.wait()
|
||||||
assert archive_count == 1
|
assert archive_count == 1
|
||||||
|
|
||||||
# Second call should skip (key is in _archiving)
|
# Second call should skip (key is in _archiving)
|
||||||
loop.auto_compact.check_expired(loop._schedule_background, loop.runtime_for_session)
|
loop.auto_compact.check_expired(loop.schedule_background, loop.runtime_for_session)
|
||||||
assert archive_count == 1
|
assert archive_count == 1
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
@@ -1082,7 +1094,10 @@ class TestProactiveAutoCompact:
|
|||||||
|
|
||||||
assert _fake_compact.state["count"] == 1
|
assert _fake_compact.state["count"] == 1
|
||||||
s1_after = loop.sessions.get_or_create("cli:expired_idle")
|
s1_after = loop.sessions.get_or_create("cli:expired_idle")
|
||||||
assert len(s1_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
assert len(s1_after.messages) == 12
|
||||||
|
assert len(s1_after.get_history(max_messages=12)) == (
|
||||||
|
loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||||
|
)
|
||||||
s2_after = loop.sessions.get_or_create("cli:expired_active")
|
s2_after = loop.sessions.get_or_create("cli:expired_active")
|
||||||
assert len(s2_after.messages) == 12 # Preserved
|
assert len(s2_after.messages) == 12 # Preserved
|
||||||
s3_after = loop.sessions.get_or_create("cli:recent")
|
s3_after = loop.sessions.get_or_create("cli:recent")
|
||||||
@@ -1211,7 +1226,10 @@ class TestSummaryPersistence:
|
|||||||
|
|
||||||
# prepare_session should recover summary from metadata
|
# prepare_session should recover summary from metadata
|
||||||
reloaded = loop.sessions.get_or_create("cli:test")
|
reloaded = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(reloaded.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
assert len(reloaded.messages) == 12
|
||||||
|
assert len(reloaded.get_history(max_messages=12)) == (
|
||||||
|
loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||||
|
)
|
||||||
_, summary = loop.auto_compact.prepare_session(reloaded, "cli:test")
|
_, summary = loop.auto_compact.prepare_session(reloaded, "cli:test")
|
||||||
|
|
||||||
assert summary is not None
|
assert summary is not None
|
||||||
|
|||||||
@@ -586,7 +586,7 @@ class TestConsolidatorTokenBudget:
|
|||||||
|
|
||||||
|
|
||||||
class TestCompactIdleSession:
|
class TestCompactIdleSession:
|
||||||
"""Tests for Consolidator.compact_idle_session — lock-protected idle truncation."""
|
"""Idle compaction tests."""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def real_consolidator(self, store, mock_provider):
|
def real_consolidator(self, store, mock_provider):
|
||||||
@@ -602,11 +602,9 @@ class TestCompactIdleSession:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_archives_prefix_keeps_suffix(
|
async def test_archives_prefix_preserves_messages_and_hides_prefix(
|
||||||
self, real_consolidator, mock_provider, runtime
|
self, real_consolidator, mock_provider, runtime
|
||||||
):
|
):
|
||||||
"""20 user/assistant turns → compact with max_suffix=8 → messages ≤ 8,
|
|
||||||
last_consolidated=0, _last_summary stored."""
|
|
||||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||||
content="Summary of old conversation.", finish_reason="stop"
|
content="Summary of old conversation.", finish_reason="stop"
|
||||||
)
|
)
|
||||||
@@ -624,9 +622,15 @@ class TestCompactIdleSession:
|
|||||||
)
|
)
|
||||||
assert result == "Summary of old conversation."
|
assert result == "Summary of old conversation."
|
||||||
|
|
||||||
|
sessions.invalidate("cli:test")
|
||||||
reloaded = sessions.get_or_create("cli:test")
|
reloaded = sessions.get_or_create("cli:test")
|
||||||
assert len(reloaded.messages) <= 8
|
assert len(reloaded.messages) == 40
|
||||||
assert reloaded.last_consolidated == 0
|
assert reloaded.messages[0]["content"] == "user msg 0"
|
||||||
|
assert reloaded.last_consolidated == 32
|
||||||
|
visible = reloaded.get_history(max_messages=40)
|
||||||
|
assert len(visible) == 8
|
||||||
|
assert visible[0]["content"] == "user msg 16"
|
||||||
|
assert visible[-1]["content"] == "assistant msg 19"
|
||||||
meta = reloaded.metadata.get("_last_summary")
|
meta = reloaded.metadata.get("_last_summary")
|
||||||
assert meta is not None
|
assert meta is not None
|
||||||
assert meta["text"] == "Summary of old conversation."
|
assert meta["text"] == "Summary of old conversation."
|
||||||
@@ -665,9 +669,7 @@ class TestCompactIdleSession:
|
|||||||
async def test_raw_dumps_only_dropped_messages_on_llm_failure(
|
async def test_raw_dumps_only_dropped_messages_on_llm_failure(
|
||||||
self, real_consolidator, mock_provider, store, runtime
|
self, real_consolidator, mock_provider, store, runtime
|
||||||
):
|
):
|
||||||
"""Summarizing over the full tail must not widen what gets raw-dumped on
|
"""Extra summary context must not enter raw fallback. Regression for #4264."""
|
||||||
LLM failure: the breadcrumb should contain only the removed prefix, not
|
|
||||||
the retained suffix that stays live in the session. Regression for #4264."""
|
|
||||||
mock_provider.chat_with_retry.side_effect = RuntimeError("LLM unavailable")
|
mock_provider.chat_with_retry.side_effect = RuntimeError("LLM unavailable")
|
||||||
sessions = real_consolidator.sessions
|
sessions = real_consolidator.sessions
|
||||||
session = sessions.get_or_create("cli:rawdrop")
|
session = sessions.get_or_create("cli:rawdrop")
|
||||||
@@ -684,8 +686,11 @@ class TestCompactIdleSession:
|
|||||||
|
|
||||||
raw = "\n".join(e["content"] for e in store.read_unprocessed_history(since_cursor=0))
|
raw = "\n".join(e["content"] for e in store.read_unprocessed_history(since_cursor=0))
|
||||||
assert "[RAW]" in raw
|
assert "[RAW]" in raw
|
||||||
assert "user msg 0" in raw # removed prefix is the breadcrumb
|
assert "user msg 0" in raw
|
||||||
assert "RETAINED_SUFFIX_marker" not in raw # retained suffix not dumped
|
assert "RETAINED_SUFFIX_marker" not in raw
|
||||||
|
reloaded = sessions.get_or_create("cli:rawdrop")
|
||||||
|
assert len(reloaded.messages) == 38
|
||||||
|
assert reloaded.messages[-1]["content"] == "RETAINED_SUFFIX_marker"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_idle_compact_writes_session_key_to_history(
|
async def test_idle_compact_writes_session_key_to_history(
|
||||||
@@ -757,10 +762,9 @@ class TestCompactIdleSession:
|
|||||||
assert "_last_summary" not in reloaded.metadata
|
assert "_last_summary" not in reloaded.metadata
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_llm_failure_still_truncates(
|
async def test_llm_failure_preserves_history_but_advances_replay_boundary(
|
||||||
self, real_consolidator, mock_provider, store, runtime
|
self, real_consolidator, mock_provider, store, runtime
|
||||||
):
|
):
|
||||||
"""LLM raises RuntimeError → raw_archive fires, session still truncated, returns None."""
|
|
||||||
mock_provider.chat_with_retry.side_effect = RuntimeError("LLM unavailable")
|
mock_provider.chat_with_retry.side_effect = RuntimeError("LLM unavailable")
|
||||||
sessions = real_consolidator.sessions
|
sessions = real_consolidator.sessions
|
||||||
session = sessions.get_or_create("cli:fail")
|
session = sessions.get_or_create("cli:fail")
|
||||||
@@ -778,9 +782,16 @@ class TestCompactIdleSession:
|
|||||||
entries = store.read_unprocessed_history(since_cursor=0)
|
entries = store.read_unprocessed_history(since_cursor=0)
|
||||||
assert any("[RAW]" in e["content"] for e in entries)
|
assert any("[RAW]" in e["content"] for e in entries)
|
||||||
|
|
||||||
# Session should still be truncated
|
|
||||||
reloaded = sessions.get_or_create("cli:fail")
|
reloaded = sessions.get_or_create("cli:fail")
|
||||||
assert len(reloaded.messages) <= 4
|
assert len(reloaded.messages) == 20
|
||||||
|
assert reloaded.messages[0]["content"] == "u0"
|
||||||
|
assert reloaded.last_consolidated == 16
|
||||||
|
assert [m["content"] for m in reloaded.get_history(max_messages=20)] == [
|
||||||
|
"u8",
|
||||||
|
"a8",
|
||||||
|
"u9",
|
||||||
|
"a9",
|
||||||
|
]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_respects_last_consolidated(
|
async def test_respects_last_consolidated(
|
||||||
@@ -802,6 +813,9 @@ class TestCompactIdleSession:
|
|||||||
"cli:offset", runtime=runtime, max_suffix=4
|
"cli:offset", runtime=runtime, max_suffix=4
|
||||||
)
|
)
|
||||||
assert result == "Tail summary."
|
assert result == "Tail summary."
|
||||||
|
reloaded = sessions.get_or_create("cli:offset")
|
||||||
|
assert len(reloaded.messages) == 60
|
||||||
|
assert reloaded.last_consolidated == 56
|
||||||
|
|
||||||
# Verify only the unconsolidated tail was processed:
|
# Verify only the unconsolidated tail was processed:
|
||||||
# 10 unconsolidated messages (50-59), keep suffix of 4 → archive 6
|
# 10 unconsolidated messages (50-59), keep suffix of 4 → archive 6
|
||||||
@@ -812,14 +826,12 @@ class TestCompactIdleSession:
|
|||||||
assert "u25" in user_content or "a25" in user_content
|
assert "u25" in user_content or "a25" in user_content
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_non_contiguous_suffix_archives_actual_dropped_messages(
|
async def test_extended_suffix_archives_only_hidden_prefix(
|
||||||
self,
|
self,
|
||||||
real_consolidator,
|
real_consolidator,
|
||||||
mock_provider,
|
mock_provider,
|
||||||
runtime,
|
runtime,
|
||||||
):
|
):
|
||||||
"""Assistant-only tails extend back to the latest user turn, so archive
|
|
||||||
the actual dropped messages rather than a computed prefix."""
|
|
||||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||||
content="Tail summary.", finish_reason="stop"
|
content="Tail summary.", finish_reason="stop"
|
||||||
)
|
)
|
||||||
@@ -837,7 +849,9 @@ class TestCompactIdleSession:
|
|||||||
assert result == "Tail summary."
|
assert result == "Tail summary."
|
||||||
|
|
||||||
reloaded = sessions.get_or_create("cli:noncontiguous")
|
reloaded = sessions.get_or_create("cli:noncontiguous")
|
||||||
assert [m["content"] for m in reloaded.messages] == [
|
assert len(reloaded.messages) == 25
|
||||||
|
assert reloaded.last_consolidated == 14
|
||||||
|
assert [m["content"] for m in reloaded.get_history(max_messages=25)] == [
|
||||||
"user-14",
|
"user-14",
|
||||||
"assistant-00",
|
"assistant-00",
|
||||||
"assistant-01",
|
"assistant-01",
|
||||||
@@ -987,23 +1001,21 @@ class TestConsolidatorSessionRefresh:
|
|||||||
# Simulate: background consolidation captures old reference
|
# Simulate: background consolidation captures old reference
|
||||||
old_ref = session
|
old_ref = session
|
||||||
|
|
||||||
# AutoCompact runs first and truncates to 8
|
|
||||||
await consolidator.compact_idle_session(
|
await consolidator.compact_idle_session(
|
||||||
"cli:test",
|
"cli:test",
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
max_suffix=8,
|
max_suffix=8,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Background consolidation runs with stale reference —
|
|
||||||
# should detect the session was replaced and not undo the compact.
|
|
||||||
await consolidator.maybe_consolidate_by_tokens(
|
await consolidator.maybe_consolidate_by_tokens(
|
||||||
old_ref,
|
old_ref,
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
)
|
)
|
||||||
|
|
||||||
session_after = sessions.get_or_create("cli:test")
|
session_after = sessions.get_or_create("cli:test")
|
||||||
# Messages should still be truncated (not restored to 40)
|
assert len(session_after.messages) == 40
|
||||||
assert len(session_after.messages) <= 8
|
assert session_after.last_consolidated == 32
|
||||||
|
assert len(session_after.get_history(max_messages=40)) == 8
|
||||||
|
|
||||||
|
|
||||||
class TestRawArchiveTruncation:
|
class TestRawArchiveTruncation:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
from nanobot.resource_links import ResourceView
|
|
||||||
from nanobot.runtime_context import RuntimeContextBlock
|
from nanobot.runtime_context import RuntimeContextBlock
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -347,65 +346,6 @@ class TestBuildSystemPrompt:
|
|||||||
assert "## AGENTS.md" not in result
|
assert "## AGENTS.md" not in result
|
||||||
assert "[Archived Context Summary]" not in result
|
assert "[Archived Context Summary]" not in result
|
||||||
|
|
||||||
def test_resource_aliases_are_absent_without_explicit_mode(self, tmp_path):
|
|
||||||
aliases = tmp_path / "resources" / "view"
|
|
||||||
resource_view = ResourceView(
|
|
||||||
root=aliases,
|
|
||||||
agent=aliases / "agent",
|
|
||||||
media=aliases / "media",
|
|
||||||
package=aliases / "package",
|
|
||||||
)
|
|
||||||
|
|
||||||
result = _builder(tmp_path, resource_view=resource_view).build_system_prompt()
|
|
||||||
|
|
||||||
assert "## Resource Aliases" not in result
|
|
||||||
|
|
||||||
def test_full_resource_aliases_show_roots_and_policy(self, tmp_path):
|
|
||||||
aliases = tmp_path / "resources" / "view"
|
|
||||||
resource_view = ResourceView(
|
|
||||||
root=aliases,
|
|
||||||
agent=aliases / "agent",
|
|
||||||
media=aliases / "media",
|
|
||||||
package=aliases / "package",
|
|
||||||
)
|
|
||||||
|
|
||||||
result = _builder(tmp_path, resource_view=resource_view).build_system_prompt(
|
|
||||||
resource_view_mode="full",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "## Resource Aliases" in result
|
|
||||||
assert f"Agent workspace: `{resource_view.agent}`" in result
|
|
||||||
assert f"Media: `{resource_view.media}`" in result
|
|
||||||
assert f"Nanobot package: `{resource_view.package}`" in result
|
|
||||||
assert f"Long-term memory: {resource_view.agent}/memory/MEMORY.md" in result
|
|
||||||
assert f"History log: {resource_view.agent}/memory/history.jsonl" in result
|
|
||||||
assert f"Custom skills: {resource_view.agent}/skills/" in result
|
|
||||||
assert "do not grant additional file or shell permissions" in result
|
|
||||||
assert "sandboxed shell may not expose an alias" in result
|
|
||||||
assert "paths relative to the current project workspace" in result
|
|
||||||
|
|
||||||
def test_restricted_resource_aliases_only_show_allowed_subtrees(self, tmp_path):
|
|
||||||
aliases = tmp_path / "resources" / "view"
|
|
||||||
resource_view = ResourceView(
|
|
||||||
root=aliases,
|
|
||||||
agent=aliases / "agent",
|
|
||||||
media=aliases / "media",
|
|
||||||
package=aliases / "package",
|
|
||||||
)
|
|
||||||
|
|
||||||
result = _builder(tmp_path, resource_view=resource_view).build_system_prompt(
|
|
||||||
resource_view_mode="restricted",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert f"Custom skills: `{resource_view.agent / 'skills'}`" in result
|
|
||||||
assert f"Media: `{resource_view.media}`" in result
|
|
||||||
assert f"Built-in skills: `{resource_view.package / 'skills'}`" in result
|
|
||||||
assert f"Agent workspace: `{resource_view.agent}`" not in result
|
|
||||||
assert f"Nanobot package: `{resource_view.package}`" not in result
|
|
||||||
canonical_workspace = tmp_path.resolve()
|
|
||||||
assert f"History log: {canonical_workspace}/memory/history.jsonl" in result
|
|
||||||
assert f"History log: {resource_view.agent}/memory/history.jsonl" not in result
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# build_messages
|
# build_messages
|
||||||
@@ -429,25 +369,6 @@ class TestBuildMessages:
|
|||||||
assert messages[1]["role"] == "user"
|
assert messages[1]["role"] == "user"
|
||||||
assert "hello" in str(messages[1]["content"])
|
assert "hello" in str(messages[1]["content"])
|
||||||
|
|
||||||
def test_resource_view_mode_is_forwarded_to_system_prompt(self, tmp_path):
|
|
||||||
aliases = tmp_path / "resources" / "view"
|
|
||||||
resource_view = ResourceView(
|
|
||||||
root=aliases,
|
|
||||||
agent=aliases / "agent",
|
|
||||||
media=aliases / "media",
|
|
||||||
package=aliases / "package",
|
|
||||||
)
|
|
||||||
builder = _builder(tmp_path, resource_view=resource_view)
|
|
||||||
|
|
||||||
messages = builder.build_messages(
|
|
||||||
[],
|
|
||||||
"hello",
|
|
||||||
resource_view_mode="restricted",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "## Resource Aliases" in messages[0]["content"]
|
|
||||||
assert f"Custom skills: `{resource_view.agent / 'skills'}`" in messages[0]["content"]
|
|
||||||
|
|
||||||
def test_public_builder_preserves_assistant_role_compatibility(self, tmp_path):
|
def test_public_builder_preserves_assistant_role_compatibility(self, tmp_path):
|
||||||
from nanobot.agent import ContextBuilder as PublicContextBuilder
|
from nanobot.agent import ContextBuilder as PublicContextBuilder
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import pytest
|
|||||||
from nanobot.agent.memory import MemoryStore
|
from nanobot.agent.memory import MemoryStore
|
||||||
from nanobot.config.schema import ModelPresetConfig
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
from nanobot.providers.base import LLMResponse
|
from nanobot.providers.base import LLMResponse
|
||||||
from nanobot.resource_links import ResourceView
|
|
||||||
from nanobot.security.workspace_access import (
|
from nanobot.security.workspace_access import (
|
||||||
bind_workspace_scope,
|
bind_workspace_scope,
|
||||||
default_workspace_scope,
|
default_workspace_scope,
|
||||||
@@ -63,27 +62,6 @@ class TestBuildDreamPrompt:
|
|||||||
prompt, _ = result
|
prompt, _ = result
|
||||||
assert "skill-creator" in prompt
|
assert "skill-creator" in prompt
|
||||||
|
|
||||||
def test_prompt_uses_package_alias_for_skill_creator(self, tmp_path):
|
|
||||||
aliases = tmp_path / "resources" / "view"
|
|
||||||
resource_view = ResourceView(
|
|
||||||
root=aliases,
|
|
||||||
agent=aliases / "agent",
|
|
||||||
media=aliases / "media",
|
|
||||||
package=aliases / "package",
|
|
||||||
)
|
|
||||||
store = MemoryStore(tmp_path / "workspace", resource_view=resource_view)
|
|
||||||
store.append_history("test")
|
|
||||||
|
|
||||||
result = store.build_dream_prompt()
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
prompt, _ = result
|
|
||||||
expected = resource_view.package / "skills" / "skill-creator" / "SKILL.md"
|
|
||||||
assert str(expected) in prompt
|
|
||||||
|
|
||||||
def test_default_dream_prompt_class_call_remains_compatible(self):
|
|
||||||
assert "skill-creator" in MemoryStore.default_dream_prompt()
|
|
||||||
|
|
||||||
def test_prompt_embeds_current_memory_file_contents(self, store):
|
def test_prompt_embeds_current_memory_file_contents(self, store):
|
||||||
"""Dream must see the real current file contents (Tier 4) so it edits the
|
"""Dream must see the real current file contents (Tier 4) so it edits the
|
||||||
files, not a stale mental model."""
|
files, not a stale mental model."""
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ async def test_preflight_consolidation_receives_pending_summary(tmp_path) -> Non
|
|||||||
return_value=(session, "Previous conversation summary: earlier context")
|
return_value=(session, "Previous conversation summary: earlier context")
|
||||||
) # type: ignore[method-assign]
|
) # type: ignore[method-assign]
|
||||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None) # type: ignore[method-assign]
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None) # type: ignore[method-assign]
|
||||||
loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
||||||
|
|
||||||
runtime = loop.llm_runtime()
|
runtime = loop.llm_runtime()
|
||||||
await loop.process_direct("hello", session_key="cli:test", runtime=runtime)
|
await loop.process_direct("hello", session_key="cli:test", runtime=runtime)
|
||||||
@@ -252,7 +252,7 @@ async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) ->
|
|||||||
return LLMResponse(content="ok", tool_calls=[])
|
return LLMResponse(content="ok", tool_calls=[])
|
||||||
loop.provider.chat_with_retry = track_llm
|
loop.provider.chat_with_retry = track_llm
|
||||||
loop.provider.chat_stream_with_retry = track_llm
|
loop.provider.chat_stream_with_retry = track_llm
|
||||||
loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
||||||
|
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
session.messages = [
|
session.messages = [
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ def _make_loop(tmp_path):
|
|||||||
WebuiTurnCoordinator(
|
WebuiTurnCoordinator(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
sessions=loop.sessions,
|
sessions=loop.sessions,
|
||||||
schedule_background=lambda coro: loop._schedule_background(coro),
|
schedule_background=lambda coro: loop.schedule_background(coro),
|
||||||
).subscribe(loop.runtime_events)
|
).subscribe(loop.runtime_events)
|
||||||
loop.turn_delivery_factory.route_policy = WebuiTurnRoutePolicy(loop.sessions)
|
loop.turn_delivery_factory.route_policy = WebuiTurnRoutePolicy(loop.sessions)
|
||||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ def _attach_webui_runtime_events(loop: AgentLoop, bus: MessageBus) -> None:
|
|||||||
coordinator = WebuiTurnCoordinator(
|
coordinator = WebuiTurnCoordinator(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
sessions=loop.sessions,
|
sessions=loop.sessions,
|
||||||
schedule_background=lambda coro: loop._schedule_background(coro),
|
schedule_background=lambda coro: loop.schedule_background(coro),
|
||||||
)
|
)
|
||||||
coordinator.subscribe(loop.runtime_events)
|
coordinator.subscribe(loop.runtime_events)
|
||||||
|
|
||||||
@@ -1203,7 +1203,7 @@ class TestToolEventProgress:
|
|||||||
elif hasattr(coro, "close"):
|
elif hasattr(coro, "close"):
|
||||||
coro.close()
|
coro.close()
|
||||||
|
|
||||||
loop._schedule_background = schedule_background # type: ignore[method-assign]
|
loop.schedule_background = schedule_background # type: ignore[method-assign]
|
||||||
|
|
||||||
await loop._dispatch(InboundMessage(
|
await loop._dispatch(InboundMessage(
|
||||||
channel="websocket",
|
channel="websocket",
|
||||||
@@ -1249,7 +1249,7 @@ class TestToolEventProgress:
|
|||||||
fake_title_after_turn,
|
fake_title_after_turn,
|
||||||
)
|
)
|
||||||
scheduled: list[object] = []
|
scheduled: list[object] = []
|
||||||
loop._schedule_background = scheduled.append # type: ignore[method-assign]
|
loop.schedule_background = scheduled.append # type: ignore[method-assign]
|
||||||
|
|
||||||
await loop._dispatch(InboundMessage(
|
await loop._dispatch(InboundMessage(
|
||||||
channel="websocket",
|
channel="websocket",
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
"""AgentLoop integration tests for the runtime resource view."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop, TurnKind
|
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
from nanobot.config.schema import ToolsConfig
|
|
||||||
from nanobot.resource_links import ResourceView
|
|
||||||
from nanobot.security.workspace_access import build_workspace_scope
|
|
||||||
|
|
||||||
|
|
||||||
def _provider() -> MagicMock:
|
|
||||||
provider = MagicMock()
|
|
||||||
provider.get_default_model.return_value = "test-model"
|
|
||||||
provider.generation = SimpleNamespace(
|
|
||||||
max_tokens=4096,
|
|
||||||
temperature=0.1,
|
|
||||||
reasoning_effort=None,
|
|
||||||
)
|
|
||||||
return provider
|
|
||||||
|
|
||||||
|
|
||||||
def _loop(
|
|
||||||
tmp_path: Path,
|
|
||||||
*,
|
|
||||||
resource_view: ResourceView | None,
|
|
||||||
tools_config: ToolsConfig | None = None,
|
|
||||||
) -> tuple[AgentLoop, MagicMock, MagicMock]:
|
|
||||||
with (
|
|
||||||
patch("nanobot.agent.loop.ContextBuilder") as context_builder,
|
|
||||||
patch("nanobot.agent.loop.SessionManager"),
|
|
||||||
patch("nanobot.agent.loop.SubagentManager") as subagent_manager,
|
|
||||||
patch.object(AgentLoop, "_register_default_tools"),
|
|
||||||
):
|
|
||||||
loop = AgentLoop(
|
|
||||||
bus=MessageBus(),
|
|
||||||
provider=_provider(),
|
|
||||||
workspace=tmp_path,
|
|
||||||
tools_config=tools_config,
|
|
||||||
resource_view=resource_view,
|
|
||||||
)
|
|
||||||
return loop, context_builder, subagent_manager
|
|
||||||
|
|
||||||
|
|
||||||
def test_loop_injects_resource_view_without_creating_one(tmp_path: Path) -> None:
|
|
||||||
view = ResourceView(root=tmp_path / "resources" / "view")
|
|
||||||
|
|
||||||
loop, context_builder, subagent_manager = _loop(
|
|
||||||
tmp_path,
|
|
||||||
resource_view=view,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert loop.resource_view is view
|
|
||||||
assert context_builder.call_args.kwargs["resource_view"] is view
|
|
||||||
assert subagent_manager.call_args.kwargs["resource_view"] is view
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("access_mode", "sandbox", "expected"),
|
|
||||||
[
|
|
||||||
("full", "", "full"),
|
|
||||||
("restricted", "", "restricted"),
|
|
||||||
("full", "bwrap", "restricted"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_initial_prompt_uses_effective_resource_view_mode(
|
|
||||||
tmp_path: Path,
|
|
||||||
access_mode: str,
|
|
||||||
sandbox: str,
|
|
||||||
expected: str,
|
|
||||||
) -> None:
|
|
||||||
tools_config = ToolsConfig()
|
|
||||||
tools_config.exec.sandbox = sandbox
|
|
||||||
view = ResourceView(root=tmp_path / "resources" / "view")
|
|
||||||
loop, _, _ = _loop(
|
|
||||||
tmp_path,
|
|
||||||
resource_view=view,
|
|
||||||
tools_config=tools_config,
|
|
||||||
)
|
|
||||||
scope = build_workspace_scope(tmp_path, access_mode)
|
|
||||||
loop.workspace_scopes = SimpleNamespace(for_message=MagicMock(return_value=scope))
|
|
||||||
loop.context.build_messages.return_value = []
|
|
||||||
turn = SimpleNamespace(
|
|
||||||
session=SimpleNamespace(key="cli:test", metadata={}),
|
|
||||||
msg=SimpleNamespace(content="hello", media=None),
|
|
||||||
history=[],
|
|
||||||
kind=TurnKind.USER,
|
|
||||||
delivery=SimpleNamespace(route=SimpleNamespace(channel="cli")),
|
|
||||||
pending_summary=None,
|
|
||||||
runtime_context_blocks=[],
|
|
||||||
ephemeral=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
loop._build_initial_messages(turn)
|
|
||||||
|
|
||||||
assert loop.context.build_messages.call_args.kwargs["resource_view_mode"] == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_initial_prompt_keeps_legacy_mode_without_resource_view(tmp_path: Path) -> None:
|
|
||||||
loop, _, _ = _loop(tmp_path, resource_view=None)
|
|
||||||
scope = build_workspace_scope(tmp_path, "full")
|
|
||||||
|
|
||||||
assert loop._resource_view_mode_for_scope(scope) is None
|
|
||||||
@@ -78,7 +78,7 @@ def _make_full_loop(tmp_path: Path) -> AgentLoop:
|
|||||||
WebuiTurnCoordinator(
|
WebuiTurnCoordinator(
|
||||||
bus=loop.bus,
|
bus=loop.bus,
|
||||||
sessions=loop.sessions,
|
sessions=loop.sessions,
|
||||||
schedule_background=lambda coro: loop._schedule_background(coro),
|
schedule_background=lambda coro: loop.schedule_background(coro),
|
||||||
).subscribe(loop.runtime_events)
|
).subscribe(loop.runtime_events)
|
||||||
return loop
|
return loop
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ from unittest.mock import MagicMock
|
|||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
|
from mcp import MCPError
|
||||||
from mcp import types as mcp_types
|
from mcp import types as mcp_types
|
||||||
from mcp.shared.exceptions import McpError
|
|
||||||
from mcp.shared.message import SessionMessage
|
from mcp.shared.message import SessionMessage
|
||||||
from mcp.types import ErrorData
|
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.tools import mcp as mcp_runtime
|
from nanobot.agent.tools import mcp as mcp_runtime
|
||||||
@@ -26,12 +25,10 @@ from nanobot.config.schema import MCPServerConfig
|
|||||||
|
|
||||||
def _mcp_notification(method: str, params: dict[str, Any] | None = None) -> SessionMessage:
|
def _mcp_notification(method: str, params: dict[str, Any] | None = None) -> SessionMessage:
|
||||||
return SessionMessage(
|
return SessionMessage(
|
||||||
message=mcp_types.JSONRPCMessage(
|
message=mcp_types.JSONRPCNotification(
|
||||||
mcp_types.JSONRPCNotification(
|
jsonrpc="2.0",
|
||||||
jsonrpc="2.0",
|
method=method,
|
||||||
method=method,
|
params=params,
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -428,7 +425,7 @@ async def test_mcp_tool_reconnects_after_session_terminated(
|
|||||||
self.call_count += 1
|
self.call_count += 1
|
||||||
assert arguments == {"symbol": "AAPL"}
|
assert arguments == {"symbol": "AAPL"}
|
||||||
if self.index == 1:
|
if self.index == 1:
|
||||||
raise McpError(ErrorData(code=-32000, message="Session terminated"))
|
raise MCPError(-32000, "Session terminated")
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
content=[mcp_types.TextContent(type="text", text="recovered")]
|
content=[mcp_types.TextContent(type="text", text="recovered")]
|
||||||
)
|
)
|
||||||
@@ -443,7 +440,7 @@ async def test_mcp_tool_reconnects_after_session_terminated(
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="quote",
|
name="quote",
|
||||||
description="quote tool",
|
description="quote tool",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
registry.register(MCPToolWrapper(session, name, tool_def, tool_timeout=5))
|
registry.register(MCPToolWrapper(session, name, tool_def, tool_timeout=5))
|
||||||
stack = AsyncExitStack()
|
stack = AsyncExitStack()
|
||||||
@@ -484,7 +481,7 @@ async def test_mcp_reconnect_handler_uses_sanitized_server_prefix(
|
|||||||
async def call_tool(self, _name: str, arguments: dict[str, Any]) -> Any:
|
async def call_tool(self, _name: str, arguments: dict[str, Any]) -> Any:
|
||||||
assert arguments == {}
|
assert arguments == {}
|
||||||
if self.index == 1:
|
if self.index == 1:
|
||||||
raise McpError(ErrorData(code=-32000, message="Session terminated"))
|
raise MCPError(-32000, "Session terminated")
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
content=[mcp_types.TextContent(type="text", text="recovered")]
|
content=[mcp_types.TextContent(type="text", text="recovered")]
|
||||||
)
|
)
|
||||||
@@ -497,7 +494,7 @@ async def test_mcp_reconnect_handler_uses_sanitized_server_prefix(
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="quote",
|
name="quote",
|
||||||
description="quote tool",
|
description="quote tool",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
registry.register(MCPToolWrapper(_FakeSession(connect_count), name, tool_def))
|
registry.register(MCPToolWrapper(_FakeSession(connect_count), name, tool_def))
|
||||||
stack = AsyncExitStack()
|
stack = AsyncExitStack()
|
||||||
@@ -532,7 +529,7 @@ async def test_concurrent_mcp_reconnect_reuses_fresh_session(
|
|||||||
|
|
||||||
class _DeadSession:
|
class _DeadSession:
|
||||||
async def read_resource(self, _uri: str) -> Any:
|
async def read_resource(self, _uri: str) -> Any:
|
||||||
raise McpError(ErrorData(code=-32000, message="Session terminated"))
|
raise MCPError(-32000, "Session terminated")
|
||||||
|
|
||||||
class _LiveSession:
|
class _LiveSession:
|
||||||
async def read_resource(self, uri: str) -> Any:
|
async def read_resource(self, uri: str) -> Any:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import socket
|
|||||||
import time
|
import time
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import httpx
|
import httpx2 as httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
@@ -39,31 +39,29 @@ def _free_port() -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _run_mcp_server(port: int, ready_event: multiprocessing.Event) -> None:
|
def _run_mcp_server(port: int, ready_event: multiprocessing.Event) -> None:
|
||||||
"""FastMCP server target for ``multiprocessing.Process``.
|
"""MCPServer target for ``multiprocessing.Process``.
|
||||||
|
|
||||||
The server exposes a single ``greet`` tool and terminates idle sessions
|
The server exposes a single ``greet`` tool and terminates idle sessions
|
||||||
after ``_IDLE_TIMEOUT_SECONDS``.
|
after ``_IDLE_TIMEOUT_SECONDS``.
|
||||||
"""
|
"""
|
||||||
from mcp.server.fastmcp import FastMCP
|
import uvicorn
|
||||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
from mcp.server import MCPServer
|
||||||
|
|
||||||
mcp = FastMCP("IdleTimeoutDemo", json_response=True, port=port)
|
mcp = MCPServer("IdleTimeoutDemo")
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def greet(name: str = "World") -> str: # noqa: N802
|
def greet(name: str = "World") -> str: # noqa: N802
|
||||||
"""Greet someone."""
|
"""Greet someone."""
|
||||||
return f"Hello, {name}!"
|
return f"Hello, {name}!"
|
||||||
|
|
||||||
mcp._session_manager = StreamableHTTPSessionManager(
|
app = mcp.streamable_http_app(
|
||||||
app=mcp._mcp_server,
|
json_response=True,
|
||||||
json_response=mcp.settings.json_response,
|
host="127.0.0.1",
|
||||||
stateless=mcp.settings.stateless_http,
|
|
||||||
security_settings=mcp.settings.transport_security,
|
|
||||||
session_idle_timeout=_IDLE_TIMEOUT_SECONDS,
|
|
||||||
)
|
)
|
||||||
|
mcp.session_manager.session_idle_timeout = _IDLE_TIMEOUT_SECONDS
|
||||||
|
|
||||||
ready_event.set()
|
ready_event.set()
|
||||||
mcp.run(transport="streamable-http")
|
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
|
||||||
|
|
||||||
|
|
||||||
async def _wait_for_server(url: str, timeout: float = 10.0) -> bool:
|
async def _wait_for_server(url: str, timeout: float = 10.0) -> bool:
|
||||||
@@ -128,10 +126,14 @@ def _make_loop(tmp_path, *, mcp_servers: dict) -> AgentLoop:
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def allow_loopback_mcp_urls(monkeypatch: pytest.MonkeyPatch):
|
def allow_loopback_mcp_urls(monkeypatch: pytest.MonkeyPatch):
|
||||||
"""The repro server runs on 127.0.0.1; allow nanobot to talk to it."""
|
"""The repro server runs on 127.0.0.1; allow nanobot to talk to it."""
|
||||||
class TestPinnedDNSAsyncTransport(security_network.PinnedDNSAsyncTransport):
|
class TestPinnedDNSAsyncTransport(security_network.Httpx2PinnedDNSAsyncTransport):
|
||||||
_resolver_lock = asyncio.Lock()
|
_resolver_lock = asyncio.Lock()
|
||||||
|
|
||||||
monkeypatch.setattr(mcp_module, "PinnedDNSAsyncTransport", TestPinnedDNSAsyncTransport)
|
monkeypatch.setattr(
|
||||||
|
mcp_module,
|
||||||
|
"Httpx2PinnedDNSAsyncTransport",
|
||||||
|
TestPinnedDNSAsyncTransport,
|
||||||
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mcp_module,
|
mcp_module,
|
||||||
"validate_url_target",
|
"validate_url_target",
|
||||||
@@ -154,7 +156,7 @@ def allow_loopback_mcp_urls(monkeypatch: pytest.MonkeyPatch):
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mcp_module,
|
mcp_module,
|
||||||
"httpx_env_proxy_mounts",
|
"httpx2_env_proxy_mounts",
|
||||||
lambda: {},
|
lambda: {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from mcp import MCPError
|
||||||
from mcp import types as mcp_types
|
from mcp import types as mcp_types
|
||||||
from mcp.shared.exceptions import McpError
|
|
||||||
from mcp.types import ErrorData
|
|
||||||
|
|
||||||
from nanobot.agent.tools.mcp import (
|
from nanobot.agent.tools.mcp import (
|
||||||
MCPPromptWrapper,
|
MCPPromptWrapper,
|
||||||
@@ -37,12 +36,16 @@ class _FakeEndOfStreamError(Exception):
|
|||||||
_FakeEndOfStreamError.__name__ = "EndOfStream"
|
_FakeEndOfStreamError.__name__ = "EndOfStream"
|
||||||
|
|
||||||
|
|
||||||
def _session_terminated_error() -> McpError:
|
def _session_terminated_error() -> MCPError:
|
||||||
return McpError(ErrorData(code=-32000, message="Session terminated"))
|
return MCPError(-32000, "Session terminated")
|
||||||
|
|
||||||
|
|
||||||
def _connection_closed_error() -> McpError:
|
def _connection_closed_error() -> MCPError:
|
||||||
return McpError(ErrorData(code=-32000, message="Connection closed"))
|
return MCPError(-32000, "Connection closed")
|
||||||
|
|
||||||
|
|
||||||
|
def _session_not_found_error() -> MCPError:
|
||||||
|
return MCPError(-32600, "Session not found")
|
||||||
|
|
||||||
|
|
||||||
def test_is_transient_recognizes_closed_resource():
|
def test_is_transient_recognizes_closed_resource():
|
||||||
@@ -85,6 +88,10 @@ def test_is_session_terminated_recognizes_connection_closed_mcp_error():
|
|||||||
assert _is_session_terminated(_connection_closed_error())
|
assert _is_session_terminated(_connection_closed_error())
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_session_terminated_recognizes_v2_session_not_found_error():
|
||||||
|
assert _is_session_terminated(_session_not_found_error())
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# MCPToolWrapper retry behaviour
|
# MCPToolWrapper retry behaviour
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -94,7 +101,7 @@ def _make_tool_def(name="test_tool"):
|
|||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
name=name,
|
name=name,
|
||||||
description="A test tool",
|
description="A test tool",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -415,10 +422,10 @@ async def test_prompt_fails_after_retry_exhausted():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_prompt_no_retry_on_mcp_error():
|
async def test_prompt_no_retry_on_mcp_error():
|
||||||
"""McpError (application-level) should NOT trigger retry."""
|
"""MCPError (application-level) should NOT trigger retry."""
|
||||||
session = AsyncMock()
|
session = AsyncMock()
|
||||||
session.get_prompt = AsyncMock(
|
session.get_prompt = AsyncMock(
|
||||||
side_effect=McpError(ErrorData(code=-1, message="not found"))
|
side_effect=MCPError(-1, "not found")
|
||||||
)
|
)
|
||||||
|
|
||||||
wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
|
wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
|
||||||
@@ -443,7 +450,7 @@ async def test_prompt_no_retry_on_non_transient():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_prompt_reconnects_on_session_terminated():
|
async def test_prompt_reconnects_on_session_terminated():
|
||||||
"""Prompt should reconnect once before falling back to McpError handling."""
|
"""Prompt should reconnect once before falling back to MCPError handling."""
|
||||||
old_session = AsyncMock()
|
old_session = AsyncMock()
|
||||||
old_session.get_prompt = AsyncMock(side_effect=_session_terminated_error())
|
old_session.get_prompt = AsyncMock(side_effect=_session_terminated_error())
|
||||||
new_session = AsyncMock()
|
new_session = AsyncMock()
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ async def test_sessions_run_concurrently_with_isolated_model_presets(tmp_path) -
|
|||||||
model_presets=presets,
|
model_presets=presets,
|
||||||
preset_snapshot_loader=load_preset,
|
preset_snapshot_loader=load_preset,
|
||||||
)
|
)
|
||||||
loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
||||||
loop.set_session_model_preset("sdk:fast", "fast")
|
loop.set_session_model_preset("sdk:fast", "fast")
|
||||||
loop.set_session_model_preset("sdk:deep", "deep")
|
loop.set_session_model_preset("sdk:deep", "deep")
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ async def test_removed_session_model_preset_falls_back_and_clears_metadata(tmp_p
|
|||||||
model="base-model",
|
model="base-model",
|
||||||
context_window_tokens=8_000,
|
context_window_tokens=8_000,
|
||||||
)
|
)
|
||||||
loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
||||||
session_key = "sdk:removed-preset"
|
session_key = "sdk:removed-preset"
|
||||||
session = loop.sessions.get_or_create(session_key)
|
session = loop.sessions.get_or_create(session_key)
|
||||||
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = "removed"
|
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = "removed"
|
||||||
@@ -161,7 +161,7 @@ async def test_streamed_sdk_resolves_session_runtime_after_lock_admission(tmp_pa
|
|||||||
model_presets=presets,
|
model_presets=presets,
|
||||||
preset_snapshot_loader=load_preset,
|
preset_snapshot_loader=load_preset,
|
||||||
)
|
)
|
||||||
loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
||||||
session_key = "sdk:queued"
|
session_key = "sdk:queued"
|
||||||
loop.set_session_model_preset(session_key, "fast")
|
loop.set_session_model_preset(session_key, "fast")
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ async def test_sdk_custom_model_preset_metadata_does_not_select_runtime(
|
|||||||
model="base-model",
|
model="base-model",
|
||||||
context_window_tokens=8_000,
|
context_window_tokens=8_000,
|
||||||
)
|
)
|
||||||
loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
||||||
bot = Nanobot(loop)
|
bot = Nanobot(loop)
|
||||||
|
|
||||||
await bot.sessions.ingest(
|
await bot.sessions.ingest(
|
||||||
@@ -239,7 +239,7 @@ async def test_sdk_invalid_internal_model_preset_metadata_fails_explicitly(
|
|||||||
model="base-model",
|
model="base-model",
|
||||||
context_window_tokens=8_000,
|
context_window_tokens=8_000,
|
||||||
)
|
)
|
||||||
loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
||||||
bot = Nanobot(loop)
|
bot = Nanobot(loop)
|
||||||
|
|
||||||
await bot.sessions.ingest(
|
await bot.sessions.ingest(
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.skills import SkillsLoader
|
from nanobot.agent.skills import SkillsLoader
|
||||||
from nanobot.resource_links import ResourceView
|
|
||||||
|
|
||||||
|
|
||||||
def _write_skill(
|
def _write_skill(
|
||||||
@@ -316,41 +315,6 @@ def test_build_skills_summary_groups_paths_by_root(tmp_path: Path) -> None:
|
|||||||
assert "`beta/SKILL.md`" in summary
|
assert "`beta/SKILL.md`" in summary
|
||||||
|
|
||||||
|
|
||||||
def test_build_skills_summary_uses_alias_roots_but_keeps_canonical_entries(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
workspace = tmp_path / "ws"
|
|
||||||
workspace_skills = workspace / "skills"
|
|
||||||
workspace_skills.mkdir(parents=True)
|
|
||||||
workspace_path = _write_skill(workspace_skills, "alpha", body="# Alpha")
|
|
||||||
builtin = tmp_path / "builtin"
|
|
||||||
builtin_path = _write_skill(builtin, "beta", body="# Beta")
|
|
||||||
aliases = tmp_path / "resources" / "view"
|
|
||||||
resource_view = ResourceView(
|
|
||||||
root=aliases,
|
|
||||||
agent=aliases / "agent",
|
|
||||||
media=aliases / "media",
|
|
||||||
package=aliases / "package",
|
|
||||||
)
|
|
||||||
|
|
||||||
loader = SkillsLoader(
|
|
||||||
workspace,
|
|
||||||
builtin_skills_dir=builtin,
|
|
||||||
resource_view=resource_view,
|
|
||||||
)
|
|
||||||
entries = loader.list_skills(filter_unavailable=False)
|
|
||||||
summary = loader.build_skills_summary()
|
|
||||||
|
|
||||||
assert {entry["path"] for entry in entries} == {
|
|
||||||
str(workspace_path),
|
|
||||||
str(builtin_path),
|
|
||||||
}
|
|
||||||
assert f"`{resource_view.agent / 'skills'}`" in summary
|
|
||||||
assert f"`{resource_view.package / 'skills'}`" in summary
|
|
||||||
assert str(workspace_path) not in summary
|
|
||||||
assert str(builtin_path) not in summary
|
|
||||||
|
|
||||||
|
|
||||||
def test_bundled_update_setup_description_is_valid_yaml(tmp_path: Path) -> None:
|
def test_bundled_update_setup_description_is_valid_yaml(tmp_path: Path) -> None:
|
||||||
metadata = SkillsLoader(tmp_path).get_skill_metadata("update-setup")
|
metadata = SkillsLoader(tmp_path).get_skill_metadata("update-setup")
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from nanobot.agent.tools.filesystem import FileToolsConfig
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.schema import ToolsConfig
|
from nanobot.config.schema import ToolsConfig
|
||||||
from nanobot.providers.base import GenerationSettings, LLMProvider
|
from nanobot.providers.base import GenerationSettings, LLMProvider
|
||||||
from nanobot.resource_links import ResourceView
|
|
||||||
from nanobot.security.workspace_access import build_workspace_scope
|
from nanobot.security.workspace_access import build_workspace_scope
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
@@ -110,51 +109,6 @@ def test_subagent_prompt_explains_grouped_skill_paths(tmp_path):
|
|||||||
assert "project-custom" not in prompt
|
assert "project-custom" not in prompt
|
||||||
|
|
||||||
|
|
||||||
def test_subagent_prompt_uses_restricted_resource_aliases(tmp_path):
|
|
||||||
agent_workspace = tmp_path / "agent"
|
|
||||||
aliases = tmp_path / "resources" / "view"
|
|
||||||
resource_view = ResourceView(
|
|
||||||
root=aliases,
|
|
||||||
agent=aliases / "agent",
|
|
||||||
media=aliases / "media",
|
|
||||||
package=aliases / "package",
|
|
||||||
)
|
|
||||||
manager = SubagentManager(
|
|
||||||
workspace=agent_workspace,
|
|
||||||
bus=MessageBus(),
|
|
||||||
max_tool_result_chars=16_000,
|
|
||||||
resource_view=resource_view,
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt = manager._build_subagent_prompt(resource_view_mode="restricted")
|
|
||||||
|
|
||||||
assert f"Custom skills: `{resource_view.agent / 'skills'}`" in prompt
|
|
||||||
assert f"Media: `{resource_view.media}`" in prompt
|
|
||||||
assert f"Built-in skills: `{resource_view.package / 'skills'}`" in prompt
|
|
||||||
assert f"Agent workspace: `{resource_view.agent}`" not in prompt
|
|
||||||
assert f"Nanobot package: `{resource_view.package}`" not in prompt
|
|
||||||
assert f"History log: {agent_workspace.resolve() / 'memory' / 'history.jsonl'}" in prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_subagent_prompt_uses_agent_alias_for_full_history_path(tmp_path):
|
|
||||||
agent_workspace = tmp_path / "agent"
|
|
||||||
aliases = tmp_path / "resources" / "view"
|
|
||||||
resource_view = ResourceView(
|
|
||||||
root=aliases,
|
|
||||||
agent=aliases / "agent",
|
|
||||||
)
|
|
||||||
manager = SubagentManager(
|
|
||||||
workspace=agent_workspace,
|
|
||||||
bus=MessageBus(),
|
|
||||||
max_tool_result_chars=16_000,
|
|
||||||
resource_view=resource_view,
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt = manager._build_subagent_prompt(resource_view_mode="full")
|
|
||||||
|
|
||||||
assert f"History log: {resource_view.agent / 'memory' / 'history.jsonl'}" in prompt
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_subagent_keeps_project_runtime_scope_with_agent_owned_tools(tmp_path):
|
async def test_subagent_keeps_project_runtime_scope_with_agent_owned_tools(tmp_path):
|
||||||
agent_workspace = tmp_path / "agent"
|
agent_workspace = tmp_path / "agent"
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ class TestCmdNewUnifiedSession:
|
|||||||
assert len(sessions.get_or_create("unified:default").messages) == 2
|
assert len(sessions.get_or_create("unified:default").messages) == 2
|
||||||
expected_snapshot = list(shared.messages)
|
expected_snapshot = list(shared.messages)
|
||||||
|
|
||||||
# _schedule_background is a *sync* method that schedules a coroutine via
|
# schedule_background is a *sync* method that schedules a coroutine via
|
||||||
# asyncio.create_task(). Mirror that exactly so the coroutine is consumed
|
# asyncio.create_task(). Mirror that exactly so the coroutine is consumed
|
||||||
# and no RuntimeWarning is emitted.
|
# and no RuntimeWarning is emitted.
|
||||||
admitted_runtime = MagicMock(name="admitted_runtime")
|
admitted_runtime = MagicMock(name="admitted_runtime")
|
||||||
@@ -255,8 +255,8 @@ class TestCmdNewUnifiedSession:
|
|||||||
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
||||||
_cancel_active_tasks=AsyncMock(return_value=0),
|
_cancel_active_tasks=AsyncMock(return_value=0),
|
||||||
llm_runtime=MagicMock(return_value=MagicMock()),
|
llm_runtime=MagicMock(return_value=MagicMock()),
|
||||||
|
schedule_background=lambda coro: asyncio.ensure_future(coro),
|
||||||
)
|
)
|
||||||
loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
|
|
||||||
|
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
channel="telegram", sender_id="user1", chat_id="111", content="/new",
|
channel="telegram", sender_id="user1", chat_id="111", content="/new",
|
||||||
@@ -303,8 +303,8 @@ class TestCmdNewUnifiedSession:
|
|||||||
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
||||||
_cancel_active_tasks=AsyncMock(return_value=0),
|
_cancel_active_tasks=AsyncMock(return_value=0),
|
||||||
runtime_for_session=MagicMock(return_value=MagicMock()),
|
runtime_for_session=MagicMock(return_value=MagicMock()),
|
||||||
|
schedule_background=lambda coro: asyncio.ensure_future(coro),
|
||||||
)
|
)
|
||||||
loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
|
|
||||||
|
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
channel="telegram", sender_id="user1", chat_id="111", content="/new",
|
channel="telegram", sender_id="user1", chat_id="111", content="/new",
|
||||||
|
|||||||
+28
-28
@@ -5,8 +5,8 @@ from unittest.mock import AsyncMock, MagicMock, call, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from prompt_toolkit.formatted_text import HTML
|
from prompt_toolkit.formatted_text import HTML
|
||||||
|
|
||||||
from nanobot.cli import commands
|
|
||||||
from nanobot.cli import stream as stream_mod
|
from nanobot.cli import stream as stream_mod
|
||||||
|
from nanobot.cli import terminal
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -14,8 +14,8 @@ def mock_prompt_session():
|
|||||||
"""Mock the global prompt session."""
|
"""Mock the global prompt session."""
|
||||||
mock_session = MagicMock()
|
mock_session = MagicMock()
|
||||||
mock_session.prompt_async = AsyncMock()
|
mock_session.prompt_async = AsyncMock()
|
||||||
with patch("nanobot.cli.commands._PROMPT_SESSION", mock_session), \
|
with patch("nanobot.cli.terminal._prompt_session", mock_session), \
|
||||||
patch("nanobot.cli.commands.patch_stdout"):
|
patch("nanobot.cli.terminal.patch_stdout"):
|
||||||
yield mock_session
|
yield mock_session
|
||||||
|
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ async def test_read_interactive_input_async_returns_input(mock_prompt_session):
|
|||||||
"""Test that _read_interactive_input_async returns the user input from prompt_session."""
|
"""Test that _read_interactive_input_async returns the user input from prompt_session."""
|
||||||
mock_prompt_session.prompt_async.return_value = "hello world"
|
mock_prompt_session.prompt_async.return_value = "hello world"
|
||||||
|
|
||||||
result = await commands._read_interactive_input_async()
|
result = await terminal._read_interactive_input_async()
|
||||||
|
|
||||||
assert result == "hello world"
|
assert result == "hello world"
|
||||||
mock_prompt_session.prompt_async.assert_called_once()
|
mock_prompt_session.prompt_async.assert_called_once()
|
||||||
@@ -38,23 +38,23 @@ async def test_read_interactive_input_async_handles_eof(mock_prompt_session):
|
|||||||
mock_prompt_session.prompt_async.side_effect = EOFError()
|
mock_prompt_session.prompt_async.side_effect = EOFError()
|
||||||
|
|
||||||
with pytest.raises(KeyboardInterrupt):
|
with pytest.raises(KeyboardInterrupt):
|
||||||
await commands._read_interactive_input_async()
|
await terminal._read_interactive_input_async()
|
||||||
|
|
||||||
|
|
||||||
def test_init_prompt_session_creates_session():
|
def test_init_prompt_session_creates_session():
|
||||||
"""Test that _init_prompt_session initializes the global session."""
|
"""Test that _init_prompt_session initializes the global session."""
|
||||||
# Ensure global is None before test
|
# Ensure global is None before test
|
||||||
commands._PROMPT_SESSION = None
|
terminal._prompt_session = None
|
||||||
|
|
||||||
with patch("nanobot.cli.commands.PromptSession") as mock_session_cls, \
|
with patch("nanobot.cli.terminal.PromptSession") as mock_session_cls, \
|
||||||
patch("nanobot.cli.commands.FileHistory"), \
|
patch("nanobot.cli.terminal.FileHistory"), \
|
||||||
patch("pathlib.Path.home") as mock_home:
|
patch("pathlib.Path.home") as mock_home:
|
||||||
|
|
||||||
mock_home.return_value = MagicMock()
|
mock_home.return_value = MagicMock()
|
||||||
|
|
||||||
commands._init_prompt_session()
|
terminal._init_prompt_session()
|
||||||
|
|
||||||
assert commands._PROMPT_SESSION is not None
|
assert terminal._prompt_session is not None
|
||||||
mock_session_cls.assert_called_once()
|
mock_session_cls.assert_called_once()
|
||||||
_, kwargs = mock_session_cls.call_args
|
_, kwargs = mock_session_cls.call_args
|
||||||
# Buffer is multiline-capable so Alt+Enter can insert newlines;
|
# Buffer is multiline-capable so Alt+Enter can insert newlines;
|
||||||
@@ -68,7 +68,7 @@ def test_cli_key_bindings_enter_submits_and_alt_enter_newlines():
|
|||||||
"""Enter submits the buffer; Alt+Enter inserts a newline."""
|
"""Enter submits the buffer; Alt+Enter inserts a newline."""
|
||||||
from prompt_toolkit.keys import Keys
|
from prompt_toolkit.keys import Keys
|
||||||
|
|
||||||
kb = commands._build_cli_key_bindings()
|
kb = terminal._build_cli_key_bindings()
|
||||||
|
|
||||||
def _keys(binding):
|
def _keys(binding):
|
||||||
return tuple(getattr(k, "value", k) for k in binding.keys)
|
return tuple(getattr(k, "value", k) for k in binding.keys)
|
||||||
@@ -102,8 +102,8 @@ async def test_raw_lf_enter_still_submits_like_wsl_terminals():
|
|||||||
|
|
||||||
with create_pipe_input() as pipe_input:
|
with create_pipe_input() as pipe_input:
|
||||||
with create_app_session(input=pipe_input, output=DummyOutput()):
|
with create_app_session(input=pipe_input, output=DummyOutput()):
|
||||||
commands._init_prompt_session()
|
terminal._init_prompt_session()
|
||||||
session = commands._PROMPT_SESSION
|
session = terminal._prompt_session
|
||||||
pipe_input.send_text("hello\x0aworld\r")
|
pipe_input.send_text("hello\x0aworld\r")
|
||||||
result = await session.prompt_async("> ")
|
result = await session.prompt_async("> ")
|
||||||
|
|
||||||
@@ -119,8 +119,8 @@ async def test_alt_enter_inserts_newline_on_lf_terminals():
|
|||||||
|
|
||||||
with create_pipe_input() as pipe_input:
|
with create_pipe_input() as pipe_input:
|
||||||
with create_app_session(input=pipe_input, output=DummyOutput()):
|
with create_app_session(input=pipe_input, output=DummyOutput()):
|
||||||
commands._init_prompt_session()
|
terminal._init_prompt_session()
|
||||||
session = commands._PROMPT_SESSION
|
session = terminal._prompt_session
|
||||||
pipe_input.send_text("foo\x1b\x0abar\r")
|
pipe_input.send_text("foo\x1b\x0abar\r")
|
||||||
result = await session.prompt_async("> ")
|
result = await session.prompt_async("> ")
|
||||||
|
|
||||||
@@ -136,8 +136,8 @@ async def test_csi_u_shift_enter_inserts_newline_not_raw_escape():
|
|||||||
|
|
||||||
with create_pipe_input() as pipe_input:
|
with create_pipe_input() as pipe_input:
|
||||||
with create_app_session(input=pipe_input, output=DummyOutput()):
|
with create_app_session(input=pipe_input, output=DummyOutput()):
|
||||||
commands._init_prompt_session()
|
terminal._init_prompt_session()
|
||||||
session = commands._PROMPT_SESSION
|
session = terminal._prompt_session
|
||||||
pipe_input.send_text("foo\x1b[13;2ubar\r")
|
pipe_input.send_text("foo\x1b[13;2ubar\r")
|
||||||
result = await session.prompt_async("> ")
|
result = await session.prompt_async("> ")
|
||||||
|
|
||||||
@@ -173,10 +173,10 @@ def test_print_cli_progress_line_pauses_spinner_before_printing():
|
|||||||
mock_console = MagicMock()
|
mock_console = MagicMock()
|
||||||
mock_console.status.return_value = spinner
|
mock_console.status.return_value = spinner
|
||||||
|
|
||||||
with patch.object(commands.console, "print", side_effect=lambda *_args, **_kwargs: order.append("print")):
|
with patch.object(terminal.console, "print", side_effect=lambda *_args, **_kwargs: order.append("print")):
|
||||||
thinking = stream_mod.ThinkingSpinner(console=mock_console)
|
thinking = stream_mod.ThinkingSpinner(console=mock_console)
|
||||||
with thinking:
|
with thinking:
|
||||||
commands._print_cli_progress_line("tool running", thinking)
|
terminal._print_cli_progress_line("tool running", thinking)
|
||||||
|
|
||||||
assert order == ["start", "stop", "print", "start", "stop"]
|
assert order == ["start", "stop", "print", "start", "stop"]
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ def test_print_cli_progress_line_opens_renderer_header_before_trace():
|
|||||||
renderer.ensure_header.side_effect = lambda: order.append("header")
|
renderer.ensure_header.side_effect = lambda: order.append("header")
|
||||||
renderer.pause_spinner.return_value = nullcontext()
|
renderer.pause_spinner.return_value = nullcontext()
|
||||||
|
|
||||||
commands._print_cli_progress_line("tool running", None, renderer)
|
terminal._print_cli_progress_line("tool running", None, renderer)
|
||||||
|
|
||||||
assert order == ["header", "print"]
|
assert order == ["header", "print"]
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@ def test_print_cli_progress_line_stops_live_before_trace():
|
|||||||
renderer = stream_mod.StreamRenderer(show_spinner=False)
|
renderer = stream_mod.StreamRenderer(show_spinner=False)
|
||||||
renderer._live = mock_live
|
renderer._live = mock_live
|
||||||
|
|
||||||
commands._print_cli_progress_line("tool running", None, renderer)
|
terminal._print_cli_progress_line("tool running", None, renderer)
|
||||||
|
|
||||||
mock_live.stop.assert_called_once()
|
mock_live.stop.assert_called_once()
|
||||||
assert renderer._live is None
|
assert renderer._live is None
|
||||||
@@ -254,10 +254,10 @@ async def test_print_interactive_progress_line_pauses_spinner_before_printing():
|
|||||||
async def fake_print(_text: str) -> None:
|
async def fake_print(_text: str) -> None:
|
||||||
order.append("print")
|
order.append("print")
|
||||||
|
|
||||||
with patch("nanobot.cli.commands._print_interactive_line", side_effect=fake_print):
|
with patch("nanobot.cli.terminal._print_interactive_line", side_effect=fake_print):
|
||||||
thinking = stream_mod.ThinkingSpinner(console=mock_console)
|
thinking = stream_mod.ThinkingSpinner(console=mock_console)
|
||||||
with thinking:
|
with thinking:
|
||||||
await commands._print_interactive_progress_line("tool running", thinking)
|
await terminal._print_interactive_progress_line("tool running", thinking)
|
||||||
|
|
||||||
assert order == ["start", "stop", "print", "start", "stop"]
|
assert order == ["start", "stop", "print", "start", "stop"]
|
||||||
|
|
||||||
@@ -269,7 +269,7 @@ def test_response_renderable_uses_text_for_explicit_plain_rendering():
|
|||||||
"📊 Tokens: 20639 in / 29 out"
|
"📊 Tokens: 20639 in / 29 out"
|
||||||
)
|
)
|
||||||
|
|
||||||
renderable = commands._response_renderable(
|
renderable = terminal._response_renderable(
|
||||||
status,
|
status,
|
||||||
render_markdown=True,
|
render_markdown=True,
|
||||||
metadata={"render_as": "text"},
|
metadata={"render_as": "text"},
|
||||||
@@ -279,7 +279,7 @@ def test_response_renderable_uses_text_for_explicit_plain_rendering():
|
|||||||
|
|
||||||
|
|
||||||
def test_response_renderable_preserves_normal_markdown_rendering():
|
def test_response_renderable_preserves_normal_markdown_rendering():
|
||||||
renderable = commands._response_renderable("**bold**", render_markdown=True)
|
renderable = terminal._response_renderable("**bold**", render_markdown=True)
|
||||||
|
|
||||||
assert renderable.__class__.__name__ == "Markdown"
|
assert renderable.__class__.__name__ == "Markdown"
|
||||||
|
|
||||||
@@ -287,7 +287,7 @@ def test_response_renderable_preserves_normal_markdown_rendering():
|
|||||||
def test_response_renderable_without_metadata_keeps_markdown_path():
|
def test_response_renderable_without_metadata_keeps_markdown_path():
|
||||||
help_text = "🐈 nanobot commands:\n/status — Show bot status\n/help — Show available commands"
|
help_text = "🐈 nanobot commands:\n/status — Show bot status\n/help — Show available commands"
|
||||||
|
|
||||||
renderable = commands._response_renderable(help_text, render_markdown=True)
|
renderable = terminal._response_renderable(help_text, render_markdown=True)
|
||||||
|
|
||||||
assert renderable.__class__.__name__ == "Markdown"
|
assert renderable.__class__.__name__ == "Markdown"
|
||||||
|
|
||||||
@@ -389,9 +389,9 @@ def test_render_interactive_ansi_force_terminal_follows_isatty():
|
|||||||
captured["console"] = c
|
captured["console"] = c
|
||||||
|
|
||||||
with patch.object(sys.stdout, "isatty", return_value=True):
|
with patch.object(sys.stdout, "isatty", return_value=True):
|
||||||
commands._render_interactive_ansi(render_fn)
|
terminal._render_interactive_ansi(render_fn)
|
||||||
assert captured["console"]._force_terminal is True
|
assert captured["console"]._force_terminal is True
|
||||||
|
|
||||||
with patch.object(sys.stdout, "isatty", return_value=False):
|
with patch.object(sys.stdout, "isatty", return_value=False):
|
||||||
commands._render_interactive_ansi(render_fn)
|
terminal._render_interactive_ansi(render_fn)
|
||||||
assert captured["console"]._force_terminal is False
|
assert captured["console"]._force_terminal is False
|
||||||
|
|||||||
+146
-188
@@ -17,6 +17,11 @@ from nanobot.agent.tools.registry import ToolRegistry
|
|||||||
from nanobot.agent.turn_delivery import TurnDeliveryFactory
|
from nanobot.agent.turn_delivery import TurnDeliveryFactory
|
||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
from nanobot.cli import commands as cli_commands
|
from nanobot.cli import commands as cli_commands
|
||||||
|
from nanobot.cli import gateway_runtime as cli_gateway_runtime
|
||||||
|
from nanobot.cli import provider as provider_commands
|
||||||
|
from nanobot.cli import terminal as cli_terminal
|
||||||
|
from nanobot.cli import webui as cli_webui
|
||||||
|
from nanobot.cli import webui_support as cli_webui_support
|
||||||
from nanobot.cli.commands import app
|
from nanobot.cli.commands import app
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.cron.service import CronJobSkippedError
|
from nanobot.cron.service import CronJobSkippedError
|
||||||
@@ -113,7 +118,7 @@ def test_gateway_signal_handler_first_signal_stops_and_second_forces() -> None:
|
|||||||
task = asyncio.create_task(never.wait())
|
task = asyncio.create_task(never.wait())
|
||||||
output: list[str] = []
|
output: list[str] = []
|
||||||
|
|
||||||
restore = cli_commands._install_gateway_shutdown_handlers(
|
restore = cli_gateway_runtime._install_gateway_shutdown_handlers(
|
||||||
loop, shutdown_event, [task], output.append,
|
loop, shutdown_event, [task], output.append,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -161,8 +166,8 @@ def test_interactive_tty_mode_restores_line_input(monkeypatch) -> None:
|
|||||||
attrs[3] &= ~(termios.ISIG | termios.ICANON | termios.ECHO)
|
attrs[3] &= ~(termios.ISIG | termios.ICANON | termios.ECHO)
|
||||||
termios.tcsetattr(slave_fd, termios.TCSANOW, attrs)
|
termios.tcsetattr(slave_fd, termios.TCSANOW, attrs)
|
||||||
|
|
||||||
monkeypatch.setattr(cli_commands.sys, "stdin", _Stdin())
|
monkeypatch.setattr(cli_terminal.sys, "stdin", _Stdin())
|
||||||
cli_commands._ensure_interactive_tty_mode()
|
cli_terminal._ensure_interactive_tty_mode()
|
||||||
|
|
||||||
restored = termios.tcgetattr(slave_fd)
|
restored = termios.tcgetattr(slave_fd)
|
||||||
assert restored[0] & termios.ICRNL
|
assert restored[0] & termios.ICRNL
|
||||||
@@ -179,24 +184,24 @@ def test_webui_restores_tty_before_loading_config(monkeypatch, tmp_path: Path) -
|
|||||||
config_file = tmp_path / "config.json"
|
config_file = tmp_path / "config.json"
|
||||||
config_file.write_text("{}", encoding="utf-8")
|
config_file.write_text("{}", encoding="utf-8")
|
||||||
calls: list[str] = []
|
calls: list[str] = []
|
||||||
original_resolve = cli_commands._resolve_webui_config_path
|
original_resolve = cli_webui._resolve_webui_config_path
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
cli_commands,
|
cli_terminal,
|
||||||
"_ensure_interactive_tty_mode",
|
"_ensure_interactive_tty_mode",
|
||||||
lambda: calls.append("tty"),
|
lambda: calls.append("tty"),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
cli_commands,
|
cli_webui,
|
||||||
"_resolve_webui_config_path",
|
"_resolve_webui_config_path",
|
||||||
lambda path: calls.append("config") or original_resolve(path),
|
lambda path: calls.append("config") or original_resolve(path),
|
||||||
)
|
)
|
||||||
_patch_webui_provider_ready(monkeypatch)
|
_patch_webui_provider_ready(monkeypatch)
|
||||||
monkeypatch.setattr(cli_commands, "sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr(cli_webui, "sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr(cli_commands, "_gateway_health_ready", lambda *_args, **_kwargs: False)
|
monkeypatch.setattr(cli_webui, "_gateway_health_ready", lambda *_args, **_kwargs: False)
|
||||||
monkeypatch.setattr(cli_commands, "_webui_endpoint_reachable", lambda *_args, **_kwargs: False)
|
monkeypatch.setattr(cli_webui, "_webui_endpoint_reachable", lambda *_args, **_kwargs: False)
|
||||||
monkeypatch.setattr(cli_commands, "_tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
|
monkeypatch.setattr(cli_webui, "_tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
|
||||||
monkeypatch.setattr(cli_commands, "_run_gateway", lambda *_args, **_kwargs: None)
|
monkeypatch.setattr(cli_webui, "_run_gateway", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes", "--no-open"])
|
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes", "--no-open"])
|
||||||
|
|
||||||
@@ -209,11 +214,11 @@ def test_disabled_dream_cursor_only_advances_when_behind(tmp_path) -> None:
|
|||||||
store.append_history("first")
|
store.append_history("first")
|
||||||
store.append_history("second")
|
store.append_history("second")
|
||||||
|
|
||||||
cli_commands._advance_dream_cursor_if_behind(store)
|
cli_gateway_runtime._advance_dream_cursor_if_behind(store)
|
||||||
assert store.get_last_dream_cursor() == 2
|
assert store.get_last_dream_cursor() == 2
|
||||||
|
|
||||||
store.set_last_dream_cursor(10)
|
store.set_last_dream_cursor(10)
|
||||||
cli_commands._advance_dream_cursor_if_behind(store)
|
cli_gateway_runtime._advance_dream_cursor_if_behind(store)
|
||||||
assert store.get_last_dream_cursor() == 10
|
assert store.get_last_dream_cursor() == 10
|
||||||
|
|
||||||
|
|
||||||
@@ -225,7 +230,7 @@ def test_commit_dream_changes_skips_noop_run(tmp_path) -> None:
|
|||||||
store.git.auto_commit("initial")
|
store.git.auto_commit("initial")
|
||||||
store.git.auto_commit = MagicMock(wraps=store.git.auto_commit)
|
store.git.auto_commit = MagicMock(wraps=store.git.auto_commit)
|
||||||
|
|
||||||
assert cli_commands._commit_dream_changes(store) is None
|
assert cli_gateway_runtime._commit_dream_changes(store) is None
|
||||||
store.git.auto_commit.assert_not_called()
|
store.git.auto_commit.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@@ -238,7 +243,7 @@ def test_commit_dream_changes_commits_real_edits(tmp_path) -> None:
|
|||||||
store.write_memory("# Memory\n- Research notes")
|
store.write_memory("# Memory\n- Research notes")
|
||||||
store.git.auto_commit = MagicMock(wraps=store.git.auto_commit)
|
store.git.auto_commit = MagicMock(wraps=store.git.auto_commit)
|
||||||
|
|
||||||
sha = cli_commands._commit_dream_changes(store)
|
sha = cli_gateway_runtime._commit_dream_changes(store)
|
||||||
|
|
||||||
assert sha is not None
|
assert sha is not None
|
||||||
store.git.auto_commit.assert_called_once()
|
store.git.auto_commit.assert_called_once()
|
||||||
@@ -390,7 +395,7 @@ def test_status_help_shows_workspace_and_config_options():
|
|||||||
assert "-c" in stripped_output
|
assert "-c" in stripped_output
|
||||||
|
|
||||||
|
|
||||||
def test_status_uses_explicit_config_and_workspace(tmp_path: Path, monkeypatch):
|
def test_status_uses_explicit_config_and_workspace(tmp_path: Path):
|
||||||
config_path = tmp_path / "instance" / "config.json"
|
config_path = tmp_path / "instance" / "config.json"
|
||||||
config_workspace = tmp_path / "config-workspace"
|
config_workspace = tmp_path / "config-workspace"
|
||||||
override_workspace = tmp_path / "override-workspace"
|
override_workspace = tmp_path / "override-workspace"
|
||||||
@@ -398,11 +403,6 @@ def test_status_uses_explicit_config_and_workspace(tmp_path: Path, monkeypatch):
|
|||||||
config.agents.defaults.workspace = str(config_workspace)
|
config.agents.defaults.workspace = str(config_workspace)
|
||||||
config_path.parent.mkdir(parents=True)
|
config_path.parent.mkdir(parents=True)
|
||||||
config_path.write_text(json.dumps(config.model_dump(mode="json", by_alias=True)))
|
config_path.write_text(json.dumps(config.model_dump(mode="json", by_alias=True)))
|
||||||
monkeypatch.setattr(
|
|
||||||
cli_commands,
|
|
||||||
"_prepare_resource_view",
|
|
||||||
lambda _config: pytest.fail("status must not prepare runtime resource links"),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
@@ -417,58 +417,6 @@ def test_status_uses_explicit_config_and_workspace(tmp_path: Path, monkeypatch):
|
|||||||
assert str(config_workspace) not in compact_output
|
assert str(config_workspace) not in compact_output
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_resource_view_uses_active_config_and_workspace(
|
|
||||||
monkeypatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
from nanobot import resource_links
|
|
||||||
|
|
||||||
config_path = (tmp_path / "instance" / "config.json").resolve()
|
|
||||||
workspace = (tmp_path / "workspace").resolve()
|
|
||||||
config = Config()
|
|
||||||
config.agents.defaults.workspace = str(workspace)
|
|
||||||
expected = SimpleNamespace(warnings=())
|
|
||||||
captured: dict[str, Path] = {}
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"nanobot.config.loader.get_config_path",
|
|
||||||
lambda: config_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _fake_ensure_resource_view(**kwargs):
|
|
||||||
captured.update(kwargs)
|
|
||||||
return expected
|
|
||||||
|
|
||||||
monkeypatch.setattr(resource_links, "ensure_resource_view", _fake_ensure_resource_view)
|
|
||||||
|
|
||||||
assert cli_commands._prepare_resource_view(config) is expected
|
|
||||||
assert captured == {
|
|
||||||
"data_dir": config_path.parent,
|
|
||||||
"config_path": config_path,
|
|
||||||
"agent_workspace": workspace,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_resource_view_failure_does_not_block_runtime(
|
|
||||||
monkeypatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
from nanobot import resource_links
|
|
||||||
|
|
||||||
config_path = tmp_path / "config.json"
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"nanobot.config.loader.get_config_path",
|
|
||||||
lambda: config_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _fail(**_kwargs):
|
|
||||||
raise OSError("read-only filesystem")
|
|
||||||
|
|
||||||
monkeypatch.setattr(resource_links, "ensure_resource_view", _fail)
|
|
||||||
|
|
||||||
assert cli_commands._prepare_resource_view(Config()) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_onboard_interactive_discard_does_not_save_or_create_workspace(mock_paths, monkeypatch):
|
def test_onboard_interactive_discard_does_not_save_or_create_workspace(mock_paths, monkeypatch):
|
||||||
config_file, workspace_dir, _ = mock_paths
|
config_file, workspace_dir, _ = mock_paths
|
||||||
|
|
||||||
@@ -550,7 +498,7 @@ def test_openai_codex_oauth_default_matches_curated_flagship():
|
|||||||
|
|
||||||
assert spec is not None
|
assert spec is not None
|
||||||
assert spec.builtin_models
|
assert spec.builtin_models
|
||||||
assert cli_commands._OAUTH_PROVIDER_DEFAULT_MODELS["openai_codex"] == (
|
assert provider_commands._OAUTH_PROVIDER_DEFAULT_MODELS["openai_codex"] == (
|
||||||
spec.builtin_models[0].id
|
spec.builtin_models[0].id
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -728,16 +676,28 @@ def test_provider_login_rejects_unknown_provider():
|
|||||||
assert "Unknown OAuth provider" in result.stdout
|
assert "Unknown OAuth provider" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_login_openai_codex_handles_missing_oauth_symbol(monkeypatch):
|
||||||
|
import oauth_cli_kit
|
||||||
|
|
||||||
|
monkeypatch.delattr(oauth_cli_kit, "get_token")
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["provider", "login", "openai-codex"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "oauth_cli_kit not installed" in result.stdout
|
||||||
|
assert result.exception is not None
|
||||||
|
|
||||||
|
|
||||||
def test_provider_login_can_set_openai_codex_as_main_provider(tmp_path):
|
def test_provider_login_can_set_openai_codex_as_main_provider(tmp_path):
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
called = False
|
called = False
|
||||||
original = cli_commands._LOGIN_HANDLERS["openai_codex"]
|
original = provider_commands._LOGIN_HANDLERS["openai_codex"]
|
||||||
|
|
||||||
def fake_login() -> None:
|
def fake_login() -> None:
|
||||||
nonlocal called
|
nonlocal called
|
||||||
called = True
|
called = True
|
||||||
|
|
||||||
cli_commands._LOGIN_HANDLERS["openai_codex"] = fake_login
|
provider_commands._LOGIN_HANDLERS["openai_codex"] = fake_login
|
||||||
try:
|
try:
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
@@ -751,7 +711,7 @@ def test_provider_login_can_set_openai_codex_as_main_provider(tmp_path):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
cli_commands._LOGIN_HANDLERS["openai_codex"] = original
|
provider_commands._LOGIN_HANDLERS["openai_codex"] = original
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert called is True
|
assert called is True
|
||||||
@@ -766,8 +726,8 @@ def test_provider_login_can_set_openai_codex_as_main_provider(tmp_path):
|
|||||||
|
|
||||||
def test_provider_login_can_set_github_copilot_as_main_provider(tmp_path):
|
def test_provider_login_can_set_github_copilot_as_main_provider(tmp_path):
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
original = cli_commands._LOGIN_HANDLERS["github_copilot"]
|
original = provider_commands._LOGIN_HANDLERS["github_copilot"]
|
||||||
cli_commands._LOGIN_HANDLERS["github_copilot"] = lambda: None
|
provider_commands._LOGIN_HANDLERS["github_copilot"] = lambda: None
|
||||||
try:
|
try:
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
@@ -781,7 +741,7 @@ def test_provider_login_can_set_github_copilot_as_main_provider(tmp_path):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
cli_commands._LOGIN_HANDLERS["github_copilot"] = original
|
provider_commands._LOGIN_HANDLERS["github_copilot"] = original
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Set github-copilot as the main provider" in result.stdout
|
assert "Set github-copilot as the main provider" in result.stdout
|
||||||
@@ -795,8 +755,8 @@ def test_provider_login_can_set_github_copilot_as_main_provider(tmp_path):
|
|||||||
|
|
||||||
def test_provider_login_can_set_xai_grok_as_main_provider(tmp_path):
|
def test_provider_login_can_set_xai_grok_as_main_provider(tmp_path):
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
original = cli_commands._LOGIN_HANDLERS["xai_grok"]
|
original = provider_commands._LOGIN_HANDLERS["xai_grok"]
|
||||||
cli_commands._LOGIN_HANDLERS["xai_grok"] = lambda: None
|
provider_commands._LOGIN_HANDLERS["xai_grok"] = lambda: None
|
||||||
try:
|
try:
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
@@ -810,7 +770,7 @@ def test_provider_login_can_set_xai_grok_as_main_provider(tmp_path):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
cli_commands._LOGIN_HANDLERS["xai_grok"] = original
|
provider_commands._LOGIN_HANDLERS["xai_grok"] = original
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Set xai-grok as the main provider" in result.stdout
|
assert "Set xai-grok as the main provider" in result.stdout
|
||||||
@@ -825,8 +785,8 @@ def test_provider_login_can_set_xai_grok_as_main_provider(tmp_path):
|
|||||||
|
|
||||||
def test_provider_login_model_implies_set_main_provider(tmp_path):
|
def test_provider_login_model_implies_set_main_provider(tmp_path):
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
original = cli_commands._LOGIN_HANDLERS["github_copilot"]
|
original = provider_commands._LOGIN_HANDLERS["github_copilot"]
|
||||||
cli_commands._LOGIN_HANDLERS["github_copilot"] = lambda: None
|
provider_commands._LOGIN_HANDLERS["github_copilot"] = lambda: None
|
||||||
try:
|
try:
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
@@ -841,7 +801,7 @@ def test_provider_login_model_implies_set_main_provider(tmp_path):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
cli_commands._LOGIN_HANDLERS["github_copilot"] = original
|
provider_commands._LOGIN_HANDLERS["github_copilot"] = original
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Set github-copilot as the main provider" in result.stdout
|
assert "Set github-copilot as the main provider" in result.stdout
|
||||||
@@ -1524,20 +1484,15 @@ def mock_agent_runtime(tmp_path):
|
|||||||
"""Mock agent command dependencies for focused CLI tests."""
|
"""Mock agent command dependencies for focused CLI tests."""
|
||||||
config = Config()
|
config = Config()
|
||||||
config.agents.defaults.workspace = str(tmp_path / "default-workspace")
|
config.agents.defaults.workspace = str(tmp_path / "default-workspace")
|
||||||
resource_view = object()
|
|
||||||
|
|
||||||
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
|
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
|
||||||
patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \
|
patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \
|
||||||
patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
|
patch("nanobot.cli.agent.sync_workspace_templates") as mock_sync_templates, \
|
||||||
patch(
|
|
||||||
"nanobot.cli.commands._prepare_resource_view",
|
|
||||||
return_value=resource_view,
|
|
||||||
) as mock_prepare_resource_view, \
|
|
||||||
patch("nanobot.providers.factory.make_provider", return_value=_fake_provider()), \
|
patch("nanobot.providers.factory.make_provider", return_value=_fake_provider()), \
|
||||||
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
|
patch("nanobot.cli.terminal._print_agent_response") as mock_print_response, \
|
||||||
patch("nanobot.bus.queue.MessageBus"), \
|
patch("nanobot.bus.queue.MessageBus"), \
|
||||||
patch("nanobot.cron.service.CronService"), \
|
patch("nanobot.cron.service.CronService"), \
|
||||||
patch("nanobot.cli.commands.AgentLoop.from_config") as mock_from_config:
|
patch("nanobot.cli.agent.AgentLoop.from_config") as mock_from_config:
|
||||||
agent_loop = MagicMock()
|
agent_loop = MagicMock()
|
||||||
agent_loop.channels_config = None
|
agent_loop.channels_config = None
|
||||||
agent_loop.process_direct = AsyncMock(
|
agent_loop.process_direct = AsyncMock(
|
||||||
@@ -1550,8 +1505,6 @@ def mock_agent_runtime(tmp_path):
|
|||||||
"config": config,
|
"config": config,
|
||||||
"load_config": mock_load_config,
|
"load_config": mock_load_config,
|
||||||
"sync_templates": mock_sync_templates,
|
"sync_templates": mock_sync_templates,
|
||||||
"prepare_resource_view": mock_prepare_resource_view,
|
|
||||||
"resource_view": resource_view,
|
|
||||||
"from_config": mock_from_config,
|
"from_config": mock_from_config,
|
||||||
"agent_loop": agent_loop,
|
"agent_loop": agent_loop,
|
||||||
"print_response": mock_print_response,
|
"print_response": mock_print_response,
|
||||||
@@ -1579,9 +1532,6 @@ def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_
|
|||||||
)
|
)
|
||||||
passed_config = mock_agent_runtime["from_config"].call_args.args[0]
|
passed_config = mock_agent_runtime["from_config"].call_args.args[0]
|
||||||
assert passed_config.workspace_path == mock_agent_runtime["config"].workspace_path
|
assert passed_config.workspace_path == mock_agent_runtime["config"].workspace_path
|
||||||
assert mock_agent_runtime["from_config"].call_args.kwargs["resource_view"] is (
|
|
||||||
mock_agent_runtime["resource_view"]
|
|
||||||
)
|
|
||||||
mock_agent_runtime["agent_loop"].process_direct.assert_awaited_once()
|
mock_agent_runtime["agent_loop"].process_direct.assert_awaited_once()
|
||||||
mock_agent_runtime["print_response"].assert_called_once_with(
|
mock_agent_runtime["print_response"].assert_called_once_with(
|
||||||
"mock-response", render_markdown=True, metadata={},
|
"mock-response", render_markdown=True, metadata={},
|
||||||
@@ -1611,8 +1561,7 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
|||||||
lambda path: seen.__setitem__("config_path", path),
|
lambda path: seen.__setitem__("config_path", path),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.agent.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._prepare_resource_view", lambda _config: None)
|
|
||||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", lambda _store: object())
|
monkeypatch.setattr("nanobot.cron.service.CronService", lambda _store: object())
|
||||||
@@ -1630,8 +1579,8 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
|||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
monkeypatch.setattr("nanobot.cli.terminal._print_agent_response", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
||||||
|
|
||||||
@@ -1650,8 +1599,7 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.agent.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._prepare_resource_view", lambda _config: None)
|
|
||||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
|
|
||||||
@@ -1673,8 +1621,8 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
monkeypatch.setattr("nanobot.cli.terminal._print_agent_response", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
||||||
|
|
||||||
@@ -1700,8 +1648,7 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.agent.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._prepare_resource_view", lambda _config: None)
|
|
||||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
||||||
@@ -1724,8 +1671,8 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
monkeypatch.setattr("nanobot.cli.terminal._print_agent_response", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
@@ -1757,8 +1704,7 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.agent.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._prepare_resource_view", lambda _config: None)
|
|
||||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
||||||
@@ -1781,9 +1727,9 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None
|
"nanobot.cli.terminal._print_agent_response", lambda *_args, **_kwargs: None
|
||||||
)
|
)
|
||||||
|
|
||||||
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
||||||
@@ -1845,20 +1791,20 @@ def test_heartbeat_retains_recent_messages_by_default():
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_heartbeat_has_active_tasks(content, expected):
|
def test_heartbeat_has_active_tasks(content, expected):
|
||||||
from nanobot.cli.commands import _heartbeat_has_active_tasks
|
from nanobot.cli.gateway_runtime import _heartbeat_has_active_tasks
|
||||||
|
|
||||||
assert _heartbeat_has_active_tasks(content) is expected
|
assert _heartbeat_has_active_tasks(content) is expected
|
||||||
|
|
||||||
|
|
||||||
def test_heartbeat_skips_bundled_template():
|
def test_heartbeat_skips_bundled_template():
|
||||||
from nanobot.cli.commands import _heartbeat_has_active_tasks
|
from nanobot.cli.gateway_runtime import _heartbeat_has_active_tasks
|
||||||
from nanobot.utils.helpers import load_bundled_template
|
from nanobot.utils.helpers import load_bundled_template
|
||||||
|
|
||||||
assert _heartbeat_has_active_tasks(load_bundled_template("HEARTBEAT.md")) is False
|
assert _heartbeat_has_active_tasks(load_bundled_template("HEARTBEAT.md")) is False
|
||||||
|
|
||||||
|
|
||||||
def test_heartbeat_target_skips_archived_webui_sessions():
|
def test_heartbeat_target_skips_archived_webui_sessions():
|
||||||
from nanobot.cli.commands import _pick_heartbeat_target_from_sessions
|
from nanobot.cli.gateway_runtime import _pick_heartbeat_target_from_sessions
|
||||||
|
|
||||||
target = _pick_heartbeat_target_from_sessions(
|
target = _pick_heartbeat_target_from_sessions(
|
||||||
enabled_channels=["websocket"],
|
enabled_channels=["websocket"],
|
||||||
@@ -1873,7 +1819,7 @@ def test_heartbeat_target_skips_archived_webui_sessions():
|
|||||||
|
|
||||||
|
|
||||||
def test_heartbeat_target_uses_last_channel_for_unified_session():
|
def test_heartbeat_target_uses_last_channel_for_unified_session():
|
||||||
from nanobot.cli.commands import _pick_heartbeat_target_from_sessions
|
from nanobot.cli.gateway_runtime import _pick_heartbeat_target_from_sessions
|
||||||
from nanobot.session.keys import LAST_CHANNEL_METADATA_KEY, UNIFIED_SESSION_KEY
|
from nanobot.session.keys import LAST_CHANNEL_METADATA_KEY, UNIFIED_SESSION_KEY
|
||||||
|
|
||||||
target = _pick_heartbeat_target_from_sessions(
|
target = _pick_heartbeat_target_from_sessions(
|
||||||
@@ -1895,7 +1841,7 @@ def test_heartbeat_target_uses_last_channel_for_unified_session():
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_heartbeat_target_rejects_unroutable_unified_metadata(metadata):
|
def test_heartbeat_target_rejects_unroutable_unified_metadata(metadata):
|
||||||
from nanobot.cli.commands import _pick_heartbeat_target_from_sessions
|
from nanobot.cli.gateway_runtime import _pick_heartbeat_target_from_sessions
|
||||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||||
|
|
||||||
target = _pick_heartbeat_target_from_sessions(
|
target = _pick_heartbeat_target_from_sessions(
|
||||||
@@ -1936,9 +1882,17 @@ def _patch_webui_provider_ready(monkeypatch) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _patch_gateway_ports_free(monkeypatch) -> None:
|
def _patch_gateway_ports_free(monkeypatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_a, **_kw: False)
|
monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_a, **_kw: False)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._tcp_endpoint_reachable", lambda *_a, **_kw: False)
|
monkeypatch.setattr("nanobot.cli.webui._tcp_endpoint_reachable", lambda *_a, **_kw: False)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_a, **_kw: False)
|
monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_a, **_kw: False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.cli.gateway_runtime._tcp_endpoint_reachable",
|
||||||
|
lambda *_a, **_kw: False,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.cli.gateway_runtime._webui_endpoint_reachable",
|
||||||
|
lambda *_a, **_kw: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _patch_cli_command_runtime(
|
def _patch_cli_command_runtime(
|
||||||
@@ -1952,7 +1906,6 @@ def _patch_cli_command_runtime(
|
|||||||
session_manager=None,
|
session_manager=None,
|
||||||
cron_service=None,
|
cron_service=None,
|
||||||
get_cron_dir=None,
|
get_cron_dir=None,
|
||||||
prepare_resource_view=None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
provider_factory = make_provider or (lambda _config: _fake_provider())
|
provider_factory = make_provider or (lambda _config: _fake_provider())
|
||||||
|
|
||||||
@@ -1967,8 +1920,12 @@ def _patch_cli_command_runtime(
|
|||||||
sync_templates or (lambda _path: None),
|
sync_templates or (lambda _path: None),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._prepare_resource_view",
|
"nanobot.cli.webui.sync_workspace_templates",
|
||||||
prepare_resource_view or (lambda _config: None),
|
sync_templates or (lambda _path: None),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.cli.gateway_runtime.sync_workspace_templates",
|
||||||
|
sync_templates or (lambda _path: None),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.providers.factory.make_provider",
|
"nanobot.providers.factory.make_provider",
|
||||||
@@ -1983,7 +1940,7 @@ def _patch_cli_command_runtime(
|
|||||||
lambda _config_path=None: _test_provider_snapshot(provider_factory(config), config),
|
lambda _config_path=None: _test_provider_snapshot(provider_factory(config), config),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._provider_setup_error",
|
"nanobot.cli.webui_support._provider_setup_error",
|
||||||
lambda _config: None,
|
lambda _config: None,
|
||||||
)
|
)
|
||||||
_patch_gateway_ports_free(monkeypatch)
|
_patch_gateway_ports_free(monkeypatch)
|
||||||
@@ -2084,10 +2041,10 @@ def test_heartbeat_empty_response_still_retains_recent_messages(
|
|||||||
session_manager=_FakeSessionManager,
|
session_manager=_FakeSessionManager,
|
||||||
cron_service=_FakeCron,
|
cron_service=_FakeCron,
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.read_webui_sidebar_state", lambda: {})
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.read_webui_sidebar_state", lambda: {})
|
||||||
monkeypatch.setattr("nanobot.cli.commands.evaluate_response", _unexpected_evaluator)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.evaluate_response", _unexpected_evaluator)
|
||||||
|
|
||||||
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||||
|
|
||||||
@@ -2110,7 +2067,7 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
|
|||||||
seen: dict[str, object] = {}
|
seen: dict[str, object] = {}
|
||||||
_patch_webui_provider_ready(monkeypatch)
|
_patch_webui_provider_ready(monkeypatch)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands.sync_workspace_templates",
|
"nanobot.cli.webui.sync_workspace_templates",
|
||||||
lambda path: seen.__setitem__("templates", path),
|
lambda path: seen.__setitem__("templates", path),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2118,7 +2075,7 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
|
|||||||
seen["gateway_config"] = config
|
seen["gateway_config"] = config
|
||||||
seen["gateway_kwargs"] = kwargs
|
seen["gateway_kwargs"] = kwargs
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cli.commands._run_gateway", _fake_run_gateway)
|
monkeypatch.setattr("nanobot.cli.webui._run_gateway", _fake_run_gateway)
|
||||||
|
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
@@ -2167,13 +2124,17 @@ def test_webui_yes_starts_first_run_without_provider_setup(monkeypatch, tmp_path
|
|||||||
seen: dict[str, object] = {}
|
seen: dict[str, object] = {}
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._provider_setup_error",
|
"nanobot.cli.webui_support._provider_setup_error",
|
||||||
|
lambda _config: "No API key configured for provider 'custom'.",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.cli.webui._provider_setup_error",
|
||||||
lambda _config: "No API key configured for provider 'custom'.",
|
lambda _config: "No API key configured for provider 'custom'.",
|
||||||
)
|
)
|
||||||
_patch_gateway_ports_free(monkeypatch)
|
_patch_gateway_ports_free(monkeypatch)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._run_gateway",
|
"nanobot.cli.webui._run_gateway",
|
||||||
lambda config, **kwargs: seen.update(config=config, **kwargs),
|
lambda config, **kwargs: seen.update(config=config, **kwargs),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2211,7 +2172,7 @@ def test_webui_missing_runtime_env_fails_before_starting_gateway(
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._run_gateway",
|
"nanobot.cli.webui._run_gateway",
|
||||||
lambda *_args, **_kwargs: pytest.fail("gateway must not start with unresolved config"),
|
lambda *_args, **_kwargs: pytest.fail("gateway must not start with unresolved config"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2265,9 +2226,9 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path
|
|||||||
config_file.write_text("{}")
|
config_file.write_text("{}")
|
||||||
seen: dict[str, object] = {}
|
seen: dict[str, object] = {}
|
||||||
_patch_webui_provider_ready(monkeypatch)
|
_patch_webui_provider_ready(monkeypatch)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._prepare_webui_bundle_for_gateway",
|
"nanobot.cli.webui._prepare_webui_bundle_for_gateway",
|
||||||
lambda *_args, **_kwargs: None,
|
lambda *_args, **_kwargs: None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2289,7 +2250,7 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
|
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._open_webui_browser",
|
"nanobot.cli.webui._open_webui_browser",
|
||||||
lambda url: seen.__setitem__("opened_url", url),
|
lambda url: seen.__setitem__("opened_url", url),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2332,7 +2293,7 @@ def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> Non
|
|||||||
url = "http://127.0.0.1:8765/#/?bootstrapSecret=super-secret"
|
url = "http://127.0.0.1:8765/#/?bootstrapSecret=super-secret"
|
||||||
monkeypatch.setattr("webbrowser.open", lambda value: opened.append(value))
|
monkeypatch.setattr("webbrowser.open", lambda value: opened.append(value))
|
||||||
|
|
||||||
cli_commands._open_webui_browser(url, wait=False)
|
cli_webui_support._open_webui_browser(url, wait=False)
|
||||||
|
|
||||||
assert opened == [url]
|
assert opened == [url]
|
||||||
output = _strip_ansi(capsys.readouterr().out)
|
output = _strip_ansi(capsys.readouterr().out)
|
||||||
@@ -2351,9 +2312,9 @@ def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
|
|||||||
config_file.write_text("{}")
|
config_file.write_text("{}")
|
||||||
seen: dict[str, object] = {}
|
seen: dict[str, object] = {}
|
||||||
_patch_webui_provider_ready(monkeypatch)
|
_patch_webui_provider_ready(monkeypatch)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._prepare_webui_bundle_for_gateway",
|
"nanobot.cli.webui._prepare_webui_bundle_for_gateway",
|
||||||
lambda *_args, **_kwargs: None,
|
lambda *_args, **_kwargs: None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2382,7 +2343,7 @@ def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
|
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._open_webui_browser",
|
"nanobot.cli.webui._open_webui_browser",
|
||||||
lambda url: seen.__setitem__("opened_url", url),
|
lambda url: seen.__setitem__("opened_url", url),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2423,15 +2384,15 @@ def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_
|
|||||||
config_file.write_text("{}")
|
config_file.write_text("{}")
|
||||||
seen: dict[str, object] = {}
|
seen: dict[str, object] = {}
|
||||||
_patch_webui_provider_ready(monkeypatch)
|
_patch_webui_provider_ready(monkeypatch)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args, **_kwargs: True)
|
monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_args, **_kwargs: True)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
|
monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._open_webui_browser",
|
"nanobot.cli.webui._open_webui_browser",
|
||||||
lambda url, **kwargs: seen.update({"opened_url": url, "open_kwargs": kwargs}),
|
lambda url, **kwargs: seen.update({"opened_url": url, "open_kwargs": kwargs}),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._run_gateway",
|
"nanobot.cli.webui._run_gateway",
|
||||||
lambda *_args, **_kwargs: pytest.fail("existing gateway should be reused"),
|
lambda *_args, **_kwargs: pytest.fail("existing gateway should be reused"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2444,7 +2405,7 @@ def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
|
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._attach_to_background_gateway",
|
"nanobot.cli.webui._attach_to_background_gateway",
|
||||||
lambda runtime: seen.__setitem__("attached_runtime", runtime),
|
lambda runtime: seen.__setitem__("attached_runtime", runtime),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2477,9 +2438,9 @@ def test_attach_to_background_gateway_stops_on_ctrl_c(monkeypatch, capsys) -> No
|
|||||||
def _interrupt(_seconds: float) -> None:
|
def _interrupt(_seconds: float) -> None:
|
||||||
raise KeyboardInterrupt
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cli.commands.time.sleep", _interrupt)
|
monkeypatch.setattr("nanobot.cli.webui_support.time.sleep", _interrupt)
|
||||||
|
|
||||||
cli_commands._attach_to_background_gateway(_FakeRuntime())
|
cli_webui_support._attach_to_background_gateway(_FakeRuntime())
|
||||||
|
|
||||||
assert stopped is True
|
assert stopped is True
|
||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
@@ -2492,12 +2453,12 @@ def test_webui_foreground_does_not_claim_unmanaged_gateway(monkeypatch, tmp_path
|
|||||||
config_file = tmp_path / "config.json"
|
config_file = tmp_path / "config.json"
|
||||||
config_file.write_text("{}")
|
config_file.write_text("{}")
|
||||||
_patch_webui_provider_ready(monkeypatch)
|
_patch_webui_provider_ready(monkeypatch)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args: True)
|
monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_args: True)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args: True)
|
monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_args: True)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._open_webui_browser", lambda *_args, **_kwargs: None)
|
monkeypatch.setattr("nanobot.cli.webui._open_webui_browser", lambda *_args, **_kwargs: None)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._attach_to_background_gateway",
|
"nanobot.cli.webui._attach_to_background_gateway",
|
||||||
lambda _runtime: pytest.fail("unmanaged gateway must not be attached"),
|
lambda _runtime: pytest.fail("unmanaged gateway must not be attached"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2520,12 +2481,12 @@ def test_webui_foreground_refuses_occupied_webui_port(monkeypatch, tmp_path: Pat
|
|||||||
config_file = tmp_path / "config.json"
|
config_file = tmp_path / "config.json"
|
||||||
config_file.write_text("{}")
|
config_file.write_text("{}")
|
||||||
_patch_webui_provider_ready(monkeypatch)
|
_patch_webui_provider_ready(monkeypatch)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args, **_kwargs: False)
|
monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_args, **_kwargs: False)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
|
monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
|
monkeypatch.setattr("nanobot.cli.webui._tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._run_gateway",
|
"nanobot.cli.webui._run_gateway",
|
||||||
lambda *_args, **_kwargs: pytest.fail("gateway should not start on occupied ports"),
|
lambda *_args, **_kwargs: pytest.fail("gateway should not start on occupied ports"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2539,8 +2500,6 @@ def test_webui_foreground_refuses_occupied_webui_port(monkeypatch, tmp_path: Pat
|
|||||||
|
|
||||||
def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None:
|
def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None:
|
||||||
pytest.importorskip("aiohttp")
|
pytest.importorskip("aiohttp")
|
||||||
resource_view = object()
|
|
||||||
seen["expected_resource_view"] = resource_view
|
|
||||||
|
|
||||||
class _FakeApiApp:
|
class _FakeApiApp:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -2553,7 +2512,6 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
|
|||||||
return cls(workspace=config.workspace_path, **extra)
|
return cls(workspace=config.workspace_path, **extra)
|
||||||
def __init__(self, **kwargs) -> None:
|
def __init__(self, **kwargs) -> None:
|
||||||
seen["workspace"] = kwargs["workspace"]
|
seen["workspace"] = kwargs["workspace"]
|
||||||
seen["resource_view"] = kwargs["resource_view"]
|
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
async def _connect_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
@@ -2583,7 +2541,6 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
|
|||||||
config,
|
config,
|
||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: object(),
|
session_manager=lambda _workspace: object(),
|
||||||
prepare_resource_view=lambda _config: resource_view,
|
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
|
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
|
||||||
@@ -2676,9 +2633,9 @@ def test_gateway_unbound_agent_cron_is_skipped(
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._provider_setup_error", lambda _config: None)
|
monkeypatch.setattr("nanobot.cli.webui_support._provider_setup_error", lambda _config: None)
|
||||||
_patch_gateway_ports_free(monkeypatch)
|
_patch_gateway_ports_free(monkeypatch)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.providers.factory.build_provider_snapshot",
|
"nanobot.providers.factory.build_provider_snapshot",
|
||||||
@@ -2752,10 +2709,10 @@ def test_gateway_unbound_agent_cron_is_skipped(
|
|||||||
raise AssertionError("unbound cron job must not be evaluated for delivery")
|
raise AssertionError("unbound cron job must not be evaluated for delivery")
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands.evaluate_response",
|
"nanobot.cli.gateway_runtime.evaluate_response",
|
||||||
_capture_evaluate_response,
|
_capture_evaluate_response,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2804,9 +2761,9 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._provider_setup_error", lambda _config: None)
|
monkeypatch.setattr("nanobot.cli.webui_support._provider_setup_error", lambda _config: None)
|
||||||
_patch_gateway_ports_free(monkeypatch)
|
_patch_gateway_ports_free(monkeypatch)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.providers.factory.build_provider_snapshot",
|
"nanobot.providers.factory.build_provider_snapshot",
|
||||||
@@ -2868,9 +2825,9 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
|||||||
raise AssertionError("bound cron must not use legacy response evaluator")
|
raise AssertionError("bound cron must not use legacy response evaluator")
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.evaluate_response", _unexpected_evaluator)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.evaluate_response", _unexpected_evaluator)
|
||||||
|
|
||||||
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||||
assert isinstance(result.exception, _StopGatewayError)
|
assert isinstance(result.exception, _StopGatewayError)
|
||||||
@@ -3003,7 +2960,6 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
|
|||||||
config.gateway.heartbeat.enabled = False
|
config.gateway.heartbeat.enabled = False
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
seen: dict[str, object] = {}
|
seen: dict[str, object] = {}
|
||||||
resource_view = object()
|
|
||||||
|
|
||||||
_patch_cli_command_runtime(
|
_patch_cli_command_runtime(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
@@ -3011,7 +2967,6 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
|
|||||||
message_bus=lambda: bus,
|
message_bus=lambda: bus,
|
||||||
session_manager=lambda _workspace: _FakeSessionManager(),
|
session_manager=lambda _workspace: _FakeSessionManager(),
|
||||||
cron_service=lambda _store_path: _FakeCronService(),
|
cron_service=lambda _store_path: _FakeCronService(),
|
||||||
prepare_resource_view=lambda _config: resource_view,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class _FakeMemory:
|
class _FakeMemory:
|
||||||
@@ -3066,7 +3021,7 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
|
|||||||
self.runtime_resolver = MagicMock()
|
self.runtime_resolver = MagicMock()
|
||||||
seen["agent"] = self
|
seen["agent"] = self
|
||||||
|
|
||||||
def _schedule_background(self, _coro) -> None:
|
def schedule_background(self, _coro) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
@@ -3098,7 +3053,7 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
|
|||||||
seen["local_trigger_queue_kwargs"] = kwargs
|
seen["local_trigger_queue_kwargs"] = kwargs
|
||||||
raise _StopGatewayError("stop")
|
raise _StopGatewayError("stop")
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.triggers.local_runner.run_local_trigger_queue",
|
"nanobot.triggers.local_runner.run_local_trigger_queue",
|
||||||
@@ -3115,7 +3070,6 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
|
|||||||
agent_kwargs = seen["agent_from_config_kwargs"]
|
agent_kwargs = seen["agent_from_config_kwargs"]
|
||||||
kwargs = seen["local_trigger_queue_kwargs"]
|
kwargs = seen["local_trigger_queue_kwargs"]
|
||||||
assert isinstance(agent_kwargs["provider"], UnconfiguredProvider) is bool(setup_error)
|
assert isinstance(agent_kwargs["provider"], UnconfiguredProvider) is bool(setup_error)
|
||||||
assert agent_kwargs["resource_view"] is resource_view
|
|
||||||
refreshed_snapshot = agent_kwargs["provider_snapshot_loader"]()
|
refreshed_snapshot = agent_kwargs["provider_snapshot_loader"]()
|
||||||
assert not isinstance(refreshed_snapshot.provider, UnconfiguredProvider)
|
assert not isinstance(refreshed_snapshot.provider, UnconfiguredProvider)
|
||||||
assert "local_trigger_store" in agent_kwargs
|
assert "local_trigger_store" in agent_kwargs
|
||||||
@@ -3207,7 +3161,7 @@ def test_gateway_custom_config_workspace_does_not_migrate_legacy_cron(
|
|||||||
|
|
||||||
def test_migrate_cron_store_moves_legacy_file(tmp_path: Path) -> None:
|
def test_migrate_cron_store_moves_legacy_file(tmp_path: Path) -> None:
|
||||||
"""Legacy global jobs.json is moved into the workspace on first run."""
|
"""Legacy global jobs.json is moved into the workspace on first run."""
|
||||||
from nanobot.cli.commands import _migrate_cron_store
|
from nanobot.cli.runtime_config import _migrate_cron_store
|
||||||
|
|
||||||
legacy_dir = tmp_path / "global" / "cron"
|
legacy_dir = tmp_path / "global" / "cron"
|
||||||
legacy_dir.mkdir(parents=True)
|
legacy_dir.mkdir(parents=True)
|
||||||
@@ -3228,7 +3182,7 @@ def test_migrate_cron_store_moves_legacy_file(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
def test_migrate_cron_store_skips_when_workspace_file_exists(tmp_path: Path) -> None:
|
def test_migrate_cron_store_skips_when_workspace_file_exists(tmp_path: Path) -> None:
|
||||||
"""Migration does not overwrite an existing workspace cron store."""
|
"""Migration does not overwrite an existing workspace cron store."""
|
||||||
from nanobot.cli.commands import _migrate_cron_store
|
from nanobot.cli.runtime_config import _migrate_cron_store
|
||||||
|
|
||||||
legacy_dir = tmp_path / "global" / "cron"
|
legacy_dir = tmp_path / "global" / "cron"
|
||||||
legacy_dir.mkdir(parents=True)
|
legacy_dir.mkdir(parents=True)
|
||||||
@@ -3395,7 +3349,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
|||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: object(),
|
session_manager=lambda _workspace: object(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
||||||
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
|
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
|
||||||
@@ -3446,13 +3400,14 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
|||||||
async def read(self, _size: int) -> bytes:
|
async def read(self, _size: int) -> bytes:
|
||||||
nonlocal started
|
nonlocal started
|
||||||
started += 1
|
started += 1
|
||||||
if started == cli_commands._GATEWAY_HEALTH_MAX_CONNECTIONS:
|
if started == cli_gateway_runtime._GATEWAY_HEALTH_MAX_CONNECTIONS:
|
||||||
all_started.set()
|
all_started.set()
|
||||||
await release.wait()
|
await release.wait()
|
||||||
return b"GET /health HTTP/1.1\r\n\r\n"
|
return b"GET /health HTTP/1.1\r\n\r\n"
|
||||||
|
|
||||||
active_writers = [
|
active_writers = [
|
||||||
_FakeWriter() for _ in range(cli_commands._GATEWAY_HEALTH_MAX_CONNECTIONS)
|
_FakeWriter()
|
||||||
|
for _ in range(cli_gateway_runtime._GATEWAY_HEALTH_MAX_CONNECTIONS)
|
||||||
]
|
]
|
||||||
active_tasks = [
|
active_tasks = [
|
||||||
asyncio.create_task(health_handler(_BlockingReader(), writer))
|
asyncio.create_task(health_handler(_BlockingReader(), writer))
|
||||||
@@ -3477,7 +3432,11 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
|||||||
async def read(self, _size: int) -> bytes:
|
async def read(self, _size: int) -> bytes:
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
monkeypatch.setattr(cli_commands, "_GATEWAY_HEALTH_READ_TIMEOUT_SECONDS", 0.01)
|
monkeypatch.setattr(
|
||||||
|
cli_gateway_runtime,
|
||||||
|
"_GATEWAY_HEALTH_READ_TIMEOUT_SECONDS",
|
||||||
|
0.01,
|
||||||
|
)
|
||||||
timed_out_writer = _FakeWriter()
|
timed_out_writer = _FakeWriter()
|
||||||
asyncio.run(health_handler(_NeverRespondingReader(), timed_out_writer))
|
asyncio.run(health_handler(_NeverRespondingReader(), timed_out_writer))
|
||||||
assert timed_out_writer.closed is True
|
assert timed_out_writer.closed is True
|
||||||
@@ -3568,7 +3527,7 @@ def test_gateway_shutdown_lets_agent_task_own_mcp_cleanup(
|
|||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: object(),
|
session_manager=lambda _workspace: object(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
||||||
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
|
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
|
||||||
@@ -3684,12 +3643,12 @@ def test_gateway_shutdown_event_exits_forever_runtime_tasks(
|
|||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: object(),
|
session_manager=lambda _workspace: object(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
||||||
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
|
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._install_gateway_shutdown_handlers",
|
"nanobot.cli.gateway_runtime._install_gateway_shutdown_handlers",
|
||||||
_fake_install_shutdown_handlers,
|
_fake_install_shutdown_handlers,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3729,7 +3688,6 @@ def test_serve_uses_api_config_defaults_and_workspace_override(
|
|||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert seen["workspace"] == override_workspace
|
assert seen["workspace"] == override_workspace
|
||||||
assert seen["resource_view"] is seen["expected_resource_view"]
|
|
||||||
assert seen["host"] == "127.0.0.2"
|
assert seen["host"] == "127.0.0.2"
|
||||||
assert seen["port"] == 18900
|
assert seen["port"] == 18900
|
||||||
assert seen["request_timeout"] == 45.0
|
assert seen["request_timeout"] == 45.0
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ def test_gateway_missing_provider_managed_start_for_webui_setup(
|
|||||||
monkeypatch.setattr(GatewayRuntime, "start_background", fake_start_background)
|
monkeypatch.setattr(GatewayRuntime, "start_background", fake_start_background)
|
||||||
monkeypatch.setattr(GatewayRuntime, "restart", fake_restart)
|
monkeypatch.setattr(GatewayRuntime, "restart", fake_restart)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands.ensure_webui_bundle",
|
"nanobot.cli.webui_support.ensure_webui_bundle",
|
||||||
lambda **_kwargs: None,
|
lambda **_kwargs: None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.bus.outbound_events import ProgressEvent, RetryWaitEvent
|
from nanobot.bus.outbound_events import ProgressEvent, RetryWaitEvent
|
||||||
from nanobot.cli import commands
|
from nanobot.cli import terminal
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -22,8 +22,8 @@ async def test_interactive_retry_wait_is_rendered_as_progress_even_when_progress
|
|||||||
async def fake_print(text: str, active_thinking: object | None, renderer=None) -> None:
|
async def fake_print(text: str, active_thinking: object | None, renderer=None) -> None:
|
||||||
calls.append((text, active_thinking))
|
calls.append((text, active_thinking))
|
||||||
|
|
||||||
with patch("nanobot.cli.commands._print_interactive_progress_line", side_effect=fake_print):
|
with patch("nanobot.cli.terminal._print_interactive_progress_line", side_effect=fake_print):
|
||||||
handled = await commands._maybe_print_interactive_progress(
|
handled = await terminal._maybe_print_interactive_progress(
|
||||||
msg,
|
msg,
|
||||||
thinking,
|
thinking,
|
||||||
channels_config,
|
channels_config,
|
||||||
@@ -46,8 +46,8 @@ async def test_reasoning_displayed_when_show_reasoning_enabled():
|
|||||||
metadata={},
|
metadata={},
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
|
with patch("nanobot.cli.terminal._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
|
||||||
handled = await commands._maybe_print_interactive_progress(msg, None, channels_config)
|
handled = await terminal._maybe_print_interactive_progress(msg, None, channels_config)
|
||||||
|
|
||||||
assert handled is True
|
assert handled is True
|
||||||
assert calls == ["Let me think about this..."]
|
assert calls == ["Let me think about this..."]
|
||||||
@@ -66,8 +66,8 @@ async def test_reasoning_delta_displayed_when_show_reasoning_enabled():
|
|||||||
metadata={},
|
metadata={},
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
|
with patch("nanobot.cli.terminal._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
|
||||||
handled = await commands._maybe_print_interactive_progress(msg, None, channels_config)
|
handled = await terminal._maybe_print_interactive_progress(msg, None, channels_config)
|
||||||
|
|
||||||
assert handled is True
|
assert handled is True
|
||||||
assert calls == ["I should search first."]
|
assert calls == ["I should search first."]
|
||||||
@@ -79,10 +79,10 @@ async def test_reasoning_delta_buffers_until_sentence_boundary():
|
|||||||
channels_config = SimpleNamespace(
|
channels_config = SimpleNamespace(
|
||||||
send_progress=True, send_tool_hints=False, show_reasoning=True,
|
send_progress=True, send_tool_hints=False, show_reasoning=True,
|
||||||
)
|
)
|
||||||
reasoning_buffer = commands._ReasoningBuffer()
|
reasoning_buffer = terminal._ReasoningBuffer()
|
||||||
|
|
||||||
with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
|
with patch("nanobot.cli.terminal._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
|
||||||
first = await commands._maybe_print_interactive_progress(
|
first = await terminal._maybe_print_interactive_progress(
|
||||||
SimpleNamespace(
|
SimpleNamespace(
|
||||||
content="The",
|
content="The",
|
||||||
event=ProgressEvent(content="The", reasoning_delta=True),
|
event=ProgressEvent(content="The", reasoning_delta=True),
|
||||||
@@ -92,7 +92,7 @@ async def test_reasoning_delta_buffers_until_sentence_boundary():
|
|||||||
channels_config,
|
channels_config,
|
||||||
reasoning_buffer=reasoning_buffer,
|
reasoning_buffer=reasoning_buffer,
|
||||||
)
|
)
|
||||||
second = await commands._maybe_print_interactive_progress(
|
second = await terminal._maybe_print_interactive_progress(
|
||||||
SimpleNamespace(
|
SimpleNamespace(
|
||||||
content=" user asked.",
|
content=" user asked.",
|
||||||
event=ProgressEvent(content=" user asked.", reasoning_delta=True),
|
event=ProgressEvent(content=" user asked.", reasoning_delta=True),
|
||||||
@@ -114,10 +114,10 @@ async def test_reasoning_end_flushes_buffered_delta():
|
|||||||
channels_config = SimpleNamespace(
|
channels_config = SimpleNamespace(
|
||||||
send_progress=True, send_tool_hints=False, show_reasoning=True,
|
send_progress=True, send_tool_hints=False, show_reasoning=True,
|
||||||
)
|
)
|
||||||
reasoning_buffer = commands._ReasoningBuffer()
|
reasoning_buffer = terminal._ReasoningBuffer()
|
||||||
|
|
||||||
with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
|
with patch("nanobot.cli.terminal._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
|
||||||
delta = await commands._maybe_print_interactive_progress(
|
delta = await terminal._maybe_print_interactive_progress(
|
||||||
SimpleNamespace(
|
SimpleNamespace(
|
||||||
content="The user asked",
|
content="The user asked",
|
||||||
event=ProgressEvent(content="The user asked", reasoning_delta=True),
|
event=ProgressEvent(content="The user asked", reasoning_delta=True),
|
||||||
@@ -127,7 +127,7 @@ async def test_reasoning_end_flushes_buffered_delta():
|
|||||||
channels_config,
|
channels_config,
|
||||||
reasoning_buffer=reasoning_buffer,
|
reasoning_buffer=reasoning_buffer,
|
||||||
)
|
)
|
||||||
end = await commands._maybe_print_interactive_progress(
|
end = await terminal._maybe_print_interactive_progress(
|
||||||
SimpleNamespace(
|
SimpleNamespace(
|
||||||
content="",
|
content="",
|
||||||
event=ProgressEvent(reasoning_end=True),
|
event=ProgressEvent(reasoning_end=True),
|
||||||
@@ -155,8 +155,8 @@ async def test_reasoning_hidden_when_show_reasoning_disabled():
|
|||||||
metadata={},
|
metadata={},
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("nanobot.cli.commands._print_cli_reasoning") as mock_reasoning:
|
with patch("nanobot.cli.terminal._print_cli_reasoning") as mock_reasoning:
|
||||||
handled = await commands._maybe_print_interactive_progress(msg, None, channels_config)
|
handled = await terminal._maybe_print_interactive_progress(msg, None, channels_config)
|
||||||
|
|
||||||
assert handled is True
|
assert handled is True
|
||||||
mock_reasoning.assert_not_called()
|
mock_reasoning.assert_not_called()
|
||||||
@@ -178,8 +178,8 @@ async def test_non_reasoning_progress_not_affected_by_show_reasoning():
|
|||||||
async def fake_print(text: str, thinking=None, renderer=None):
|
async def fake_print(text: str, thinking=None, renderer=None):
|
||||||
calls.append(text)
|
calls.append(text)
|
||||||
|
|
||||||
with patch("nanobot.cli.commands._print_interactive_progress_line", side_effect=fake_print):
|
with patch("nanobot.cli.terminal._print_interactive_progress_line", side_effect=fake_print):
|
||||||
handled = await commands._maybe_print_interactive_progress(msg, None, channels_config)
|
handled = await terminal._maybe_print_interactive_progress(msg, None, channels_config)
|
||||||
|
|
||||||
assert handled is True
|
assert handled is True
|
||||||
assert calls == ["working on it..."]
|
assert calls == ["working on it..."]
|
||||||
@@ -200,10 +200,10 @@ async def test_reasoning_shown_when_send_progress_disabled():
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"nanobot.cli.commands._print_cli_reasoning",
|
"nanobot.cli.terminal._print_cli_reasoning",
|
||||||
side_effect=lambda t, th, r=None: calls.append(t),
|
side_effect=lambda t, th, r=None: calls.append(t),
|
||||||
):
|
):
|
||||||
handled = await commands._maybe_print_interactive_progress(msg, None, channels_config)
|
handled = await terminal._maybe_print_interactive_progress(msg, None, channels_config)
|
||||||
|
|
||||||
assert handled is True
|
assert handled is True
|
||||||
assert calls == ["Let me think about this..."]
|
assert calls == ["Let me think about this..."]
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
Surrogate characters in CLI input must not crash history file writes.
|
Surrogate characters in CLI input must not crash history file writes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from nanobot.cli.commands import SafeFileHistory, _sanitize_surrogates
|
from nanobot.cli.commands import SafeFileHistory as LegacySafeFileHistory
|
||||||
|
from nanobot.cli.terminal import SafeFileHistory, _sanitize_surrogates
|
||||||
|
|
||||||
|
|
||||||
|
def test_commands_keeps_safe_file_history_import_compatible() -> None:
|
||||||
|
assert LegacySafeFileHistory is SafeFileHistory
|
||||||
|
|
||||||
|
|
||||||
class TestSanitizeSurrogates:
|
class TestSanitizeSurrogates:
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ class TestMidTurnCommandDispatchedDirectly:
|
|||||||
))
|
))
|
||||||
loop.sessions.save = MagicMock()
|
loop.sessions.save = MagicMock()
|
||||||
loop.sessions.invalidate = MagicMock()
|
loop.sessions.invalidate = MagicMock()
|
||||||
loop._schedule_background = MagicMock()
|
loop.schedule_background = MagicMock()
|
||||||
loop._cancel_active_tasks = AsyncMock(return_value=0)
|
loop._cancel_active_tasks = AsyncMock(return_value=0)
|
||||||
return loop
|
return loop
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from urllib.parse import parse_qs, urlencode, urlsplit
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from oauth_cli_kit.models import OAuthToken
|
||||||
|
|
||||||
|
import nanobot.providers.openai_codex_oauth as codex_oauth
|
||||||
|
from nanobot.providers.openai_codex_oauth import (
|
||||||
|
OpenAICodexOAuthError,
|
||||||
|
OpenAICodexOAuthInputError,
|
||||||
|
complete_openai_codex_oauth_login,
|
||||||
|
start_openai_codex_oauth_login,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _authorization_url(state: str = "expected-state") -> str:
|
||||||
|
return f"{codex_oauth.OPENAI_CODEX_PROVIDER.authorize_url}?{urlencode({'state': state})}"
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_completion(flow) -> OAuthToken:
|
||||||
|
deadline = time.monotonic() + 1
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
token = complete_openai_codex_oauth_login(flow)
|
||||||
|
if token is not None:
|
||||||
|
return token
|
||||||
|
time.sleep(0.01)
|
||||||
|
pytest.fail("OAuth flow did not finish")
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_interactive_login(
|
||||||
|
captured: dict[str, object],
|
||||||
|
*,
|
||||||
|
error: Exception | None = None,
|
||||||
|
) -> Callable[..., OAuthToken]:
|
||||||
|
def login(
|
||||||
|
*,
|
||||||
|
print_fn,
|
||||||
|
prompt_fn,
|
||||||
|
provider,
|
||||||
|
proxy,
|
||||||
|
open_browser,
|
||||||
|
) -> OAuthToken:
|
||||||
|
captured.update(
|
||||||
|
provider=provider,
|
||||||
|
proxy=proxy,
|
||||||
|
open_browser=open_browser,
|
||||||
|
)
|
||||||
|
print_fn("Open this URL:")
|
||||||
|
print_fn(_authorization_url())
|
||||||
|
if not open_browser:
|
||||||
|
captured["callback_url"] = prompt_fn("Paste callback URL")
|
||||||
|
if error is not None:
|
||||||
|
raise error
|
||||||
|
return OAuthToken(
|
||||||
|
access="access-token",
|
||||||
|
refresh="refresh-token",
|
||||||
|
expires=2_000_000_000_000,
|
||||||
|
account_id="acct-test",
|
||||||
|
)
|
||||||
|
|
||||||
|
return login
|
||||||
|
|
||||||
|
|
||||||
|
def test_authorization_url_comes_from_oauth_cli_kit() -> None:
|
||||||
|
flow = start_openai_codex_oauth_login(
|
||||||
|
timeout_s=2,
|
||||||
|
open_browser=False,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
params = parse_qs(urlsplit(flow.authorization_url).query)
|
||||||
|
assert params["response_type"] == ["code"]
|
||||||
|
assert params["client_id"] == [codex_oauth.OPENAI_CODEX_PROVIDER.client_id]
|
||||||
|
assert params["redirect_uri"] == [codex_oauth.OPENAI_CODEX_PROVIDER.redirect_uri]
|
||||||
|
assert params["code_challenge_method"] == ["S256"]
|
||||||
|
assert params["code_challenge"]
|
||||||
|
assert params["state"]
|
||||||
|
finally:
|
||||||
|
flow.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_flow_delegates_browser_and_callback_to_public_oauth_cli_kit(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
codex_oauth,
|
||||||
|
"login_oauth_interactive",
|
||||||
|
_fake_interactive_login(captured),
|
||||||
|
)
|
||||||
|
flow = start_openai_codex_oauth_login(timeout_s=5)
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = _wait_for_completion(flow)
|
||||||
|
finally:
|
||||||
|
flow.cancel()
|
||||||
|
|
||||||
|
assert token.account_id == "acct-test"
|
||||||
|
assert captured == {
|
||||||
|
"provider": codex_oauth.OPENAI_CODEX_PROVIDER,
|
||||||
|
"proxy": None,
|
||||||
|
"open_browser": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_flow_delegates_pasted_callback_to_public_oauth_cli_kit(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
codex_oauth,
|
||||||
|
"login_oauth_interactive",
|
||||||
|
_fake_interactive_login(captured),
|
||||||
|
)
|
||||||
|
flow = start_openai_codex_oauth_login(
|
||||||
|
proxy="http://127.0.0.1:7890",
|
||||||
|
timeout_s=5,
|
||||||
|
open_browser=False,
|
||||||
|
)
|
||||||
|
callback_url = (
|
||||||
|
"http://localhost:1455/auth/callback?"
|
||||||
|
+ urlencode({"code": "authorization-code", "state": "expected-state"})
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
assert complete_openai_codex_oauth_login(flow) is None
|
||||||
|
with pytest.raises(OpenAICodexOAuthInputError, match="full callback URL"):
|
||||||
|
complete_openai_codex_oauth_login(flow, "authorization-code")
|
||||||
|
token = complete_openai_codex_oauth_login(flow, callback_url)
|
||||||
|
if token is None:
|
||||||
|
token = _wait_for_completion(flow)
|
||||||
|
finally:
|
||||||
|
flow.cancel()
|
||||||
|
|
||||||
|
assert token is not None
|
||||||
|
assert token.account_id == "acct-test"
|
||||||
|
assert captured == {
|
||||||
|
"provider": codex_oauth.OPENAI_CODEX_PROVIDER,
|
||||||
|
"proxy": "http://127.0.0.1:7890",
|
||||||
|
"open_browser": False,
|
||||||
|
"callback_url": callback_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_flow_rejects_callback_from_another_login(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
codex_oauth,
|
||||||
|
"login_oauth_interactive",
|
||||||
|
_fake_interactive_login(captured),
|
||||||
|
)
|
||||||
|
flow = start_openai_codex_oauth_login(
|
||||||
|
timeout_s=5,
|
||||||
|
open_browser=False,
|
||||||
|
)
|
||||||
|
callback_url = (
|
||||||
|
"http://localhost:1455/auth/callback?"
|
||||||
|
+ urlencode({"code": "authorization-code", "state": "wrong-state"})
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with pytest.raises(OpenAICodexOAuthInputError, match="does not belong"):
|
||||||
|
complete_openai_codex_oauth_login(flow, callback_url)
|
||||||
|
assert "callback_url" not in captured
|
||||||
|
finally:
|
||||||
|
flow.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_flow_reports_authorization_denial_without_exchanging_code(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
codex_oauth,
|
||||||
|
"login_oauth_interactive",
|
||||||
|
_fake_interactive_login(captured),
|
||||||
|
)
|
||||||
|
flow = start_openai_codex_oauth_login(
|
||||||
|
timeout_s=5,
|
||||||
|
open_browser=False,
|
||||||
|
)
|
||||||
|
callback_url = (
|
||||||
|
"http://localhost:1455/auth/callback?"
|
||||||
|
+ urlencode({"error": "access_denied", "state": "expected-state"})
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with pytest.raises(OpenAICodexOAuthError, match="authorization server"):
|
||||||
|
complete_openai_codex_oauth_login(flow, callback_url)
|
||||||
|
finally:
|
||||||
|
flow.cancel()
|
||||||
|
|
||||||
|
assert "callback_url" not in captured
|
||||||
|
|
||||||
|
|
||||||
|
def test_dependency_error_is_bounded_and_does_not_expose_callback(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
codex_oauth,
|
||||||
|
"login_oauth_interactive",
|
||||||
|
_fake_interactive_login(
|
||||||
|
captured,
|
||||||
|
error=RuntimeError("Token exchange failed: 400 secret-code upstream-body"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
flow = start_openai_codex_oauth_login(
|
||||||
|
timeout_s=5,
|
||||||
|
open_browser=False,
|
||||||
|
)
|
||||||
|
callback_url = (
|
||||||
|
"http://localhost:1455/auth/callback?"
|
||||||
|
+ urlencode({"code": "secret-code", "state": "expected-state"})
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with pytest.raises(OpenAICodexOAuthError) as exc:
|
||||||
|
token = complete_openai_codex_oauth_login(flow, callback_url)
|
||||||
|
if token is None:
|
||||||
|
_wait_for_completion(flow)
|
||||||
|
finally:
|
||||||
|
flow.cancel()
|
||||||
|
|
||||||
|
assert str(exc.value) == "OpenAI Codex OAuth token exchange failed with HTTP 400."
|
||||||
|
assert "secret-code" not in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_flow_expires_while_waiting_for_callback(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
codex_oauth,
|
||||||
|
"login_oauth_interactive",
|
||||||
|
_fake_interactive_login({}),
|
||||||
|
)
|
||||||
|
flow = start_openai_codex_oauth_login(
|
||||||
|
timeout_s=0.05,
|
||||||
|
open_browser=False,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
time.sleep(0.08)
|
||||||
|
with pytest.raises(OpenAICodexOAuthError, match="expired"):
|
||||||
|
complete_openai_codex_oauth_login(flow)
|
||||||
|
finally:
|
||||||
|
flow.cancel()
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.resource_links import ensure_resource_view
|
|
||||||
from nanobot.security.workspace_policy import WorkspaceBoundaryError, resolve_allowed_path
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def resource_targets(tmp_path: Path) -> tuple[Path, Path, Path, Path]:
|
|
||||||
data_dir = tmp_path / "data"
|
|
||||||
workspace = tmp_path / "agent"
|
|
||||||
package = tmp_path / "package" / "nanobot"
|
|
||||||
project = tmp_path / "project"
|
|
||||||
(workspace / "skills" / "custom").mkdir(parents=True)
|
|
||||||
(workspace / "memory").mkdir()
|
|
||||||
(package / "skills" / "builtin").mkdir(parents=True)
|
|
||||||
(package / "templates").mkdir()
|
|
||||||
project.mkdir()
|
|
||||||
(workspace / "skills" / "custom" / "SKILL.md").write_text("custom", encoding="utf-8")
|
|
||||||
(workspace / "memory" / "history.jsonl").write_text("{}\n", encoding="utf-8")
|
|
||||||
(package / "skills" / "builtin" / "SKILL.md").write_text("builtin", encoding="utf-8")
|
|
||||||
(package / "templates" / "identity.md").write_text("identity", encoding="utf-8")
|
|
||||||
return data_dir, workspace, package, project
|
|
||||||
|
|
||||||
|
|
||||||
def _view_for(targets: tuple[Path, Path, Path, Path]):
|
|
||||||
data_dir, workspace, package, _ = targets
|
|
||||||
view = ensure_resource_view(
|
|
||||||
data_dir=data_dir,
|
|
||||||
config_path=data_dir / "config.json",
|
|
||||||
agent_workspace=workspace,
|
|
||||||
package_root=package,
|
|
||||||
)
|
|
||||||
if view.agent is None or view.media is None or view.package is None:
|
|
||||||
pytest.skip(f"directory links unavailable: {view.warnings}")
|
|
||||||
return view
|
|
||||||
|
|
||||||
|
|
||||||
def test_restricted_access_follows_resource_alias_targets(
|
|
||||||
resource_targets: tuple[Path, Path, Path, Path],
|
|
||||||
) -> None:
|
|
||||||
_, workspace, package, project = resource_targets
|
|
||||||
view = _view_for(resource_targets)
|
|
||||||
|
|
||||||
custom_skill = resolve_allowed_path(
|
|
||||||
view.agent / "skills" / "custom" / "SKILL.md",
|
|
||||||
workspace=project,
|
|
||||||
allowed_root=project,
|
|
||||||
extra_allowed_roots=[workspace / "skills", package / "skills"],
|
|
||||||
strict=True,
|
|
||||||
)
|
|
||||||
builtin_skill = resolve_allowed_path(
|
|
||||||
view.package / "skills" / "builtin" / "SKILL.md",
|
|
||||||
workspace=project,
|
|
||||||
allowed_root=project,
|
|
||||||
extra_allowed_roots=[workspace / "skills", package / "skills"],
|
|
||||||
strict=True,
|
|
||||||
)
|
|
||||||
media_root = resolve_allowed_path(
|
|
||||||
view.media,
|
|
||||||
workspace=project,
|
|
||||||
allowed_root=project,
|
|
||||||
extra_allowed_roots=[resource_targets[0] / "media"],
|
|
||||||
strict=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert custom_skill == (workspace / "skills" / "custom" / "SKILL.md").resolve()
|
|
||||||
assert builtin_skill == (package / "skills" / "builtin" / "SKILL.md").resolve()
|
|
||||||
assert media_root == (resource_targets[0] / "media").resolve()
|
|
||||||
|
|
||||||
|
|
||||||
def test_alias_does_not_expand_restricted_package_or_agent_access(
|
|
||||||
resource_targets: tuple[Path, Path, Path, Path],
|
|
||||||
) -> None:
|
|
||||||
_, workspace, _, project = resource_targets
|
|
||||||
view = _view_for(resource_targets)
|
|
||||||
|
|
||||||
with pytest.raises(WorkspaceBoundaryError):
|
|
||||||
resolve_allowed_path(
|
|
||||||
view.package / "templates" / "identity.md",
|
|
||||||
workspace=project,
|
|
||||||
allowed_root=project,
|
|
||||||
extra_allowed_roots=[workspace / "skills"],
|
|
||||||
strict=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
history = workspace / "memory" / "history.jsonl"
|
|
||||||
with pytest.raises(WorkspaceBoundaryError):
|
|
||||||
resolve_allowed_path(
|
|
||||||
view.agent / "memory" / "history.jsonl",
|
|
||||||
workspace=project,
|
|
||||||
allowed_root=project,
|
|
||||||
extra_allowed_files=[history],
|
|
||||||
strict=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert resolve_allowed_path(
|
|
||||||
history,
|
|
||||||
workspace=project,
|
|
||||||
allowed_root=project,
|
|
||||||
extra_allowed_files=[history],
|
|
||||||
strict=True,
|
|
||||||
) == history.resolve()
|
|
||||||
@@ -9,9 +9,12 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.security.network import (
|
from nanobot.security.network import (
|
||||||
|
Httpx2PinnedDNSAsyncTransport,
|
||||||
|
PinnedDNSAsyncTransport,
|
||||||
configure_ssrf_whitelist,
|
configure_ssrf_whitelist,
|
||||||
contains_internal_url,
|
contains_internal_url,
|
||||||
env_proxy_applies_to_url,
|
env_proxy_applies_to_url,
|
||||||
|
httpx2_env_proxy_mounts,
|
||||||
httpx_env_proxy_mounts,
|
httpx_env_proxy_mounts,
|
||||||
is_loopback_host,
|
is_loopback_host,
|
||||||
pin_resolved_url_dns,
|
pin_resolved_url_dns,
|
||||||
@@ -264,6 +267,17 @@ def test_env_proxy_helpers_respect_no_proxy(monkeypatch):
|
|||||||
assert any(transport is None for transport in mounts.values())
|
assert any(transport is None for transport in mounts.values())
|
||||||
assert any(transport is not None for transport in mounts.values())
|
assert any(transport is not None for transport in mounts.values())
|
||||||
|
|
||||||
|
httpx2_mounts = httpx2_env_proxy_mounts()
|
||||||
|
assert any(transport is None for transport in httpx2_mounts.values())
|
||||||
|
assert any(transport is not None for transport in httpx2_mounts.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_httpx_transports_share_global_dns_pin_lock():
|
||||||
|
assert (
|
||||||
|
Httpx2PinnedDNSAsyncTransport._resolver_lock
|
||||||
|
is PinnedDNSAsyncTransport._resolver_lock
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# contains_internal_url — shell command scanning
|
# contains_internal_url — shell command scanning
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import nanobot.session as session_api
|
||||||
|
from nanobot.session import Session, SessionManager
|
||||||
|
from nanobot.session.manager import FILE_MAX_MESSAGES, SessionStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_types_are_not_public_session_api() -> None:
|
||||||
|
assert not hasattr(session_api, "SessionStore")
|
||||||
|
assert not hasattr(session_api, "JsonlSessionStore")
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_delegates_persistence_to_store(tmp_path) -> None:
|
||||||
|
stored = Session(key="cli:test")
|
||||||
|
stored.add_message("user", "hello")
|
||||||
|
payload = {
|
||||||
|
"key": stored.key,
|
||||||
|
"created_at": stored.created_at.isoformat(),
|
||||||
|
"updated_at": stored.updated_at.isoformat(),
|
||||||
|
"metadata": {},
|
||||||
|
"messages": stored.messages,
|
||||||
|
}
|
||||||
|
metadata = {
|
||||||
|
"key": stored.key,
|
||||||
|
"created_at": stored.created_at.isoformat(),
|
||||||
|
"updated_at": stored.updated_at.isoformat(),
|
||||||
|
"metadata": {},
|
||||||
|
}
|
||||||
|
listing = [
|
||||||
|
{
|
||||||
|
"key": stored.key,
|
||||||
|
"created_at": stored.created_at.isoformat(),
|
||||||
|
"updated_at": stored.updated_at.isoformat(),
|
||||||
|
"title": "",
|
||||||
|
"preview": "hello",
|
||||||
|
"path": "session.db",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
store = MagicMock(spec=SessionStore)
|
||||||
|
store.load.return_value = stored
|
||||||
|
store.read.return_value = payload
|
||||||
|
store.read_metadata.return_value = metadata
|
||||||
|
store.list_sessions.return_value = listing
|
||||||
|
store.delete.return_value = True
|
||||||
|
manager = SessionManager(tmp_path, store=store)
|
||||||
|
|
||||||
|
assert manager.get_or_create(stored.key) is stored
|
||||||
|
assert manager.get_or_create(stored.key) is stored
|
||||||
|
store.load.assert_called_once_with(stored.key)
|
||||||
|
|
||||||
|
manager.save(stored, fsync=True)
|
||||||
|
store.save.assert_called_once_with(stored, fsync=True)
|
||||||
|
assert manager.read_session_file(stored.key) == payload
|
||||||
|
assert manager.read_session_metadata(stored.key) == metadata
|
||||||
|
assert manager.list_sessions() == listing
|
||||||
|
|
||||||
|
assert manager.delete_session(stored.key) is True
|
||||||
|
store.delete.assert_called_once_with(stored.key)
|
||||||
|
assert manager.get_cached(stored.key) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_applies_file_cap_before_store_save(tmp_path) -> None:
|
||||||
|
store = MagicMock(spec=SessionStore)
|
||||||
|
archiver = MagicMock()
|
||||||
|
manager = SessionManager(tmp_path, store=store)
|
||||||
|
manager.set_file_cap_archiver(archiver)
|
||||||
|
session = Session(
|
||||||
|
key="cli:large",
|
||||||
|
messages=[
|
||||||
|
{"role": "user", "content": str(index)}
|
||||||
|
for index in range(FILE_MAX_MESSAGES + 1)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.save(session)
|
||||||
|
|
||||||
|
assert len(session.messages) == FILE_MAX_MESSAGES
|
||||||
|
archiver.assert_called_once()
|
||||||
|
store.save.assert_called_once_with(session, fsync=False)
|
||||||
@@ -10,7 +10,6 @@ from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
from nanobot.nanobot import (
|
from nanobot.nanobot import (
|
||||||
STREAM_EVENT_REASONING_COMPLETED,
|
STREAM_EVENT_REASONING_COMPLETED,
|
||||||
STREAM_EVENT_REASONING_DELTA,
|
STREAM_EVENT_REASONING_DELTA,
|
||||||
@@ -31,7 +30,6 @@ from nanobot.nanobot import (
|
|||||||
StreamEvent,
|
StreamEvent,
|
||||||
StreamEventType,
|
StreamEventType,
|
||||||
)
|
)
|
||||||
from nanobot.nanobot import _prepare_resource_view as prepare_resource_view
|
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
RUNTIME_CONTEXT_HISTORY_META,
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
RuntimeContextBlock,
|
RuntimeContextBlock,
|
||||||
@@ -41,15 +39,6 @@ from nanobot.session.manager import FILE_MAX_MESSAGES
|
|||||||
from nanobot.utils.llm_runtime import runtime_from_provider_snapshot
|
from nanobot.utils.llm_runtime import runtime_from_provider_snapshot
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _disable_sdk_resource_view_creation(monkeypatch) -> None:
|
|
||||||
"""Keep facade tests from creating runtime links unless a test opts in."""
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"nanobot.nanobot._prepare_resource_view",
|
|
||||||
lambda _config, _config_path: None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _write_config(tmp_path: Path, overrides: dict | None = None) -> Path:
|
def _write_config(tmp_path: Path, overrides: dict | None = None) -> Path:
|
||||||
data = {
|
data = {
|
||||||
"providers": {"openrouter": {"apiKey": "sk-test-key"}},
|
"providers": {"openrouter": {"apiKey": "sk-test-key"}},
|
||||||
@@ -169,72 +158,6 @@ def test_from_config_default_path():
|
|||||||
mock_load.assert_called_once_with(None)
|
mock_load.assert_called_once_with(None)
|
||||||
|
|
||||||
|
|
||||||
def test_from_config_scopes_resource_view_to_custom_config_without_global_mutation(
|
|
||||||
monkeypatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
from nanobot.config import loader
|
|
||||||
|
|
||||||
instance_dir = tmp_path / "instance"
|
|
||||||
instance_dir.mkdir()
|
|
||||||
config_path = _write_config(instance_dir)
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
unrelated_config = tmp_path / "other" / "config.json"
|
|
||||||
monkeypatch.setattr(loader, "_current_config_path", unrelated_config)
|
|
||||||
resource_view = object()
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"nanobot.nanobot._prepare_resource_view",
|
|
||||||
return_value=resource_view,
|
|
||||||
) as mock_prepare, patch("nanobot.nanobot.AgentLoop.from_config") as mock_loop:
|
|
||||||
bot = Nanobot.from_config(config_path, workspace=workspace)
|
|
||||||
|
|
||||||
prepared_config, prepared_path = mock_prepare.call_args.args
|
|
||||||
assert prepared_path == config_path.resolve()
|
|
||||||
assert prepared_config.workspace_path == workspace.resolve()
|
|
||||||
assert mock_loop.call_args.kwargs["resource_view"] is resource_view
|
|
||||||
assert loader.get_config_path() == unrelated_config
|
|
||||||
assert bot._loop is mock_loop.return_value
|
|
||||||
|
|
||||||
|
|
||||||
def test_sdk_resource_view_failure_is_non_fatal(
|
|
||||||
monkeypatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
from nanobot import resource_links
|
|
||||||
|
|
||||||
config = Config()
|
|
||||||
config.agents.defaults.workspace = str(tmp_path / "workspace")
|
|
||||||
|
|
||||||
def _fail(**_kwargs):
|
|
||||||
raise PermissionError("read-only")
|
|
||||||
|
|
||||||
monkeypatch.setattr(resource_links, "ensure_resource_view", _fail)
|
|
||||||
|
|
||||||
assert prepare_resource_view(config, tmp_path / "config.json") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_sdk_resource_view_prepares_fresh_workspace_before_linking(
|
|
||||||
monkeypatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
from nanobot import resource_links
|
|
||||||
|
|
||||||
config = Config()
|
|
||||||
workspace = tmp_path / "fresh-workspace"
|
|
||||||
config.agents.defaults.workspace = str(workspace)
|
|
||||||
expected = SimpleNamespace(warnings=())
|
|
||||||
|
|
||||||
def _capture(**kwargs):
|
|
||||||
assert workspace.is_dir()
|
|
||||||
assert kwargs["agent_workspace"] == workspace
|
|
||||||
return expected
|
|
||||||
|
|
||||||
monkeypatch.setattr(resource_links, "ensure_resource_view", _capture)
|
|
||||||
|
|
||||||
assert prepare_resource_view(config, tmp_path / "config.json") is expected
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_returns_result(tmp_path):
|
async def test_run_returns_result(tmp_path):
|
||||||
config_path = _write_config(tmp_path)
|
config_path = _write_config(tmp_path)
|
||||||
|
|||||||
@@ -1,332 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from filelock import Timeout
|
|
||||||
|
|
||||||
from nanobot import resource_links
|
|
||||||
from nanobot.resource_links import ResourceView, ensure_resource_view
|
|
||||||
|
|
||||||
|
|
||||||
def _targets(tmp_path: Path) -> tuple[Path, Path, Path, Path]:
|
|
||||||
data_dir = tmp_path / "state"
|
|
||||||
config_path = data_dir / "config.json"
|
|
||||||
agent_workspace = tmp_path / "agent"
|
|
||||||
package_root = tmp_path / "package"
|
|
||||||
agent_workspace.mkdir()
|
|
||||||
package_root.mkdir()
|
|
||||||
return data_dir, config_path, agent_workspace, package_root
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure(
|
|
||||||
data_dir: Path,
|
|
||||||
config_path: Path,
|
|
||||||
agent_workspace: Path,
|
|
||||||
package_root: Path,
|
|
||||||
) -> ResourceView:
|
|
||||||
return ensure_resource_view(
|
|
||||||
data_dir=data_dir,
|
|
||||||
config_path=config_path,
|
|
||||||
agent_workspace=agent_workspace,
|
|
||||||
package_root=package_root,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _remove_directory_link(path: Path) -> None:
|
|
||||||
try:
|
|
||||||
path.unlink()
|
|
||||||
except OSError:
|
|
||||||
os.rmdir(path)
|
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_resource_view_is_stable_and_idempotent(tmp_path: Path) -> None:
|
|
||||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
|
||||||
|
|
||||||
first = _ensure(data_dir, config_path, agent_workspace, package_root)
|
|
||||||
second = _ensure(data_dir, config_path, agent_workspace, package_root)
|
|
||||||
|
|
||||||
assert first == second
|
|
||||||
assert first.warnings == ()
|
|
||||||
assert first.root is not None
|
|
||||||
assert len(first.root.name) == 16
|
|
||||||
assert first.agent is not None
|
|
||||||
assert first.agent.resolve(strict=True) == agent_workspace.resolve(strict=True)
|
|
||||||
assert first.media is not None
|
|
||||||
assert first.media.resolve(strict=True) == (data_dir / "media").resolve(strict=True)
|
|
||||||
assert first.package is not None
|
|
||||||
assert first.package.resolve(strict=True) == package_root.resolve(strict=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_resource_view_id_isolated_by_config_workspace_and_package(tmp_path: Path) -> None:
|
|
||||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
|
||||||
other_workspace = tmp_path / "other-agent"
|
|
||||||
other_package = tmp_path / "other-package"
|
|
||||||
other_workspace.mkdir()
|
|
||||||
other_package.mkdir()
|
|
||||||
|
|
||||||
baseline = _ensure(data_dir, config_path, agent_workspace, package_root)
|
|
||||||
config_variant = _ensure(
|
|
||||||
data_dir,
|
|
||||||
data_dir / "other-config.json",
|
|
||||||
agent_workspace,
|
|
||||||
package_root,
|
|
||||||
)
|
|
||||||
workspace_variant = _ensure(data_dir, config_path, other_workspace, package_root)
|
|
||||||
package_variant = _ensure(data_dir, config_path, agent_workspace, other_package)
|
|
||||||
|
|
||||||
roots = {
|
|
||||||
baseline.root,
|
|
||||||
config_variant.root,
|
|
||||||
workspace_variant.root,
|
|
||||||
package_variant.root,
|
|
||||||
}
|
|
||||||
assert None not in roots
|
|
||||||
assert len(roots) == 4
|
|
||||||
|
|
||||||
|
|
||||||
def test_partial_link_failure_only_degrades_that_alias(
|
|
||||||
monkeypatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
|
||||||
real_create = resource_links._create_directory_link
|
|
||||||
|
|
||||||
def fail_media(alias: Path, target: Path) -> None:
|
|
||||||
if alias.name == "media":
|
|
||||||
raise PermissionError("media denied")
|
|
||||||
real_create(alias, target)
|
|
||||||
|
|
||||||
monkeypatch.setattr(resource_links, "_create_directory_link", fail_media)
|
|
||||||
|
|
||||||
view = _ensure(data_dir, config_path, agent_workspace, package_root)
|
|
||||||
|
|
||||||
assert view.root is not None
|
|
||||||
assert view.agent is not None
|
|
||||||
assert view.media is None
|
|
||||||
assert view.package is not None
|
|
||||||
assert any("media denied" in warning for warning in view.warnings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_existing_alias_collision_is_never_replaced(tmp_path: Path) -> None:
|
|
||||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
|
||||||
first = _ensure(data_dir, config_path, agent_workspace, package_root)
|
|
||||||
assert first.agent is not None
|
|
||||||
_remove_directory_link(first.agent)
|
|
||||||
first.agent.write_text("user-owned", encoding="utf-8")
|
|
||||||
|
|
||||||
second = _ensure(data_dir, config_path, agent_workspace, package_root)
|
|
||||||
|
|
||||||
assert second.root == first.root
|
|
||||||
assert second.agent is None
|
|
||||||
assert second.media is not None
|
|
||||||
assert second.package is not None
|
|
||||||
assert first.agent.read_text(encoding="utf-8") == "user-owned"
|
|
||||||
assert any("alias collision for agent" in warning for warning in second.warnings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrong_link_is_never_repointed(tmp_path: Path) -> None:
|
|
||||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
|
||||||
wrong_target = tmp_path / "wrong-agent"
|
|
||||||
wrong_target.mkdir()
|
|
||||||
first = _ensure(data_dir, config_path, agent_workspace, package_root)
|
|
||||||
assert first.agent is not None
|
|
||||||
_remove_directory_link(first.agent)
|
|
||||||
resource_links._create_directory_link(first.agent, wrong_target)
|
|
||||||
|
|
||||||
second = _ensure(data_dir, config_path, agent_workspace, package_root)
|
|
||||||
|
|
||||||
assert second.agent is None
|
|
||||||
assert first.agent.resolve(strict=True) == wrong_target.resolve(strict=True)
|
|
||||||
assert any("alias collision for agent" in warning for warning in second.warnings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_unmanaged_namespace_collision_is_not_modified(tmp_path: Path) -> None:
|
|
||||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
|
||||||
namespace = data_dir / "resources"
|
|
||||||
namespace.mkdir(parents=True)
|
|
||||||
user_file = namespace / "notes.txt"
|
|
||||||
user_file.write_text("keep me", encoding="utf-8")
|
|
||||||
|
|
||||||
view = _ensure(data_dir, config_path, agent_workspace, package_root)
|
|
||||||
|
|
||||||
assert view.root is None
|
|
||||||
assert view.agent is None
|
|
||||||
assert user_file.read_text(encoding="utf-8") == "keep me"
|
|
||||||
assert list(namespace.iterdir()) == [user_file]
|
|
||||||
assert any("ownership marker missing" in warning for warning in view.warnings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mismatched_view_marker_is_not_repaired(tmp_path: Path) -> None:
|
|
||||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
|
||||||
first = _ensure(data_dir, config_path, agent_workspace, package_root)
|
|
||||||
assert first.root is not None
|
|
||||||
marker = first.root / ".nanobot-resource-view.json"
|
|
||||||
payload = json.loads(marker.read_text(encoding="utf-8"))
|
|
||||||
payload["targets"]["agent"] = str(tmp_path / "someone-else")
|
|
||||||
marker.write_text(json.dumps(payload), encoding="utf-8")
|
|
||||||
|
|
||||||
second = _ensure(data_dir, config_path, agent_workspace, package_root)
|
|
||||||
|
|
||||||
assert second.root is None
|
|
||||||
assert second.agent is None
|
|
||||||
assert any("marker does not match" in warning for warning in second.warnings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_marker_encoding_degrades_without_raising(tmp_path: Path) -> None:
|
|
||||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
|
||||||
first = _ensure(data_dir, config_path, agent_workspace, package_root)
|
|
||||||
assert first.root is not None
|
|
||||||
marker = first.root / ".nanobot-resource-view.json"
|
|
||||||
marker.write_bytes(b"\xff")
|
|
||||||
|
|
||||||
second = _ensure(data_dir, config_path, agent_workspace, package_root)
|
|
||||||
|
|
||||||
assert second.root is None
|
|
||||||
assert any("Could not read resource view marker" in warning for warning in second.warnings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_failed_marker_write_removes_only_new_empty_view_directory(
|
|
||||||
monkeypatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
|
||||||
real_write_marker = resource_links._write_marker
|
|
||||||
|
|
||||||
def fail_view_marker(marker_path: Path, payload: dict) -> None:
|
|
||||||
if marker_path.name == resource_links._VIEW_MARKER:
|
|
||||||
raise PermissionError("view marker denied")
|
|
||||||
real_write_marker(marker_path, payload)
|
|
||||||
|
|
||||||
monkeypatch.setattr(resource_links, "_write_marker", fail_view_marker)
|
|
||||||
|
|
||||||
view = _ensure(data_dir, config_path, agent_workspace, package_root)
|
|
||||||
|
|
||||||
namespace = data_dir / "resources"
|
|
||||||
assert view.root is None
|
|
||||||
assert namespace.is_dir()
|
|
||||||
assert [entry.name for entry in namespace.iterdir()] == [
|
|
||||||
resource_links._NAMESPACE_MARKER
|
|
||||||
]
|
|
||||||
assert any("view marker denied" in warning for warning in view.warnings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_view_inside_agent_target_is_fully_disabled_to_avoid_recursive_walk(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
agent_workspace = tmp_path / "agent"
|
|
||||||
data_dir = agent_workspace / ".nanobot"
|
|
||||||
config_path = data_dir / "config.json"
|
|
||||||
package_root = tmp_path / "package"
|
|
||||||
agent_workspace.mkdir()
|
|
||||||
package_root.mkdir()
|
|
||||||
|
|
||||||
view = _ensure(data_dir, config_path, agent_workspace, package_root)
|
|
||||||
|
|
||||||
assert view.root is None
|
|
||||||
assert view.agent is None
|
|
||||||
assert view.media is None
|
|
||||||
assert view.package is None
|
|
||||||
assert not (data_dir / "resources").exists()
|
|
||||||
assert any("recursive traversal unsafe" in warning for warning in view.warnings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_unverified_new_link_is_removed_without_touching_target(
|
|
||||||
monkeypatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
|
||||||
real_points_to = resource_links._link_points_to
|
|
||||||
|
|
||||||
def fail_agent_verification(alias: Path, target: Path) -> bool:
|
|
||||||
if alias.name == "agent":
|
|
||||||
return False
|
|
||||||
return real_points_to(alias, target)
|
|
||||||
|
|
||||||
monkeypatch.setattr(resource_links, "_link_points_to", fail_agent_verification)
|
|
||||||
|
|
||||||
view = _ensure(data_dir, config_path, agent_workspace, package_root)
|
|
||||||
|
|
||||||
assert view.root is not None
|
|
||||||
assert view.agent is None
|
|
||||||
assert not os.path.lexists(view.root / "agent")
|
|
||||||
assert agent_workspace.is_dir()
|
|
||||||
assert view.media is not None
|
|
||||||
assert view.package is not None
|
|
||||||
assert any("could not be verified" in warning for warning in view.warnings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_lock_timeout_is_nonfatal_and_finite(monkeypatch, tmp_path: Path) -> None:
|
|
||||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
|
||||||
observed_timeouts: list[float] = []
|
|
||||||
|
|
||||||
def fail_lock(lock_path: str, *, timeout: float):
|
|
||||||
observed_timeouts.append(timeout)
|
|
||||||
raise Timeout(lock_path)
|
|
||||||
|
|
||||||
monkeypatch.setattr(resource_links, "FileLock", fail_lock)
|
|
||||||
|
|
||||||
view = _ensure(data_dir, config_path, agent_workspace, package_root)
|
|
||||||
|
|
||||||
assert observed_timeouts == [resource_links._LOCK_TIMEOUT_SECONDS]
|
|
||||||
assert view == ResourceView(
|
|
||||||
warnings=(
|
|
||||||
f"Timed out waiting for resource view lock: "
|
|
||||||
f"{data_dir.resolve() / '.nanobot-resource-links.lock'}",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_windows_symlink_failure_falls_back_to_junction(monkeypatch, tmp_path: Path) -> None:
|
|
||||||
alias = tmp_path / "alias"
|
|
||||||
target = tmp_path / "target"
|
|
||||||
target.mkdir()
|
|
||||||
junction_calls: list[tuple[Path, Path]] = []
|
|
||||||
|
|
||||||
def fail_symlink(self: Path, target: Path, *, target_is_directory: bool = False) -> None:
|
|
||||||
assert target_is_directory is True
|
|
||||||
raise PermissionError("symlinks unavailable")
|
|
||||||
|
|
||||||
def record_junction(link: Path, junction_target: Path) -> None:
|
|
||||||
junction_calls.append((link, junction_target))
|
|
||||||
|
|
||||||
monkeypatch.setattr(Path, "symlink_to", fail_symlink)
|
|
||||||
monkeypatch.setattr(resource_links, "_is_windows", lambda: True)
|
|
||||||
monkeypatch.setattr(resource_links, "_create_windows_junction", record_junction)
|
|
||||||
|
|
||||||
resource_links._create_directory_link(alias, target)
|
|
||||||
|
|
||||||
assert junction_calls == [(alias, target)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_windows_junction_command_timeout_is_bounded(monkeypatch, tmp_path: Path) -> None:
|
|
||||||
observed_timeouts: list[float] = []
|
|
||||||
|
|
||||||
def time_out(command: str, **kwargs):
|
|
||||||
observed_timeouts.append(kwargs["timeout"])
|
|
||||||
raise subprocess.TimeoutExpired(command, kwargs["timeout"])
|
|
||||||
|
|
||||||
monkeypatch.setattr(resource_links.subprocess, "run", time_out)
|
|
||||||
|
|
||||||
with pytest.raises(OSError, match="Timed out creating Windows junction"):
|
|
||||||
resource_links._create_windows_junction(tmp_path / "alias", tmp_path / "target")
|
|
||||||
|
|
||||||
assert observed_timeouts == [resource_links._JUNCTION_TIMEOUT_SECONDS]
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_package_root_points_to_installed_nanobot_package(tmp_path: Path) -> None:
|
|
||||||
data_dir = tmp_path / "state"
|
|
||||||
agent_workspace = tmp_path / "agent"
|
|
||||||
agent_workspace.mkdir()
|
|
||||||
|
|
||||||
view = ensure_resource_view(
|
|
||||||
data_dir=data_dir,
|
|
||||||
config_path=data_dir / "config.json",
|
|
||||||
agent_workspace=agent_workspace,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert view.package is not None
|
|
||||||
assert view.package.resolve(strict=True) == Path(resource_links.__file__).parent.resolve(strict=True)
|
|
||||||
@@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import ModuleType, SimpleNamespace
|
from types import ModuleType, SimpleNamespace
|
||||||
|
|
||||||
import httpx
|
import httpx2 as httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import nanobot.agent.tools.mcp as mcp_mod
|
import nanobot.agent.tools.mcp as mcp_mod
|
||||||
@@ -52,7 +52,7 @@ class _FakeBlobResourceContents:
|
|||||||
class _FakeImageContent:
|
class _FakeImageContent:
|
||||||
def __init__(self, data: str, mime_type: str = "image/png") -> None:
|
def __init__(self, data: str, mime_type: str = "image/png") -> None:
|
||||||
self.data = data
|
self.data = data
|
||||||
self.mimeType = mime_type
|
self.mime_type = mime_type
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -111,7 +111,7 @@ def _fake_mcp_module(
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _fake_streamable_http_client(_url: str, http_client=None):
|
async def _fake_streamable_http_client(_url: str, http_client=None):
|
||||||
yield object(), object(), object()
|
yield object(), object()
|
||||||
|
|
||||||
mod.ClientSession = _FakeClientSession
|
mod.ClientSession = _FakeClientSession
|
||||||
mod.StdioServerParameters = _FakeStdioServerParameters
|
mod.StdioServerParameters = _FakeStdioServerParameters
|
||||||
@@ -133,12 +133,13 @@ def _fake_mcp_module(
|
|||||||
shared_mod = ModuleType("mcp.shared")
|
shared_mod = ModuleType("mcp.shared")
|
||||||
exc_mod = ModuleType("mcp.shared.exceptions")
|
exc_mod = ModuleType("mcp.shared.exceptions")
|
||||||
|
|
||||||
class _FakeMcpError(Exception):
|
class _FakeMCPError(Exception):
|
||||||
def __init__(self, code: int = -1, message: str = "error"):
|
def __init__(self, code: int = -1, message: str = "error"):
|
||||||
self.error = SimpleNamespace(code=code, message=message)
|
self.error = SimpleNamespace(code=code, message=message)
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
|
|
||||||
exc_mod.McpError = _FakeMcpError
|
mod.MCPError = _FakeMCPError
|
||||||
|
exc_mod.MCPError = _FakeMCPError
|
||||||
monkeypatch.setitem(sys.modules, "mcp.shared", shared_mod)
|
monkeypatch.setitem(sys.modules, "mcp.shared", shared_mod)
|
||||||
monkeypatch.setitem(sys.modules, "mcp.shared.exceptions", exc_mod)
|
monkeypatch.setitem(sys.modules, "mcp.shared.exceptions", exc_mod)
|
||||||
|
|
||||||
@@ -147,7 +148,7 @@ def _make_wrapper(session: object, *, timeout: float = 0.1) -> MCPToolWrapper:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="demo",
|
name="demo",
|
||||||
description="demo tool",
|
description="demo tool",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
return MCPToolWrapper(session, "test", tool_def, tool_timeout=timeout)
|
return MCPToolWrapper(session, "test", tool_def, tool_timeout=timeout)
|
||||||
|
|
||||||
@@ -185,7 +186,7 @@ def test_wrapper_preserves_non_nullable_unions() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="demo",
|
name="demo",
|
||||||
description="demo tool",
|
description="demo tool",
|
||||||
inputSchema={
|
input_schema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"value": {
|
"value": {
|
||||||
@@ -207,7 +208,7 @@ def test_wrapper_normalizes_nullable_property_type_union() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="demo",
|
name="demo",
|
||||||
description="demo tool",
|
description="demo tool",
|
||||||
inputSchema={
|
input_schema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {"type": ["string", "null"]},
|
"name": {"type": ["string", "null"]},
|
||||||
@@ -224,7 +225,7 @@ def test_wrapper_normalizes_nullable_property_anyof() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="demo",
|
name="demo",
|
||||||
description="demo tool",
|
description="demo tool",
|
||||||
inputSchema={
|
input_schema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -249,7 +250,7 @@ def test_wrapper_hoists_recursive_local_refs_into_defs() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="search_dataset",
|
name="search_dataset",
|
||||||
description="search tool",
|
description="search tool",
|
||||||
inputSchema={
|
input_schema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -282,7 +283,7 @@ def test_wrapper_hoists_root_self_ref_into_defs() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="tree",
|
name="tree",
|
||||||
description="tree tool",
|
description="tree tool",
|
||||||
inputSchema={
|
input_schema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"children": {"type": "array", "items": {"$ref": "#"}},
|
"children": {"type": "array", "items": {"$ref": "#"}},
|
||||||
@@ -304,7 +305,7 @@ def test_wrapper_preserves_existing_defs_refs() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="demo",
|
name="demo",
|
||||||
description="demo tool",
|
description="demo tool",
|
||||||
inputSchema={
|
input_schema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"$defs": {"value": {"type": "string"}},
|
"$defs": {"value": {"type": "string"}},
|
||||||
"properties": {"value": {"$ref": "#/$defs/value"}},
|
"properties": {"value": {"$ref": "#/$defs/value"}},
|
||||||
@@ -321,7 +322,7 @@ def test_wrapper_resolves_uri_encoded_json_pointer() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="demo",
|
name="demo",
|
||||||
description="demo tool",
|
description="demo tool",
|
||||||
inputSchema={
|
input_schema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"space name/value": {"type": "string"},
|
"space name/value": {"type": "string"},
|
||||||
@@ -449,7 +450,7 @@ async def test_execute_wraps_mcp_is_error_result() -> None:
|
|||||||
async def call_tool(_name: str, arguments: dict) -> object:
|
async def call_tool(_name: str, arguments: dict) -> object:
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
content=[_FakeTextContent("Error: server-side MCP failure")],
|
content=[_FakeTextContent("Error: server-side MCP failure")],
|
||||||
isError=True,
|
is_error=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
|
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
|
||||||
@@ -494,7 +495,7 @@ async def test_execute_preserves_success_text_that_starts_with_error() -> None:
|
|||||||
async def call_tool(_name: str, arguments: dict) -> object:
|
async def call_tool(_name: str, arguments: dict) -> object:
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
content=[_FakeTextContent("Error: generated report successfully")],
|
content=[_FakeTextContent("Error: generated report successfully")],
|
||||||
isError=False,
|
is_error=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
|
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
|
||||||
@@ -622,7 +623,7 @@ def _make_tool_def(name: str) -> SimpleNamespace:
|
|||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
name=name,
|
name=name,
|
||||||
description=f"{name} tool",
|
description=f"{name} tool",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -936,7 +937,7 @@ async def test_connect_mcp_servers_env_proxy_adds_proxy_mounts_and_keeps_pinned_
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _capturing_streamable_http_client(_url: str, http_client=None):
|
async def _capturing_streamable_http_client(_url: str, http_client=None):
|
||||||
assert http_client is not None
|
assert http_client is not None
|
||||||
yield object(), object(), object()
|
yield object(), object()
|
||||||
|
|
||||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
|
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
|
||||||
monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,::1")
|
monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,::1")
|
||||||
@@ -944,11 +945,11 @@ async def test_connect_mcp_servers_env_proxy_adds_proxy_mounts_and_keeps_pinned_
|
|||||||
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mcp_mod,
|
mcp_mod,
|
||||||
"PinnedDNSAsyncTransport",
|
"Httpx2PinnedDNSAsyncTransport",
|
||||||
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.security.network.httpx.AsyncHTTPTransport",
|
"nanobot.security.network.httpx2.AsyncHTTPTransport",
|
||||||
lambda **_kwargs: httpx.MockTransport(
|
lambda **_kwargs: httpx.MockTransport(
|
||||||
lambda request: httpx.Response(200, request=request)
|
lambda request: httpx.Response(200, request=request)
|
||||||
),
|
),
|
||||||
@@ -976,11 +977,11 @@ def test_mcp_http_clients_no_proxy_env_keeps_pinned_direct_route(monkeypatch):
|
|||||||
monkeypatch.setenv("NO_PROXY", "mcp.example.com")
|
monkeypatch.setenv("NO_PROXY", "mcp.example.com")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mcp_mod,
|
mcp_mod,
|
||||||
"PinnedDNSAsyncTransport",
|
"Httpx2PinnedDNSAsyncTransport",
|
||||||
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.security.network.httpx.AsyncHTTPTransport",
|
"nanobot.security.network.httpx2.AsyncHTTPTransport",
|
||||||
lambda **_kwargs: httpx.MockTransport(
|
lambda **_kwargs: httpx.MockTransport(
|
||||||
lambda request: httpx.Response(200, request=request)
|
lambda request: httpx.Response(200, request=request)
|
||||||
),
|
),
|
||||||
@@ -1050,13 +1051,15 @@ async def test_connect_mcp_servers_http_clients_reject_unsafe_redirect_targets(
|
|||||||
assert http_client is not None
|
assert http_client is not None
|
||||||
used_transports.append("streamableHttp")
|
used_transports.append("streamableHttp")
|
||||||
await http_client.get("https://example.com/start")
|
await http_client.get("https://example.com/start")
|
||||||
yield object(), object(), object()
|
yield object(), object()
|
||||||
|
|
||||||
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
|
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
|
||||||
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
||||||
|
# Keep the redirect exercise isolated from host-level proxy settings.
|
||||||
|
monkeypatch.setattr(mcp_mod, "httpx2_env_proxy_mounts", lambda: {})
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mcp_mod,
|
mcp_mod,
|
||||||
"PinnedDNSAsyncTransport",
|
"Httpx2PinnedDNSAsyncTransport",
|
||||||
lambda **_kwargs: httpx.MockTransport(_handler),
|
lambda **_kwargs: httpx.MockTransport(_handler),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(mcp_mod.httpx, "AsyncClient", _async_client_with_mock_transport)
|
monkeypatch.setattr(mcp_mod.httpx, "AsyncClient", _async_client_with_mock_transport)
|
||||||
@@ -1138,13 +1141,13 @@ async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _capturing_streamable_http_client(_url: str, http_client=None):
|
async def _capturing_streamable_http_client(_url: str, http_client=None):
|
||||||
captured["timeout"] = http_client.timeout
|
captured["timeout"] = http_client.timeout
|
||||||
yield object(), object(), object()
|
yield object(), object()
|
||||||
|
|
||||||
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
|
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
|
||||||
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mcp_mod,
|
mcp_mod,
|
||||||
"PinnedDNSAsyncTransport",
|
"Httpx2PinnedDNSAsyncTransport",
|
||||||
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -1385,10 +1388,10 @@ async def test_prompt_wrapper_execute_handles_timeout() -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_prompt_wrapper_execute_handles_mcp_error() -> None:
|
async def test_prompt_wrapper_execute_handles_mcp_error() -> None:
|
||||||
from mcp.shared.exceptions import McpError
|
from mcp import MCPError
|
||||||
|
|
||||||
async def get_prompt(name: str, arguments: dict | None = None) -> object:
|
async def get_prompt(name: str, arguments: dict | None = None) -> object:
|
||||||
raise McpError(code=42, message="invalid argument")
|
raise MCPError(code=42, message="invalid argument")
|
||||||
|
|
||||||
wrapper = _make_prompt_wrapper(SimpleNamespace(get_prompt=get_prompt))
|
wrapper = _make_prompt_wrapper(SimpleNamespace(get_prompt=get_prompt))
|
||||||
result = await wrapper.execute()
|
result = await wrapper.execute()
|
||||||
@@ -1510,7 +1513,7 @@ def test_tool_wrapper_sanitizes_name() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="My Tool",
|
name="My Tool",
|
||||||
description="tool with spaces",
|
description="tool with spaces",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "srv", tool_def)
|
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "srv", tool_def)
|
||||||
assert wrapper.name == "mcp_srv_My_Tool"
|
assert wrapper.name == "mcp_srv_My_Tool"
|
||||||
@@ -1541,7 +1544,7 @@ def test_tool_wrapper_preserves_original_name_for_mcp_call() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="My Tool",
|
name="My Tool",
|
||||||
description="tool with spaces",
|
description="tool with spaces",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "srv", tool_def)
|
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "srv", tool_def)
|
||||||
# The sanitized API-facing name differs from the original MCP name
|
# The sanitized API-facing name differs from the original MCP name
|
||||||
@@ -1619,12 +1622,12 @@ def test_long_server_name_tools_are_matched_by_server_name() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="search",
|
name="search",
|
||||||
description="search tool",
|
description="search tool",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
other_tool_def = SimpleNamespace(
|
other_tool_def = SimpleNamespace(
|
||||||
name="search",
|
name="search",
|
||||||
description="other search tool",
|
description="other search tool",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), server_name, tool_def)
|
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), server_name, tool_def)
|
||||||
other_wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "other", other_tool_def)
|
other_wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "other", other_tool_def)
|
||||||
|
|||||||
@@ -107,6 +107,20 @@ def test_segmented_transcript_paginates_latest_and_older_without_overlap(
|
|||||||
assert older["page"]["user_message_offset"] == 2
|
assert older["page"]["user_message_offset"] == 2
|
||||||
assert _message_contents(older) == _numbered_turn_texts(3, 4)
|
assert _message_contents(older) == _numbered_turn_texts(3, 4)
|
||||||
|
|
||||||
|
latest_again = build_webui_thread_response(key, limit=4, direction="latest")
|
||||||
|
full = build_webui_thread_response(key)
|
||||||
|
assert latest_again is not None
|
||||||
|
assert full is not None
|
||||||
|
assert [message["id"] for message in latest_again["messages"]] == [
|
||||||
|
message["id"] for message in latest["messages"]
|
||||||
|
]
|
||||||
|
full_ids_by_content = {
|
||||||
|
message["content"]: message["id"] for message in full["messages"]
|
||||||
|
}
|
||||||
|
assert [full_ids_by_content[message["content"]] for message in latest["messages"]] == [
|
||||||
|
message["id"] for message in latest["messages"]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_page_cursor_survives_active_rotation_after_latest_page(
|
def test_page_cursor_survives_active_rotation_after_latest_page(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
@@ -279,6 +293,21 @@ def test_write_session_messages_as_transcript_builds_canonical_prefix(
|
|||||||
assert [m["content"] for m in msgs] == ["round1", "answer1"]
|
assert [m["content"] for m in msgs] == ["round1", "answer1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_transcript_replay_generates_stable_message_ids() -> None:
|
||||||
|
lines = [
|
||||||
|
{"event": "user", "chat_id": "stable", "text": "question"},
|
||||||
|
{"event": "message", "chat_id": "stable", "text": "answer"},
|
||||||
|
{"event": "turn_end", "chat_id": "stable"},
|
||||||
|
]
|
||||||
|
|
||||||
|
first = replay_transcript_to_ui_messages(lines)
|
||||||
|
second = replay_transcript_to_ui_messages(lines)
|
||||||
|
|
||||||
|
assert [message["id"] for message in second] == [
|
||||||
|
message["id"] for message in first
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
|
def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
key = "websocket:t2"
|
key = "websocket:t2"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfi
|
|||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.registry import find_by_name
|
||||||
from nanobot.webui.settings_api import (
|
from nanobot.webui.settings_api import (
|
||||||
WebUISettingsError,
|
WebUISettingsError,
|
||||||
|
_clear_webui_oauth_flows,
|
||||||
_docs_version,
|
_docs_version,
|
||||||
_model_catalog_kind,
|
_model_catalog_kind,
|
||||||
_oauth_provider_status,
|
_oauth_provider_status,
|
||||||
@@ -1454,25 +1455,114 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
|||||||
)
|
)
|
||||||
monkeypatch.setenv("CODEX_PROXY_TEST", proxy)
|
monkeypatch.setenv("CODEX_PROXY_TEST", proxy)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
import oauth_cli_kit
|
class FakeFlow:
|
||||||
|
authorization_url = "https://auth.openai.com/oauth/authorize?state=test"
|
||||||
|
remaining_seconds = 600
|
||||||
|
expired = False
|
||||||
|
|
||||||
captured: dict[str, str | None] = {}
|
def cancel(self) -> None:
|
||||||
|
captured["cancelled"] = True
|
||||||
|
|
||||||
def fake_get_token(*, proxy=None):
|
def fake_start(*, proxy=None, timeout_s=None, open_browser=None):
|
||||||
captured["get_proxy"] = proxy
|
captured.update(
|
||||||
raise RuntimeError("no-token")
|
proxy=proxy,
|
||||||
|
timeout_s=timeout_s,
|
||||||
|
open_browser=open_browser,
|
||||||
|
)
|
||||||
|
return FakeFlow()
|
||||||
|
|
||||||
def fake_login(*, print_fn, prompt_fn, proxy=None):
|
monkeypatch.setattr(
|
||||||
captured["login_proxy"] = proxy
|
"nanobot.providers.openai_codex_oauth.start_openai_codex_oauth_login",
|
||||||
return SimpleNamespace(access="access-token", account_id="acct-test")
|
fake_start,
|
||||||
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(oauth_cli_kit, "get_token", fake_get_token)
|
payload = login_oauth_provider({"provider": ["openai-codex"]})
|
||||||
monkeypatch.setattr(oauth_cli_kit, "login_oauth_interactive", fake_login)
|
|
||||||
|
|
||||||
login_oauth_provider({"provider": ["openai-codex"]})
|
assert captured == {
|
||||||
|
"proxy": proxy,
|
||||||
|
"timeout_s": 600,
|
||||||
|
"open_browser": True,
|
||||||
|
}
|
||||||
|
assert payload["status"] == "authorization_required"
|
||||||
|
assert payload["provider"] == "openai_codex"
|
||||||
|
assert payload["authorization_url"] == FakeFlow.authorization_url
|
||||||
|
assert payload["completion_input"] == "callback_url"
|
||||||
|
|
||||||
assert captured == {"get_proxy": proxy, "login_proxy": proxy}
|
callbacks: list[str | None] = []
|
||||||
|
|
||||||
|
def fake_complete(_flow, callback):
|
||||||
|
callbacks.append(callback)
|
||||||
|
if callback is None:
|
||||||
|
return None
|
||||||
|
return SimpleNamespace(access="access-token")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.providers.openai_codex_oauth.complete_openai_codex_oauth_login",
|
||||||
|
fake_complete,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.settings_api.settings_payload",
|
||||||
|
lambda: {"settings": "ready"},
|
||||||
|
)
|
||||||
|
|
||||||
|
pending = complete_oauth_provider(
|
||||||
|
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
||||||
|
)
|
||||||
|
completed = complete_oauth_provider(
|
||||||
|
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
||||||
|
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert pending == {
|
||||||
|
"status": "pending",
|
||||||
|
"provider": "openai_codex",
|
||||||
|
"flow_id": payload["flow_id"],
|
||||||
|
}
|
||||||
|
assert completed == {"settings": "ready"}
|
||||||
|
assert callbacks == [
|
||||||
|
None,
|
||||||
|
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
save_config(Config(), config_path)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
class FakeFlow:
|
||||||
|
authorization_url = "https://auth.openai.com/oauth/authorize?state=test"
|
||||||
|
remaining_seconds = 600
|
||||||
|
expired = False
|
||||||
|
|
||||||
|
def cancel(self) -> None:
|
||||||
|
captured["cancelled"] = True
|
||||||
|
|
||||||
|
def fake_start(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return FakeFlow()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.providers.openai_codex_oauth.start_openai_codex_oauth_login",
|
||||||
|
fake_start,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = login_oauth_provider(
|
||||||
|
{"provider": ["openai-codex"], "remote_browser": ["true"]}
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_clear_webui_oauth_flows("openai_codex")
|
||||||
|
|
||||||
|
assert payload["completion_input"] == "callback_url"
|
||||||
|
assert captured["open_browser"] is False
|
||||||
|
assert captured["cancelled"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
||||||
@@ -1481,7 +1571,7 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
|||||||
real_import = builtins.__import__
|
real_import = builtins.__import__
|
||||||
|
|
||||||
def fake_import(name, *args, **kwargs):
|
def fake_import(name, *args, **kwargs):
|
||||||
if name == "oauth_cli_kit":
|
if name == "nanobot.providers.openai_codex_oauth":
|
||||||
raise ImportError("missing")
|
raise ImportError("missing")
|
||||||
return real_import(name, *args, **kwargs)
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
@@ -1542,6 +1632,7 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
|||||||
assert payload["status"] == "authorization_required"
|
assert payload["status"] == "authorization_required"
|
||||||
assert payload["provider"] == "xai_grok"
|
assert payload["provider"] == "xai_grok"
|
||||||
assert payload["authorization_url"] == FakeFlow.authorization_url
|
assert payload["authorization_url"] == FakeFlow.authorization_url
|
||||||
|
assert payload["completion_input"] == "authorization_code"
|
||||||
assert payload["flow_id"]
|
assert payload["flow_id"]
|
||||||
|
|
||||||
callbacks: list[str | None] = []
|
callbacks: list[str | None] = []
|
||||||
|
|||||||
@@ -27,15 +27,31 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("provider", "header_name", "authorization_response"),
|
||||||
|
[
|
||||||
|
("xai_grok", "X-Nanobot-OAuth-Code", "secret"),
|
||||||
|
(
|
||||||
|
"openai_codex",
|
||||||
|
"X-Nanobot-OAuth-Callback",
|
||||||
|
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_xai_oauth_completion_reads_code_from_private_header(monkeypatch) -> None:
|
async def test_oauth_completion_reads_private_response_header(
|
||||||
|
monkeypatch,
|
||||||
|
provider: str,
|
||||||
|
header_name: str,
|
||||||
|
authorization_response: str,
|
||||||
|
) -> None:
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
def complete(query, authorization_code=None):
|
def complete(query, authorization_response=None):
|
||||||
captured.update(query=query, authorization_code=authorization_code)
|
captured.update(query=query, authorization_response=authorization_response)
|
||||||
return {
|
return {
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"provider": "xai_grok",
|
"provider": provider,
|
||||||
"flow_id": "flow-123",
|
"flow_id": "flow-123",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,13 +60,13 @@ async def test_xai_oauth_completion_reads_code_from_private_header(monkeypatch)
|
|||||||
request = SimpleNamespace(
|
request = SimpleNamespace(
|
||||||
path=(
|
path=(
|
||||||
"/api/settings/provider/oauth-login/complete"
|
"/api/settings/provider/oauth-login/complete"
|
||||||
"?provider=xai_grok&flow_id=flow-123"
|
f"?provider={provider}&flow_id=flow-123"
|
||||||
),
|
),
|
||||||
headers=Headers(
|
headers=Headers(
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
"X-Nanobot-OAuth-Code",
|
header_name,
|
||||||
"secret",
|
authorization_response,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
@@ -66,14 +82,14 @@ async def test_xai_oauth_completion_reads_code_from_private_header(monkeypatch)
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert json.loads(response.body) == {
|
assert json.loads(response.body) == {
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"provider": "xai_grok",
|
"provider": provider,
|
||||||
"flow_id": "flow-123",
|
"flow_id": "flow-123",
|
||||||
}
|
}
|
||||||
assert captured == {
|
assert captured == {
|
||||||
"query": {"provider": ["xai_grok"], "flow_id": ["flow-123"]},
|
"query": {"provider": [provider], "flow_id": ["flow-123"]},
|
||||||
"authorization_code": "secret",
|
"authorization_response": authorization_response,
|
||||||
}
|
}
|
||||||
assert "secret" not in request.path
|
assert authorization_response not in request.path
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||
+42
-31
@@ -936,7 +936,7 @@ function Shell({
|
|||||||
onNativeEngineRestart: () => Promise<string>;
|
onNativeEngineRestart: () => Promise<string>;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { client, token } = useClient();
|
const { client, getToken } = useClient();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
const {
|
const {
|
||||||
sessions,
|
sessions,
|
||||||
@@ -981,13 +981,14 @@ function Shell({
|
|||||||
const [pairingRequests, setPairingRequests] = useState<PairingRequestInfo[]>([]);
|
const [pairingRequests, setPairingRequests] = useState<PairingRequestInfo[]>([]);
|
||||||
const [pairingBusyCode, setPairingBusyCode] = useState<string | null>(null);
|
const [pairingBusyCode, setPairingBusyCode] = useState<string | null>(null);
|
||||||
const [pairingError, setPairingError] = useState<string | null>(null);
|
const [pairingError, setPairingError] = useState<string | null>(null);
|
||||||
|
const pairingRefreshRef = useRef<Promise<number> | null>(null);
|
||||||
const [snoozedPairingCodes, setSnoozedPairingCodes] = useState<Map<string, number>>(
|
const [snoozedPairingCodes, setSnoozedPairingCodes] = useState<Map<string, number>>(
|
||||||
() => new Map(),
|
() => new Map(),
|
||||||
);
|
);
|
||||||
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
|
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
|
||||||
const [updatedChatIds, setUpdatedChatIds] = useState<Set<string>>(readSessionUpdateChatIds);
|
const [updatedChatIds, setUpdatedChatIds] = useState<Set<string>>(readSessionUpdateChatIds);
|
||||||
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
|
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
|
||||||
const skills = useSkills(token);
|
const skills = useSkills(getToken);
|
||||||
const pageVisible = usePageVisibility();
|
const pageVisible = usePageVisibility();
|
||||||
const [settingsSnapshot, setSettingsSnapshot] = useState<SettingsPayload | null>(null);
|
const [settingsSnapshot, setSettingsSnapshot] = useState<SettingsPayload | null>(null);
|
||||||
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
|
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
|
||||||
@@ -1030,7 +1031,7 @@ function Shell({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchSettings(token)
|
fetchSettings(getToken())
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!cancelled) setSettingsSnapshot(payload);
|
if (!cancelled) setSettingsSnapshot(payload);
|
||||||
})
|
})
|
||||||
@@ -1040,7 +1041,7 @@ function Shell({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [getToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -1057,29 +1058,39 @@ function Shell({
|
|||||||
writeSessionUpdateChatIds(updatedChatIds);
|
writeSessionUpdateChatIds(updatedChatIds);
|
||||||
}, [updatedChatIds]);
|
}, [updatedChatIds]);
|
||||||
|
|
||||||
const refreshPairingRequests = useCallback(async (): Promise<number> => {
|
const refreshPairingRequests = useCallback((): Promise<number> => {
|
||||||
try {
|
if (pairingRefreshRef.current) return pairingRefreshRef.current;
|
||||||
const payload = await fetchPairingRequests(token);
|
|
||||||
const requests = Array.isArray(payload.requests) ? payload.requests : [];
|
const request = (async () => {
|
||||||
setPairingRequests(requests);
|
try {
|
||||||
setSnoozedPairingCodes((current) => {
|
const payload = await fetchPairingRequests(getToken());
|
||||||
if (current.size === 0) return current;
|
const requests = Array.isArray(payload.requests) ? payload.requests : [];
|
||||||
const activeCodes = new Set(requests.map((request) => request.code));
|
setPairingRequests(requests);
|
||||||
const now = Date.now();
|
setSnoozedPairingCodes((current) => {
|
||||||
const next = new Map(
|
if (current.size === 0) return current;
|
||||||
Array.from(current).filter(
|
const activeCodes = new Set(requests.map((request) => request.code));
|
||||||
([code, snoozedUntil]) => activeCodes.has(code) && snoozedUntil > now,
|
const now = Date.now();
|
||||||
),
|
const next = new Map(
|
||||||
);
|
Array.from(current).filter(
|
||||||
return next.size === current.size ? current : next;
|
([code, snoozedUntil]) => activeCodes.has(code) && snoozedUntil > now,
|
||||||
});
|
),
|
||||||
return requests.length;
|
);
|
||||||
} catch {
|
return next.size === current.size ? current : next;
|
||||||
// Pairing is an opportunistic WebUI affordance. The slash command path
|
});
|
||||||
// remains available if this polling request fails.
|
return requests.length;
|
||||||
return 0;
|
} catch {
|
||||||
}
|
// Pairing is an opportunistic WebUI affordance. The slash command path
|
||||||
}, [token]);
|
// remains available if this polling request fails.
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const clearRequest = () => {
|
||||||
|
if (pairingRefreshRef.current === request) pairingRefreshRef.current = null;
|
||||||
|
};
|
||||||
|
pairingRefreshRef.current = request;
|
||||||
|
void request.then(clearRequest, clearRequest);
|
||||||
|
return request;
|
||||||
|
}, [getToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pageVisible) return undefined;
|
if (!pageVisible) return undefined;
|
||||||
@@ -1137,12 +1148,12 @@ function Shell({
|
|||||||
|
|
||||||
const refreshWorkspaces = useCallback(async () => {
|
const refreshWorkspaces = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const payload = await fetchWorkspaces(token);
|
const payload = await fetchWorkspaces(getToken());
|
||||||
setWorkspaces(payload);
|
setWorkspaces(payload);
|
||||||
} catch {
|
} catch {
|
||||||
setWorkspaces(null);
|
setWorkspaces(null);
|
||||||
}
|
}
|
||||||
}, [token]);
|
}, [getToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshWorkspaces();
|
void refreshWorkspaces();
|
||||||
@@ -1824,7 +1835,7 @@ function Shell({
|
|||||||
setPairingBusyCode(code);
|
setPairingBusyCode(code);
|
||||||
setPairingError(null);
|
setPairingError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await runPairingAction(token, action, code);
|
const payload = await runPairingAction(getToken(), action, code);
|
||||||
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||||
setSnoozedPairingCodes((current) => {
|
setSnoozedPairingCodes((current) => {
|
||||||
if (!current.has(code)) return current;
|
if (!current.has(code)) return current;
|
||||||
@@ -1839,7 +1850,7 @@ function Shell({
|
|||||||
setPairingBusyCode(null);
|
setPairingBusyCode(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[refreshPairingRequests, token],
|
[getToken, refreshPairingRequests],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onDismissPairingRequest = useCallback((code: string) => {
|
const onDismissPairingRequest = useCallback((code: string) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
|
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
|
||||||
import { AlertCircle, ChevronRight, Loader2, X } from "lucide-react";
|
import { AlertCircle, ChevronRight, Loader2, X } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -21,7 +21,7 @@ interface FilePreviewPanelProps {
|
|||||||
|
|
||||||
type PreviewState =
|
type PreviewState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { status: "error"; message: string }
|
| { status: "error"; error: unknown }
|
||||||
| { status: "ready"; payload: FilePreviewPayload };
|
| { status: "ready"; payload: FilePreviewPayload };
|
||||||
|
|
||||||
export function FilePreviewPanel({
|
export function FilePreviewPanel({
|
||||||
@@ -36,6 +36,8 @@ export function FilePreviewPanel({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<PreviewState>({ status: "loading" });
|
const [state, setState] = useState<PreviewState>({ status: "loading" });
|
||||||
const [entered, setEntered] = useState(false);
|
const [entered, setEntered] = useState(false);
|
||||||
|
const tokenRef = useRef(token);
|
||||||
|
tokenRef.current = token;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const frame = window.requestAnimationFrame(() => setEntered(true));
|
const frame = window.requestAnimationFrame(() => setEntered(true));
|
||||||
@@ -45,25 +47,17 @@ export function FilePreviewPanel({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setState({ status: "loading" });
|
setState({ status: "loading" });
|
||||||
fetchFilePreview(token, sessionKey, path)
|
fetchFilePreview(tokenRef.current, sessionKey, path)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!cancelled) setState({ status: "ready", payload });
|
if (!cancelled) setState({ status: "ready", payload });
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (cancelled) return;
|
if (!cancelled) setState({ status: "error", error });
|
||||||
const message = error instanceof ApiError
|
|
||||||
? (error.status === 404 && /API route not found/i.test(error.message)
|
|
||||||
? t("filePreview.routeMissing", {
|
|
||||||
defaultValue: "File preview needs the latest gateway. Restart nanobot gateway and try again.",
|
|
||||||
})
|
|
||||||
: error.message)
|
|
||||||
: t("filePreview.failed", { defaultValue: "Could not preview this file." });
|
|
||||||
setState({ status: "error", message });
|
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [path, sessionKey, t, token]);
|
}, [path, sessionKey]);
|
||||||
|
|
||||||
const displayPath = state.status === "ready" ? state.payload.display_path : path;
|
const displayPath = state.status === "ready" ? state.payload.display_path : path;
|
||||||
const previewPath = state.status === "ready" ? state.payload.path : displayPath;
|
const previewPath = state.status === "ready" ? state.payload.path : displayPath;
|
||||||
@@ -92,6 +86,15 @@ export function FilePreviewPanel({
|
|||||||
...directoryParts,
|
...directoryParts,
|
||||||
fileName,
|
fileName,
|
||||||
].join("/")}`;
|
].join("/")}`;
|
||||||
|
const errorMessage = state.status === "error"
|
||||||
|
? (state.error instanceof ApiError
|
||||||
|
? (state.error.status === 404 && /API route not found/i.test(state.error.message)
|
||||||
|
? t("filePreview.routeMissing", {
|
||||||
|
defaultValue: "File preview needs the latest gateway. Restart nanobot gateway and try again.",
|
||||||
|
})
|
||||||
|
: state.error.message)
|
||||||
|
: t("filePreview.failed", { defaultValue: "Could not preview this file." }))
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
@@ -222,7 +225,7 @@ export function FilePreviewPanel({
|
|||||||
className="mx-auto mb-3 h-5 w-5 text-muted-foreground/70"
|
className="mx-auto mb-3 h-5 w-5 text-muted-foreground/70"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<p>{state.message}</p>
|
<p>{errorMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -633,7 +633,7 @@ export function SettingsView({
|
|||||||
hostChromeInset = false,
|
hostChromeInset = false,
|
||||||
}: SettingsViewProps) {
|
}: SettingsViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { token } = useClient();
|
const { getToken, token } = useClient();
|
||||||
const pageVisible = usePageVisibility();
|
const pageVisible = usePageVisibility();
|
||||||
const remoteBrowserAccess =
|
const remoteBrowserAccess =
|
||||||
typeof window !== "undefined" && !isLoopbackHost(window.location.hostname);
|
typeof window !== "undefined" && !isLoopbackHost(window.location.hostname);
|
||||||
@@ -661,11 +661,12 @@ export function SettingsView({
|
|||||||
const [nanobotFeatureConfirm, setNanobotFeatureConfirm] = useState<NanobotFeatureInfo | null>(null);
|
const [nanobotFeatureConfirm, setNanobotFeatureConfirm] = useState<NanobotFeatureInfo | null>(null);
|
||||||
const [mcpPresetAction, setMcpPresetAction] = useState<string | null>(null);
|
const [mcpPresetAction, setMcpPresetAction] = useState<string | null>(null);
|
||||||
const [providerSaving, setProviderSaving] = useState<string | null>(null);
|
const [providerSaving, setProviderSaving] = useState<string | null>(null);
|
||||||
const [xaiOAuthFlow, setXaiOAuthFlow] =
|
const [providerOAuthFlow, setProviderOAuthFlow] =
|
||||||
useState<ProviderOAuthAuthorizationRequired | null>(null);
|
useState<ProviderOAuthAuthorizationRequired | null>(null);
|
||||||
const xaiOAuthFlowRef = useRef<ProviderOAuthAuthorizationRequired | null>(null);
|
const providerOAuthFlowRef = useRef<ProviderOAuthAuthorizationRequired | null>(null);
|
||||||
const [xaiOAuthCode, setXaiOAuthCode] = useState("");
|
const [providerOAuthResponse, setProviderOAuthResponse] = useState("");
|
||||||
const [xaiOAuthCompleting, setXaiOAuthCompleting] = useState(false);
|
const [providerOAuthCompleting, setProviderOAuthCompleting] = useState(false);
|
||||||
|
const [providerOAuthDialogError, setProviderOAuthDialogError] = useState<string | null>(null);
|
||||||
const [webSearchSaving, setWebSearchSaving] = useState(false);
|
const [webSearchSaving, setWebSearchSaving] = useState(false);
|
||||||
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
|
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
|
||||||
const [transcriptionSaving, setTranscriptionSaving] = useState(false);
|
const [transcriptionSaving, setTranscriptionSaving] = useState(false);
|
||||||
@@ -765,37 +766,44 @@ export function SettingsView({
|
|||||||
[onSettingsChange],
|
[onSettingsChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeXaiOAuthFlow = useCallback(() => {
|
const closeProviderOAuthFlow = useCallback(() => {
|
||||||
xaiOAuthFlowRef.current = null;
|
providerOAuthFlowRef.current = null;
|
||||||
setXaiOAuthFlow(null);
|
setProviderOAuthFlow(null);
|
||||||
setXaiOAuthCode("");
|
setProviderOAuthResponse("");
|
||||||
setXaiOAuthCompleting(false);
|
setProviderOAuthCompleting(false);
|
||||||
|
setProviderOAuthDialogError(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!xaiOAuthFlow) return;
|
if (!providerOAuthFlow) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let timer: number | null = null;
|
let timer: number | null = null;
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
const payload = await completeProviderOAuth(
|
const payload = await completeProviderOAuth(
|
||||||
token,
|
getToken(),
|
||||||
xaiOAuthFlow.provider,
|
providerOAuthFlow.provider,
|
||||||
xaiOAuthFlow.flow_id,
|
providerOAuthFlow.flow_id,
|
||||||
);
|
);
|
||||||
if (cancelled || xaiOAuthFlowRef.current?.flow_id !== xaiOAuthFlow.flow_id) return;
|
if (
|
||||||
|
cancelled
|
||||||
|
|| providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id
|
||||||
|
) return;
|
||||||
if (isProviderOAuthPending(payload)) {
|
if (isProviderOAuthPending(payload)) {
|
||||||
timer = window.setTimeout(() => void poll(), 1000);
|
timer = window.setTimeout(() => void poll(), 1000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
setExpandedProvider(xaiOAuthFlow.provider);
|
setExpandedProvider(providerOAuthFlow.provider);
|
||||||
setError(null);
|
setError(null);
|
||||||
closeXaiOAuthFlow();
|
closeProviderOAuthFlow();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled || xaiOAuthFlowRef.current?.flow_id !== xaiOAuthFlow.flow_id) return;
|
if (
|
||||||
|
cancelled
|
||||||
|
|| providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id
|
||||||
|
) return;
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
closeXaiOAuthFlow();
|
closeProviderOAuthFlow();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
timer = window.setTimeout(() => void poll(), 1000);
|
timer = window.setTimeout(() => void poll(), 1000);
|
||||||
@@ -803,7 +811,7 @@ export function SettingsView({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (timer !== null) window.clearTimeout(timer);
|
if (timer !== null) window.clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [applyPayload, closeXaiOAuthFlow, token, xaiOAuthFlow]);
|
}, [applyPayload, closeProviderOAuthFlow, getToken, providerOAuthFlow]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialSettings || settings !== null) return;
|
if (!initialSettings || settings !== null) return;
|
||||||
@@ -815,7 +823,7 @@ export function SettingsView({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const showLoading = settings === null;
|
const showLoading = settings === null;
|
||||||
if (showLoading) setLoading(true);
|
if (showLoading) setLoading(true);
|
||||||
fetchSettings(token)
|
fetchSettings(getToken())
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
@@ -831,30 +839,37 @@ export function SettingsView({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [applyPayload, token]);
|
}, [applyPayload, getToken]);
|
||||||
|
|
||||||
const hasSettings = settings !== null;
|
const hasSettings = settings !== null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSection !== "overview" || !hasSettings || !pageVisible) return;
|
if (activeSection !== "overview" || !hasSettings || !pageVisible) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const refresh = () => {
|
let refreshing = false;
|
||||||
fetchSettingsUsage(token)
|
const refresh = async () => {
|
||||||
.then((usage) => {
|
if (refreshing) return;
|
||||||
if (cancelled) return;
|
refreshing = true;
|
||||||
|
try {
|
||||||
|
const usage = await fetchSettingsUsage(getToken());
|
||||||
|
if (!cancelled) {
|
||||||
setSettings((current) => (current ? { ...current, usage } : current));
|
setSettings((current) => (current ? { ...current, usage } : current));
|
||||||
})
|
}
|
||||||
.catch(() => {});
|
} catch {
|
||||||
|
// Usage is best-effort telemetry; the settings snapshot remains usable.
|
||||||
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
void refresh();
|
void refresh();
|
||||||
const interval = window.setInterval(refresh, 5000);
|
const interval = window.setInterval(() => void refresh(), 5000);
|
||||||
const onFocus = () => refresh();
|
const onFocus = () => void refresh();
|
||||||
window.addEventListener("focus", onFocus);
|
window.addEventListener("focus", onFocus);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
window.removeEventListener("focus", onFocus);
|
window.removeEventListener("focus", onFocus);
|
||||||
};
|
};
|
||||||
}, [activeSection, hasSettings, pageVisible, token]);
|
}, [activeSection, getToken, hasSettings, pageVisible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSection !== "apps") return;
|
if (activeSection !== "apps") return;
|
||||||
@@ -863,7 +878,7 @@ export function SettingsView({
|
|||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
const loadCliApps = (showLoading: boolean) => {
|
const loadCliApps = (showLoading: boolean) => {
|
||||||
if (showLoading) setCliAppsLoading(true);
|
if (showLoading) setCliAppsLoading(true);
|
||||||
fetchCliApps(token)
|
fetchCliApps(getToken())
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (payload.catalog_refresh_pending && retryCount < CLI_APPS_REFRESH_MAX_RETRIES) {
|
if (payload.catalog_refresh_pending && retryCount < CLI_APPS_REFRESH_MAX_RETRIES) {
|
||||||
@@ -889,15 +904,23 @@ export function SettingsView({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (retry !== null) window.clearTimeout(retry);
|
if (retry !== null) window.clearTimeout(retry);
|
||||||
};
|
};
|
||||||
}, [activeSection, token]);
|
}, [activeSection, getToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!["channels", "models", "browser", "runtime"].includes(activeSection)) return;
|
if (
|
||||||
|
!pageVisible
|
||||||
|
|| !["channels", "models", "browser", "runtime"].includes(activeSection)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const refresh = async (showLoading = false) => {
|
let refreshing = false;
|
||||||
|
const refresh = async (showLoading = false): Promise<void> => {
|
||||||
|
if (refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
if (showLoading) setNanobotFeaturesLoading(true);
|
if (showLoading) setNanobotFeaturesLoading(true);
|
||||||
try {
|
try {
|
||||||
const payload = await fetchNanobotFeatures(token);
|
const payload = await fetchNanobotFeatures(getToken());
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setNanobotFeatures(payload);
|
setNanobotFeatures(payload);
|
||||||
setNanobotFeaturesError(null);
|
setNanobotFeaturesError(null);
|
||||||
@@ -906,6 +929,7 @@ export function SettingsView({
|
|||||||
const message = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
if (!cancelled && message !== "HTTP 404") setNanobotFeaturesError(message);
|
if (!cancelled && message !== "HTTP 404") setNanobotFeaturesError(message);
|
||||||
} finally {
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
if (!cancelled && showLoading) setNanobotFeaturesLoading(false);
|
if (!cancelled && showLoading) setNanobotFeaturesLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -926,13 +950,13 @@ export function SettingsView({
|
|||||||
window.removeEventListener("focus", refreshOnFocus);
|
window.removeEventListener("focus", refreshOnFocus);
|
||||||
document.removeEventListener("visibilitychange", refreshOnFocus);
|
document.removeEventListener("visibilitychange", refreshOnFocus);
|
||||||
};
|
};
|
||||||
}, [activeSection, token]);
|
}, [activeSection, getToken, pageVisible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSection !== "runtime") return;
|
if (activeSection !== "runtime") return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setApiServiceLoading(true);
|
setApiServiceLoading(true);
|
||||||
fetchApiService(token)
|
fetchApiService(getToken())
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setApiService(payload);
|
setApiService(payload);
|
||||||
@@ -948,13 +972,13 @@ export function SettingsView({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [activeSection, token]);
|
}, [activeSection, getToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSection !== "apps") return;
|
if (activeSection !== "apps") return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setMcpPresetsLoading(true);
|
setMcpPresetsLoading(true);
|
||||||
fetchMcpPresets(token)
|
fetchMcpPresets(getToken())
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setMcpPresets(payload);
|
setMcpPresets(payload);
|
||||||
@@ -970,13 +994,13 @@ export function SettingsView({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [activeSection, token]);
|
}, [activeSection, getToken]);
|
||||||
|
|
||||||
const refreshAutomations = useCallback(
|
const refreshAutomations = useCallback(
|
||||||
async (showLoading = false) => {
|
async (showLoading = false) => {
|
||||||
if (showLoading) setAutomationsLoading(true);
|
if (showLoading) setAutomationsLoading(true);
|
||||||
try {
|
try {
|
||||||
const payload = await fetchAutomations(token);
|
const payload = await fetchAutomations(getToken());
|
||||||
setAutomations(payload);
|
setAutomations(payload);
|
||||||
setAutomationsError(null);
|
setAutomationsError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -985,23 +1009,26 @@ export function SettingsView({
|
|||||||
if (showLoading) setAutomationsLoading(false);
|
if (showLoading) setAutomationsLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[token],
|
[getToken],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSection !== "automations" || !pageVisible) return;
|
if (activeSection !== "automations" || !pageVisible) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let refreshing = false;
|
||||||
const refresh = async (showLoading = false) => {
|
const refresh = async (showLoading = false) => {
|
||||||
if (cancelled) return;
|
if (cancelled || refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
if (showLoading) setAutomationsLoading(true);
|
if (showLoading) setAutomationsLoading(true);
|
||||||
try {
|
try {
|
||||||
const payload = await fetchAutomations(token);
|
const payload = await fetchAutomations(getToken());
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setAutomations(payload);
|
setAutomations(payload);
|
||||||
setAutomationsError(null);
|
setAutomationsError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) setAutomationsError((err as Error).message);
|
if (!cancelled) setAutomationsError((err as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
if (!cancelled && showLoading) setAutomationsLoading(false);
|
if (!cancelled && showLoading) setAutomationsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1014,7 +1041,7 @@ export function SettingsView({
|
|||||||
window.clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
window.removeEventListener("focus", refreshOnFocus);
|
window.removeEventListener("focus", refreshOnFocus);
|
||||||
};
|
};
|
||||||
}, [activeSection, pageVisible, token]);
|
}, [activeSection, getToken, pageVisible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeLocalPreferences(localPrefs);
|
writeLocalPreferences(localPrefs);
|
||||||
@@ -1593,7 +1620,11 @@ export function SettingsView({
|
|||||||
const runProviderOAuth = async (providerName: string, action: "login" | "logout") => {
|
const runProviderOAuth = async (providerName: string, action: "login" | "logout") => {
|
||||||
if (providerSaving) return;
|
if (providerSaving) return;
|
||||||
let popup: Window | null = null;
|
let popup: Window | null = null;
|
||||||
if (action === "login" && providerName === "xai_grok" && !remoteBrowserAccess) {
|
if (
|
||||||
|
action === "login"
|
||||||
|
&& providerName === "xai_grok"
|
||||||
|
&& !remoteBrowserAccess
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
popup = window.open("about:blank", "_blank");
|
popup = window.open("about:blank", "_blank");
|
||||||
if (popup) popup.opener = null;
|
if (popup) popup.opener = null;
|
||||||
@@ -1605,7 +1636,12 @@ export function SettingsView({
|
|||||||
try {
|
try {
|
||||||
const payload =
|
const payload =
|
||||||
action === "login"
|
action === "login"
|
||||||
? await loginProviderOAuth(token, providerName)
|
? await loginProviderOAuth(
|
||||||
|
token,
|
||||||
|
providerName,
|
||||||
|
"",
|
||||||
|
providerName === "openai_codex" && remoteBrowserAccess,
|
||||||
|
)
|
||||||
: await logoutProviderOAuth(token, providerName);
|
: await logoutProviderOAuth(token, providerName);
|
||||||
if (isProviderOAuthAuthorizationRequired(payload)) {
|
if (isProviderOAuthAuthorizationRequired(payload)) {
|
||||||
try {
|
try {
|
||||||
@@ -1613,15 +1649,16 @@ export function SettingsView({
|
|||||||
} catch {
|
} catch {
|
||||||
// The dialog keeps the authorization link available when the popup was closed.
|
// The dialog keeps the authorization link available when the popup was closed.
|
||||||
}
|
}
|
||||||
xaiOAuthFlowRef.current = payload;
|
providerOAuthFlowRef.current = payload;
|
||||||
setXaiOAuthFlow(payload);
|
setProviderOAuthFlow(payload);
|
||||||
setXaiOAuthCode("");
|
setProviderOAuthResponse("");
|
||||||
|
setProviderOAuthDialogError(null);
|
||||||
setExpandedProvider(providerName);
|
setExpandedProvider(providerName);
|
||||||
setError(null);
|
setError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
popup?.close();
|
popup?.close();
|
||||||
closeXaiOAuthFlow();
|
closeProviderOAuthFlow();
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
setExpandedProvider(providerName);
|
setExpandedProvider(providerName);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -1633,31 +1670,31 @@ export function SettingsView({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeXaiOAuth = async () => {
|
const completeProviderOAuthResponse = async () => {
|
||||||
const flow = xaiOAuthFlowRef.current;
|
const flow = providerOAuthFlowRef.current;
|
||||||
const authorizationCode = xaiOAuthCode.trim();
|
const authorizationResponse = providerOAuthResponse.trim();
|
||||||
if (!flow || !authorizationCode || xaiOAuthCompleting) return;
|
if (!flow || !authorizationResponse || providerOAuthCompleting) return;
|
||||||
setXaiOAuthCompleting(true);
|
setProviderOAuthCompleting(true);
|
||||||
|
setProviderOAuthDialogError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await completeProviderOAuth(
|
const payload = await completeProviderOAuth(
|
||||||
token,
|
token,
|
||||||
flow.provider,
|
flow.provider,
|
||||||
flow.flow_id,
|
flow.flow_id,
|
||||||
authorizationCode,
|
authorizationResponse,
|
||||||
);
|
);
|
||||||
if (xaiOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
|
if (providerOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
|
||||||
if (isProviderOAuthPending(payload)) return;
|
if (isProviderOAuthPending(payload)) return;
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
setExpandedProvider(flow.provider);
|
setExpandedProvider(flow.provider);
|
||||||
setError(null);
|
setError(null);
|
||||||
closeXaiOAuthFlow();
|
closeProviderOAuthFlow();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (xaiOAuthFlowRef.current?.flow_id === flow.flow_id) {
|
if (providerOAuthFlowRef.current?.flow_id === flow.flow_id) {
|
||||||
setError((err as Error).message);
|
setProviderOAuthDialogError((err as Error).message);
|
||||||
closeXaiOAuthFlow();
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setXaiOAuthCompleting(false);
|
setProviderOAuthCompleting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2298,19 +2335,33 @@ export function SettingsView({
|
|||||||
onConfirm={handleDeleteModelConfiguration}
|
onConfirm={handleDeleteModelConfiguration}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<XaiOAuthLoginDialog
|
<ProviderOAuthLoginDialog
|
||||||
flow={xaiOAuthFlow}
|
flow={providerOAuthFlow}
|
||||||
authorizationCode={xaiOAuthCode}
|
providerLabel={
|
||||||
completing={xaiOAuthCompleting}
|
providerOAuthFlow
|
||||||
|
? settings?.providers.find((provider) => provider.name === providerOAuthFlow.provider)
|
||||||
|
?.label ?? providerOAuthFlow.provider
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
authorizationResponse={providerOAuthResponse}
|
||||||
|
completing={providerOAuthCompleting}
|
||||||
|
error={providerOAuthDialogError}
|
||||||
remoteBrowserAccess={remoteBrowserAccess}
|
remoteBrowserAccess={remoteBrowserAccess}
|
||||||
onAuthorizationCodeChange={setXaiOAuthCode}
|
onAuthorizationResponseChange={(value) => {
|
||||||
|
setProviderOAuthResponse(value);
|
||||||
|
setProviderOAuthDialogError(null);
|
||||||
|
}}
|
||||||
onOpenAuthorization={() => {
|
onOpenAuthorization={() => {
|
||||||
if (!xaiOAuthFlow) return;
|
if (!providerOAuthFlow) return;
|
||||||
const opened = window.open(xaiOAuthFlow.authorization_url, "_blank", "noopener,noreferrer");
|
const opened = window.open(
|
||||||
|
providerOAuthFlow.authorization_url,
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer",
|
||||||
|
);
|
||||||
if (opened) opened.opener = null;
|
if (opened) opened.opener = null;
|
||||||
}}
|
}}
|
||||||
onComplete={() => void completeXaiOAuth()}
|
onComplete={() => void completeProviderOAuthResponse()}
|
||||||
onClose={closeXaiOAuthFlow}
|
onClose={closeProviderOAuthFlow}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NanobotFeatureInstallDialog
|
<NanobotFeatureInstallDialog
|
||||||
@@ -2961,26 +3012,35 @@ function AppearanceSettings({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function XaiOAuthLoginDialog({
|
function ProviderOAuthLoginDialog({
|
||||||
flow,
|
flow,
|
||||||
authorizationCode,
|
providerLabel,
|
||||||
|
authorizationResponse,
|
||||||
completing,
|
completing,
|
||||||
|
error,
|
||||||
remoteBrowserAccess,
|
remoteBrowserAccess,
|
||||||
onAuthorizationCodeChange,
|
onAuthorizationResponseChange,
|
||||||
onOpenAuthorization,
|
onOpenAuthorization,
|
||||||
onComplete,
|
onComplete,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
flow: ProviderOAuthAuthorizationRequired | null;
|
flow: ProviderOAuthAuthorizationRequired | null;
|
||||||
authorizationCode: string;
|
providerLabel: string;
|
||||||
|
authorizationResponse: string;
|
||||||
completing: boolean;
|
completing: boolean;
|
||||||
|
error: string | null;
|
||||||
remoteBrowserAccess: boolean;
|
remoteBrowserAccess: boolean;
|
||||||
onAuthorizationCodeChange: (value: string) => void;
|
onAuthorizationResponseChange: (value: string) => void;
|
||||||
onOpenAuthorization: () => void;
|
onOpenAuthorization: () => void;
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const expectsCallbackUrl = flow?.completion_input === "callback_url";
|
||||||
|
const inputId = expectsCallbackUrl ? "provider-oauth-callback" : "provider-oauth-code";
|
||||||
|
const inputLabel = expectsCallbackUrl
|
||||||
|
? t("settings.oauth.callbackUrl")
|
||||||
|
: t("settings.oauth.authorizationCode");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -2998,36 +3058,75 @@ function XaiOAuthLoginDialog({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>xAI Grok</DialogTitle>
|
<DialogTitle>{providerLabel}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{remoteBrowserAccess
|
{expectsCallbackUrl
|
||||||
? t("settings.oauth.remoteCodeHelp")
|
? remoteBrowserAccess
|
||||||
: t("settings.oauth.localCodeHelp")}
|
? t("settings.oauth.remoteCallbackHelp")
|
||||||
|
: t("settings.oauth.localCallbackHelp")
|
||||||
|
: remoteBrowserAccess
|
||||||
|
? t("settings.oauth.remoteCodeHelp")
|
||||||
|
: t("settings.oauth.localCodeHelp")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
<div className="flex items-center gap-2 rounded-[14px] border border-border/45 bg-muted/35 px-3 py-2.5 text-[12px] text-muted-foreground">
|
||||||
|
{expectsCallbackUrl && remoteBrowserAccess ? (
|
||||||
|
<Clipboard className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{expectsCallbackUrl && remoteBrowserAccess
|
||||||
|
? t("settings.oauth.pasteCallbackToContinue")
|
||||||
|
: t("settings.oauth.waitingForCallback")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="xai-oauth-code"
|
htmlFor={inputId}
|
||||||
className="block text-xs font-medium text-foreground"
|
className="block text-xs font-medium text-foreground"
|
||||||
>
|
>
|
||||||
{t("settings.oauth.authorizationCode")}
|
{inputLabel}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
{expectsCallbackUrl ? (
|
||||||
id="xai-oauth-code"
|
<Textarea
|
||||||
value={authorizationCode}
|
id={inputId}
|
||||||
onChange={(event) => onAuthorizationCodeChange(event.target.value)}
|
value={authorizationResponse}
|
||||||
placeholder={t("settings.oauth.authorizationCode")}
|
onChange={(event) => onAuthorizationResponseChange(event.target.value)}
|
||||||
aria-label={t("settings.oauth.authorizationCode")}
|
placeholder={t("settings.oauth.callbackUrlPlaceholder")}
|
||||||
autoComplete="off"
|
aria-label={inputLabel}
|
||||||
spellCheck={false}
|
autoComplete="off"
|
||||||
/>
|
spellCheck={false}
|
||||||
|
className="min-h-[88px] resize-none break-all font-mono text-[12px] leading-5"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={inputId}
|
||||||
|
value={authorizationResponse}
|
||||||
|
onChange={(event) => onAuthorizationResponseChange(event.target.value)}
|
||||||
|
placeholder={inputLabel}
|
||||||
|
aria-label={inputLabel}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="rounded-[14px] border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-[12px] text-destructive"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<DialogFooter className="gap-2 sm:space-x-0">
|
<DialogFooter className="gap-2 sm:space-x-0">
|
||||||
<Button type="button" variant="outline" onClick={onOpenAuthorization}>
|
<Button type="button" variant="outline" onClick={onOpenAuthorization}>
|
||||||
<ExternalLink className="mr-2 h-4 w-4" aria-hidden />
|
<ExternalLink className="mr-2 h-4 w-4" aria-hidden />
|
||||||
{t("settings.oauth.signIn")}
|
{expectsCallbackUrl
|
||||||
|
? t("settings.oauth.openChatGPT")
|
||||||
|
: t("settings.oauth.signIn")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={!authorizationCode.trim() || completing}>
|
<Button type="submit" disabled={!authorizationResponse.trim() || completing}>
|
||||||
{completing ? t("settings.oauth.signingIn") : t("settings.oauth.finishSignIn")}
|
{completing ? t("settings.oauth.signingIn") : t("settings.oauth.finishSignIn")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -4236,7 +4335,12 @@ function ProvidersSettings({
|
|||||||
account: provider.oauth_account || provider.label,
|
account: provider.oauth_account || provider.label,
|
||||||
defaultValue: "Signed in as {{account}}",
|
defaultValue: "Signed in as {{account}}",
|
||||||
})
|
})
|
||||||
: provider.name === "xai_grok" && remoteBrowserAccess
|
: provider.name === "openai_codex" && remoteBrowserAccess
|
||||||
|
? tx(
|
||||||
|
"settings.oauth.codexRemoteSignInHelp",
|
||||||
|
"Sign in through this browser, then paste the full localhost callback URL back into nanobot.",
|
||||||
|
)
|
||||||
|
: provider.name === "xai_grok" && remoteBrowserAccess
|
||||||
? tx(
|
? tx(
|
||||||
"settings.oauth.remoteSignInHelp",
|
"settings.oauth.remoteSignInHelp",
|
||||||
"Select Sign in to open xAI on your computer, then paste the authorization code shown after login.",
|
"Select Sign in to open xAI on your computer, then paste the authorization code shown after login.",
|
||||||
@@ -8899,6 +9003,8 @@ function ModelIdPicker({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
|
const tokenRef = useRef(token);
|
||||||
|
tokenRef.current = token;
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [payload, setPayload] = useState<ProviderModelsPayload | null>(null);
|
const [payload, setPayload] = useState<ProviderModelsPayload | null>(null);
|
||||||
@@ -8967,7 +9073,7 @@ function ModelIdPicker({
|
|||||||
setPayload(null);
|
setPayload(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetchProviderModels(token, effectiveProvider)
|
fetchProviderModels(tokenRef.current, effectiveProvider)
|
||||||
.then((nextPayload) => {
|
.then((nextPayload) => {
|
||||||
if (!cancelled) setPayload(nextPayload);
|
if (!cancelled) setPayload(nextPayload);
|
||||||
})
|
})
|
||||||
@@ -8980,7 +9086,7 @@ function ModelIdPicker({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [effectiveProvider, open, shouldFetchModels, token]);
|
}, [effectiveProvider, open, shouldFetchModels]);
|
||||||
|
|
||||||
const selectModel = (model: string) => {
|
const selectModel = (model: string) => {
|
||||||
onChange(model);
|
onChange(model);
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ function SkillDetailSheet({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { token } = useClient();
|
const { getToken } = useClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [detail, setDetail] = useState<SkillDetail | null>(null);
|
const [detail, setDetail] = useState<SkillDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -322,7 +322,7 @@ function SkillDetailSheet({
|
|||||||
setActionError("");
|
setActionError("");
|
||||||
setDeleteOpen(false);
|
setDeleteOpen(false);
|
||||||
setDescriptionExpanded(false);
|
setDescriptionExpanded(false);
|
||||||
fetchSkillDetail(token, skill.name)
|
fetchSkillDetail(getToken(), skill.name)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!cancelled) setDetail(payload);
|
if (!cancelled) setDetail(payload);
|
||||||
})
|
})
|
||||||
@@ -335,7 +335,7 @@ function SkillDetailSheet({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, refreshKey, skill, token]);
|
}, [getToken, open, refreshKey, skill]);
|
||||||
|
|
||||||
if (!skill) return null;
|
if (!skill) return null;
|
||||||
|
|
||||||
@@ -354,7 +354,7 @@ function SkillDetailSheet({
|
|||||||
setActionBusy(true);
|
setActionBusy(true);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const payload = await updateSkillEnabled(token, activeSkill.name, !enabled);
|
const payload = await updateSkillEnabled(getToken(), activeSkill.name, !enabled);
|
||||||
notifySkillsChanged(payload);
|
notifySkillsChanged(payload);
|
||||||
const updated = payload.skills.find((item) => item.name === activeSkill.name);
|
const updated = payload.skills.find((item) => item.name === activeSkill.name);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
@@ -378,7 +378,7 @@ function SkillDetailSheet({
|
|||||||
setActionBusy(true);
|
setActionBusy(true);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const payload = await deleteSkill(token, activeSkill.name);
|
const payload = await deleteSkill(getToken(), activeSkill.name);
|
||||||
notifySkillsChanged(payload);
|
notifySkillsChanged(payload);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function SkillsMarketplace({
|
|||||||
installing: string;
|
installing: string;
|
||||||
onInstallingChange: (skillId: string) => void;
|
onInstallingChange: (skillId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { token } = useClient();
|
const { getToken } = useClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
|
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
|
||||||
@@ -78,7 +78,7 @@ export function SkillsMarketplace({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setTrendingLoading(true);
|
setTrendingLoading(true);
|
||||||
fetchTrendingMarketplaceSkills(token)
|
fetchTrendingMarketplaceSkills(getToken())
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setTrending(payload.skills);
|
setTrending(payload.skills);
|
||||||
@@ -92,7 +92,7 @@ export function SkillsMarketplace({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [getToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const skills = query.trim().length < 2 ? trending : results;
|
const skills = query.trim().length < 2 ? trending : results;
|
||||||
@@ -102,7 +102,7 @@ export function SkillsMarketplace({
|
|||||||
if (!unresolved.length) return;
|
if (!unresolved.length) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchMarketplaceSkillTrends(token, unresolved.map((skill) => skill.id))
|
fetchMarketplaceSkillTrends(getToken(), unresolved.map((skill) => skill.id))
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setTrends((current) => ({ ...current, ...payload.trends }));
|
setTrends((current) => ({ ...current, ...payload.trends }));
|
||||||
@@ -112,7 +112,7 @@ export function SkillsMarketplace({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [query, results, token, trending, trends]);
|
}, [getToken, query, results, trending, trends]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalized = query.trim();
|
const normalized = query.trim();
|
||||||
@@ -127,7 +127,7 @@ export function SkillsMarketplace({
|
|||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
searchMarketplaceSkills(token, normalized)
|
searchMarketplaceSkills(getToken(), normalized)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setResults(payload.skills);
|
setResults(payload.skills);
|
||||||
@@ -152,7 +152,7 @@ export function SkillsMarketplace({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [query, t, token]);
|
}, [getToken, query, t]);
|
||||||
|
|
||||||
const install = async (skill: MarketplaceSkillSummary) => {
|
const install = async (skill: MarketplaceSkillSummary) => {
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
@@ -160,7 +160,7 @@ export function SkillsMarketplace({
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const payload = await installMarketplaceSkill(
|
const payload = await installMarketplaceSkill(
|
||||||
token,
|
getToken(),
|
||||||
skill.provider,
|
skill.provider,
|
||||||
skill.source,
|
skill.source,
|
||||||
skill.skill_id,
|
skill.skill_id,
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export function ChannelQrConnectFlow({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [handledRequestId, setHandledRequestId] = useState(0);
|
const [handledRequestId, setHandledRequestId] = useState(0);
|
||||||
const pollInFlight = useRef(false);
|
const pollInFlight = useRef(false);
|
||||||
|
const tokenRef = useRef(token);
|
||||||
|
tokenRef.current = token;
|
||||||
const startDomain = startOptions.domain;
|
const startDomain = startOptions.domain;
|
||||||
const startInstanceId = startOptions.instanceId;
|
const startInstanceId = startOptions.instanceId;
|
||||||
const startMode = startOptions.mode;
|
const startMode = startOptions.mode;
|
||||||
@@ -100,7 +102,11 @@ export function ChannelQrConnectFlow({
|
|||||||
if (pollInFlight.current) return;
|
if (pollInFlight.current) return;
|
||||||
pollInFlight.current = true;
|
pollInFlight.current = true;
|
||||||
try {
|
try {
|
||||||
const payload = await pollChannelConnect(token, channelName, connect.session_id);
|
const payload = await pollChannelConnect(
|
||||||
|
tokenRef.current,
|
||||||
|
channelName,
|
||||||
|
connect.session_id,
|
||||||
|
);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setConnect((current) => ({
|
setConnect((current) => ({
|
||||||
...(current ?? payload),
|
...(current ?? payload),
|
||||||
@@ -129,13 +135,20 @@ export function ChannelQrConnectFlow({
|
|||||||
window.clearTimeout(initial);
|
window.clearTimeout(initial);
|
||||||
window.clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, [channelName, connect?.interval_ms, connect?.session_id, connect?.status, onFeaturesUpdate, pageVisible, token]);
|
}, [
|
||||||
|
channelName,
|
||||||
|
connect?.interval_ms,
|
||||||
|
connect?.session_id,
|
||||||
|
connect?.status,
|
||||||
|
onFeaturesUpdate,
|
||||||
|
pageVisible,
|
||||||
|
]);
|
||||||
|
|
||||||
const start = useCallback(async (force = false) => {
|
const start = useCallback(async (force = false) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await startChannelConnect(token, channelName, {
|
const payload = await startChannelConnect(tokenRef.current, channelName, {
|
||||||
domain: startDomain,
|
domain: startDomain,
|
||||||
instanceId: startInstanceId,
|
instanceId: startInstanceId,
|
||||||
mode: startMode,
|
mode: startMode,
|
||||||
@@ -147,7 +160,7 @@ export function ChannelQrConnectFlow({
|
|||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}, [channelName, startDomain, startForce, startInstanceId, startMode, token]);
|
}, [channelName, startDomain, startForce, startInstanceId, startMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connectRequestId || connectRequestId === handledRequestId) return;
|
if (!connectRequestId || connectRequestId === handledRequestId) return;
|
||||||
@@ -162,7 +175,11 @@ export function ChannelQrConnectFlow({
|
|||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const payload = await cancelChannelConnect(token, channelName, connect.session_id);
|
const payload = await cancelChannelConnect(
|
||||||
|
tokenRef.current,
|
||||||
|
channelName,
|
||||||
|
connect.session_id,
|
||||||
|
);
|
||||||
setConnect(payload);
|
setConnect(payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
|
|||||||
@@ -496,7 +496,7 @@ interface PendingFirstMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface InstalledSettingItemsOptions<Payload, Item> {
|
interface InstalledSettingItemsOptions<Payload, Item> {
|
||||||
token: string;
|
getToken: () => string;
|
||||||
eventName: string;
|
eventName: string;
|
||||||
fetchPayload: (token: string) => Promise<Payload>;
|
fetchPayload: (token: string) => Promise<Payload>;
|
||||||
isPayload: (value: unknown) => value is Payload;
|
isPayload: (value: unknown) => value is Payload;
|
||||||
@@ -504,7 +504,7 @@ interface InstalledSettingItemsOptions<Payload, Item> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useInstalledSettingItems<Payload, Item>({
|
function useInstalledSettingItems<Payload, Item>({
|
||||||
token,
|
getToken,
|
||||||
eventName,
|
eventName,
|
||||||
fetchPayload,
|
fetchPayload,
|
||||||
isPayload,
|
isPayload,
|
||||||
@@ -512,42 +512,65 @@ function useInstalledSettingItems<Payload, Item>({
|
|||||||
}: InstalledSettingItemsOptions<Payload, Item>): Item[] {
|
}: InstalledSettingItemsOptions<Payload, Item>): Item[] {
|
||||||
const [items, setItems] = useState<Item[]>([]);
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
|
|
||||||
const refresh = useCallback(async (isCancelled?: () => boolean) => {
|
|
||||||
try {
|
|
||||||
const payload = await fetchPayload(token);
|
|
||||||
if (!isCancelled?.()) setItems(selectItems(payload));
|
|
||||||
} catch {
|
|
||||||
// Keep the last successful catalog during transient focus/visibility refresh failures.
|
|
||||||
}
|
|
||||||
}, [fetchPayload, selectItems, token]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void refresh(() => cancelled);
|
let refreshQueued = false;
|
||||||
|
let refreshAfterFlight = false;
|
||||||
const refreshOnFocus = () => {
|
let refreshing = false;
|
||||||
if (document.visibilityState === "hidden") return;
|
let payloadVersion = 0;
|
||||||
void refresh();
|
const refresh = async (): Promise<void> => {
|
||||||
|
if (refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
|
const version = payloadVersion;
|
||||||
|
try {
|
||||||
|
const payload = await fetchPayload(getToken());
|
||||||
|
if (!cancelled && version === payloadVersion) {
|
||||||
|
setItems(selectItems(payload));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep the last successful catalog during transient refresh failures.
|
||||||
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
|
if (refreshAfterFlight && !cancelled) {
|
||||||
|
refreshAfterFlight = false;
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
const queueRefresh = () => {
|
||||||
|
if (document.visibilityState === "hidden" || refreshQueued) return;
|
||||||
|
refreshQueued = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
refreshQueued = false;
|
||||||
|
if (!cancelled) void refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
void refresh();
|
||||||
|
|
||||||
const refreshOnChanged = (event: Event) => {
|
const refreshOnChanged = (event: Event) => {
|
||||||
const payload = (event as CustomEvent<unknown>).detail;
|
const payload = (event as CustomEvent<unknown>).detail;
|
||||||
if (isPayload(payload)) {
|
if (isPayload(payload)) {
|
||||||
|
payloadVersion += 1;
|
||||||
setItems(selectItems(payload));
|
setItems(selectItems(payload));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refresh();
|
if (refreshing) {
|
||||||
|
refreshAfterFlight = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queueRefresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("focus", refreshOnFocus);
|
window.addEventListener("focus", queueRefresh);
|
||||||
document.addEventListener("visibilitychange", refreshOnFocus);
|
document.addEventListener("visibilitychange", queueRefresh);
|
||||||
window.addEventListener(eventName, refreshOnChanged);
|
window.addEventListener(eventName, refreshOnChanged);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.removeEventListener("focus", refreshOnFocus);
|
window.removeEventListener("focus", queueRefresh);
|
||||||
document.removeEventListener("visibilitychange", refreshOnFocus);
|
document.removeEventListener("visibilitychange", queueRefresh);
|
||||||
window.removeEventListener(eventName, refreshOnChanged);
|
window.removeEventListener(eventName, refreshOnChanged);
|
||||||
};
|
};
|
||||||
}, [eventName, isPayload, refresh, selectItems]);
|
}, [eventName, fetchPayload, getToken, isPayload, selectItems]);
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
@@ -581,6 +604,7 @@ export function ThreadShell({
|
|||||||
const {
|
const {
|
||||||
messages: historical,
|
messages: historical,
|
||||||
loading,
|
loading,
|
||||||
|
error: historyError,
|
||||||
loadingOlder,
|
loadingOlder,
|
||||||
loadOlder,
|
loadOlder,
|
||||||
hasMoreBefore,
|
hasMoreBefore,
|
||||||
@@ -594,19 +618,19 @@ export function ThreadShell({
|
|||||||
version: historyVersion,
|
version: historyVersion,
|
||||||
forkBoundaryMessageCount,
|
forkBoundaryMessageCount,
|
||||||
} = useSessionHistory(historyKey);
|
} = useSessionHistory(historyKey);
|
||||||
const { client, ingressLimits, modelName, token } = useClient();
|
const { client, getToken, ingressLimits, modelName, token } = useClient();
|
||||||
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
||||||
const [booting, setBooting] = useState(false);
|
const [booting, setBooting] = useState(false);
|
||||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||||
const cliApps = useInstalledSettingItems({
|
const cliApps = useInstalledSettingItems({
|
||||||
token,
|
getToken,
|
||||||
eventName: CLI_APPS_CHANGED_EVENT,
|
eventName: CLI_APPS_CHANGED_EVENT,
|
||||||
fetchPayload: fetchInstalledCliApps,
|
fetchPayload: fetchInstalledCliApps,
|
||||||
isPayload: isCliAppsPayload,
|
isPayload: isCliAppsPayload,
|
||||||
selectItems: installedCliAppsFromPayload,
|
selectItems: installedCliAppsFromPayload,
|
||||||
});
|
});
|
||||||
const mcpPresets = useInstalledSettingItems({
|
const mcpPresets = useInstalledSettingItems({
|
||||||
token,
|
getToken,
|
||||||
eventName: MCP_PRESETS_CHANGED_EVENT,
|
eventName: MCP_PRESETS_CHANGED_EVENT,
|
||||||
fetchPayload: fetchMcpPresets,
|
fetchPayload: fetchMcpPresets,
|
||||||
isPayload: isMcpPresetsPayload,
|
isPayload: isMcpPresetsPayload,
|
||||||
@@ -738,7 +762,7 @@ export function ThreadShell({
|
|||||||
}, [chatId, messagesReady, rememberedViewportTurnId, turnActive]);
|
}, [chatId, messagesReady, rememberedViewportTurnId, turnActive]);
|
||||||
const filePreviewAvailabilityCache = useMemo(
|
const filePreviewAvailabilityCache = useMemo(
|
||||||
() => new Map<string, FilePreviewAvailabilityCacheEntry>(),
|
() => new Map<string, FilePreviewAvailabilityCacheEntry>(),
|
||||||
[historyKey, token],
|
[historyKey],
|
||||||
);
|
);
|
||||||
const filePreviewAvailabilityRevision = displayMessages.length;
|
const filePreviewAvailabilityRevision = displayMessages.length;
|
||||||
const resolveFilePreviewAvailability = useCallback((path: string) => {
|
const resolveFilePreviewAvailability = useCallback((path: string) => {
|
||||||
@@ -750,7 +774,7 @@ export function ThreadShell({
|
|||||||
) {
|
) {
|
||||||
return cached.promise;
|
return cached.promise;
|
||||||
}
|
}
|
||||||
const pending = fetchFilePreviewAvailability(token, historyKey, path).catch(
|
const pending = fetchFilePreviewAvailability(getToken(), historyKey, path).catch(
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError) {
|
||||||
if (error.status === 404 && /API route not found/i.test(error.message)) {
|
if (error.status === 404 && /API route not found/i.test(error.message)) {
|
||||||
@@ -775,8 +799,8 @@ export function ThreadShell({
|
|||||||
}, [
|
}, [
|
||||||
filePreviewAvailabilityCache,
|
filePreviewAvailabilityCache,
|
||||||
filePreviewAvailabilityRevision,
|
filePreviewAvailabilityRevision,
|
||||||
|
getToken,
|
||||||
historyKey,
|
historyKey,
|
||||||
token,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const showHeroComposer = displayMessages.length === 0 && !loading;
|
const showHeroComposer = displayMessages.length === 0 && !loading;
|
||||||
@@ -829,11 +853,11 @@ export function ThreadShell({
|
|||||||
|
|
||||||
const refreshModelSettings = useCallback(async () => {
|
const refreshModelSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setSettings(await fetchSettings(token));
|
setSettings(await fetchSettings(getToken()));
|
||||||
} catch {
|
} catch {
|
||||||
if (!settingsSnapshot) setSettings(null);
|
if (!settingsSnapshot) setSettings(null);
|
||||||
}
|
}
|
||||||
}, [settingsSnapshot, token]);
|
}, [getToken, settingsSnapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsSnapshot) {
|
if (settingsSnapshot) {
|
||||||
@@ -1067,14 +1091,37 @@ export function ThreadShell({
|
|||||||
});
|
});
|
||||||
}, [chatId, client, refreshCanonicalHistory]);
|
}, [chatId, client, refreshCanonicalHistory]);
|
||||||
|
|
||||||
|
const wasPageHiddenRef = useRef(document.visibilityState === "hidden");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refreshOnReturn = () => {
|
const refreshOnReturn = () => {
|
||||||
if (document.visibilityState !== "visible") return;
|
if (document.visibilityState === "hidden") {
|
||||||
|
wasPageHiddenRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!wasPageHiddenRef.current) return;
|
||||||
|
wasPageHiddenRef.current = false;
|
||||||
|
if (!chatId || client.status !== "open" || loading) return;
|
||||||
|
if (
|
||||||
|
!turnActive
|
||||||
|
&& !hasPendingToolCalls
|
||||||
|
&& !client.hasUnsettledRun(chatId)
|
||||||
|
&& !historyError
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
refreshCanonicalHistory();
|
refreshCanonicalHistory();
|
||||||
};
|
};
|
||||||
document.addEventListener("visibilitychange", refreshOnReturn);
|
document.addEventListener("visibilitychange", refreshOnReturn);
|
||||||
return () => document.removeEventListener("visibilitychange", refreshOnReturn);
|
return () => document.removeEventListener("visibilitychange", refreshOnReturn);
|
||||||
}, [refreshCanonicalHistory]);
|
}, [
|
||||||
|
chatId,
|
||||||
|
client,
|
||||||
|
hasPendingToolCalls,
|
||||||
|
historyError,
|
||||||
|
loading,
|
||||||
|
refreshCanonicalHistory,
|
||||||
|
turnActive,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let refreshOnNextOpen = client.status !== "open";
|
let refreshOnNextOpen = client.status !== "open";
|
||||||
@@ -1154,7 +1201,7 @@ export function ThreadShell({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const commands = await listSlashCommands(token);
|
const commands = await listSlashCommands(getToken());
|
||||||
if (!cancelled) setSlashCommands(commands);
|
if (!cancelled) setSlashCommands(commands);
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setSlashCommands([]);
|
if (!cancelled) setSlashCommands([]);
|
||||||
@@ -1163,7 +1210,7 @@ export function ThreadShell({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [getToken]);
|
||||||
|
|
||||||
const handleWelcomeSend = useCallback(
|
const handleWelcomeSend = useCallback(
|
||||||
async (content: string, images?: SendAttachment[], options?: SendOptions) => {
|
async (content: string, images?: SendAttachment[], options?: SendOptions) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||||
import { fetchSessionAutomations } from "@/lib/api";
|
import { fetchSessionAutomations } from "@/lib/api";
|
||||||
@@ -12,20 +12,25 @@ export function useSessionAutomationJobs(open: boolean, token: string, sessionKe
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadFailed, setLoadFailed] = useState(false);
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const tokenRef = useRef(token);
|
||||||
|
tokenRef.current = token;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !pageVisible) return;
|
if (!open || !pageVisible) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let loadedOnce = false;
|
let loadedOnce = false;
|
||||||
|
let refreshing = false;
|
||||||
|
|
||||||
const refresh = async (showLoading = false) => {
|
const refresh = async (showLoading = false) => {
|
||||||
|
if (refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLoadFailed(false);
|
setLoadFailed(false);
|
||||||
setJobs([]);
|
setJobs([]);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const next = await fetchSessionAutomations(token, sessionKey);
|
const next = await fetchSessionAutomations(tokenRef.current, sessionKey);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setJobs(next.jobs);
|
setJobs(next.jobs);
|
||||||
setLoadFailed(false);
|
setLoadFailed(false);
|
||||||
@@ -33,6 +38,7 @@ export function useSessionAutomationJobs(open: boolean, token: string, sessionKe
|
|||||||
} catch {
|
} catch {
|
||||||
if (!cancelled && !loadedOnce) setLoadFailed(true);
|
if (!cancelled && !loadedOnce) setLoadFailed(true);
|
||||||
} finally {
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
if (!cancelled && showLoading) setLoading(false);
|
if (!cancelled && showLoading) setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -46,7 +52,7 @@ export function useSessionAutomationJobs(open: boolean, token: string, sessionKe
|
|||||||
window.clearInterval(refreshId);
|
window.clearInterval(refreshId);
|
||||||
window.removeEventListener("focus", refreshOnFocus);
|
window.removeEventListener("focus", refreshOnFocus);
|
||||||
};
|
};
|
||||||
}, [open, pageVisible, sessionKey, token]);
|
}, [open, pageVisible, sessionKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !pageVisible) return;
|
if (!open || !pageVisible) return;
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ const INITIAL_HISTORY_PAGE_LIMIT = 160;
|
|||||||
const OLDER_HISTORY_PAGE_LIMIT = 120;
|
const OLDER_HISTORY_PAGE_LIMIT = 120;
|
||||||
const CHAT_CREATE_TIMEOUT_MS = 60_000;
|
const CHAT_CREATE_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === "object"
|
||||||
|
&& error !== null
|
||||||
|
&& "name" in error
|
||||||
|
&& error.name === "AbortError"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionHistoryContinuity = "initial" | "overlap" | "reset";
|
export type SessionHistoryContinuity = "initial" | "overlap" | "reset";
|
||||||
|
|
||||||
function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
|
function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
|
||||||
@@ -132,32 +141,46 @@ export function useSessions(): {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const tokenRef = useRef(token);
|
const tokenRef = useRef(token);
|
||||||
const optimisticKeysRef = useRef<Set<string>>(new Set());
|
const optimisticKeysRef = useRef<Set<string>>(new Set());
|
||||||
|
const refreshPendingRef = useRef(false);
|
||||||
|
const refreshInFlightRef = useRef<Promise<void> | null>(null);
|
||||||
tokenRef.current = token;
|
tokenRef.current = token;
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback((): Promise<void> => {
|
||||||
try {
|
refreshPendingRef.current = true;
|
||||||
|
if (refreshInFlightRef.current) return refreshInFlightRef.current;
|
||||||
|
const request = (async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const rows = await listSessions(tokenRef.current);
|
try {
|
||||||
const serverKeys = new Set(rows.map((row) => row.key));
|
while (refreshPendingRef.current) {
|
||||||
setSessions((prev) => [
|
refreshPendingRef.current = false;
|
||||||
...rows,
|
try {
|
||||||
...prev.filter(
|
const rows = await listSessions(tokenRef.current);
|
||||||
(session) =>
|
const serverKeys = new Set(rows.map((row) => row.key));
|
||||||
optimisticKeysRef.current.has(session.key) &&
|
setSessions((prev) => [
|
||||||
!serverKeys.has(session.key),
|
...rows,
|
||||||
),
|
...prev.filter(
|
||||||
]);
|
(session) =>
|
||||||
for (const key of Array.from(optimisticKeysRef.current)) {
|
optimisticKeysRef.current.has(session.key)
|
||||||
if (serverKeys.has(key)) optimisticKeysRef.current.delete(key);
|
&& !serverKeys.has(session.key),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
for (const key of Array.from(optimisticKeysRef.current)) {
|
||||||
|
if (serverKeys.has(key)) optimisticKeysRef.current.delete(key);
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
const msg =
|
||||||
|
e instanceof ApiError ? `HTTP ${e.status}` : (e as Error).message;
|
||||||
|
setError(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
refreshInFlightRef.current = null;
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
setError(null);
|
})();
|
||||||
} catch (e) {
|
refreshInFlightRef.current = request;
|
||||||
const msg =
|
return request;
|
||||||
e instanceof ApiError ? `HTTP ${e.status}` : (e as Error).message;
|
|
||||||
setError(msg);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -165,9 +188,20 @@ export function useSessions(): {
|
|||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return client.onSessionUpdate(() => {
|
let disposed = false;
|
||||||
void refresh();
|
let refreshQueued = false;
|
||||||
|
const unsubscribe = client.onSessionUpdate(() => {
|
||||||
|
if (refreshQueued) return;
|
||||||
|
refreshQueued = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
refreshQueued = false;
|
||||||
|
if (!disposed) void refresh();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
}, [client, refresh]);
|
}, [client, refresh]);
|
||||||
|
|
||||||
const createChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null): Promise<string> => {
|
const createChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null): Promise<string> => {
|
||||||
@@ -272,8 +306,9 @@ export function useSessionHistory(key: string | null): {
|
|||||||
/** Exact active turn when supplied by a current gateway. */
|
/** Exact active turn when supplied by a current gateway. */
|
||||||
activeTurnId: string | null;
|
activeTurnId: string | null;
|
||||||
} {
|
} {
|
||||||
const { token } = useClient();
|
const { getToken } = useClient();
|
||||||
const loadingOlderRef = useRef(false);
|
const loadingOlderRef = useRef(false);
|
||||||
|
const olderRequestAbortRef = useRef<AbortController | null>(null);
|
||||||
const historyVersionRef = useRef(0);
|
const historyVersionRef = useRef(0);
|
||||||
const [refreshSeq, setRefreshSeq] = useState(0);
|
const [refreshSeq, setRefreshSeq] = useState(0);
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
@@ -313,8 +348,17 @@ export function useSessionHistory(key: string | null): {
|
|||||||
activeTurnId: null,
|
activeTurnId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
olderRequestAbortRef.current?.abort();
|
||||||
|
olderRequestAbortRef.current = null;
|
||||||
|
loadingOlderRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!key) {
|
if (!key) {
|
||||||
|
olderRequestAbortRef.current?.abort();
|
||||||
|
olderRequestAbortRef.current = null;
|
||||||
|
loadingOlderRef.current = false;
|
||||||
setState({
|
setState({
|
||||||
key: null,
|
key: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
@@ -335,6 +379,10 @@ export function useSessionHistory(key: string | null): {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
olderRequestAbortRef.current?.abort();
|
||||||
|
olderRequestAbortRef.current = null;
|
||||||
|
loadingOlderRef.current = false;
|
||||||
// Mark the new key as loading immediately so callers never see stale
|
// Mark the new key as loading immediately so callers never see stale
|
||||||
// messages from the previous session during the render right after a switch.
|
// messages from the previous session during the render right after a switch.
|
||||||
setState((prev) => prev.key === key
|
setState((prev) => prev.key === key
|
||||||
@@ -358,9 +406,10 @@ export function useSessionHistory(key: string | null): {
|
|||||||
});
|
});
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const body = await fetchWebuiThread(token, key, {
|
const body = await fetchWebuiThread(getToken(), key, {
|
||||||
limit: INITIAL_HISTORY_PAGE_LIMIT,
|
limit: INITIAL_HISTORY_PAGE_LIMIT,
|
||||||
direction: "latest",
|
direction: "latest",
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
historyVersionRef.current += 1;
|
historyVersionRef.current += 1;
|
||||||
@@ -414,7 +463,7 @@ export function useSessionHistory(key: string | null): {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (cancelled) return;
|
if (cancelled || isAbortError(e)) return;
|
||||||
if (e instanceof ApiError && e.status === 404) {
|
if (e instanceof ApiError && e.status === 404) {
|
||||||
historyVersionRef.current += 1;
|
historyVersionRef.current += 1;
|
||||||
const responseVersion = historyVersionRef.current;
|
const responseVersion = historyVersionRef.current;
|
||||||
@@ -463,8 +512,9 @@ export function useSessionHistory(key: string | null): {
|
|||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [key, token, refreshSeq]);
|
}, [getToken, key, refreshSeq]);
|
||||||
|
|
||||||
const loadOlder = useCallback(async () => {
|
const loadOlder = useCallback(async () => {
|
||||||
if (!key || loadingOlderRef.current) return;
|
if (!key || loadingOlderRef.current) return;
|
||||||
@@ -478,13 +528,16 @@ export function useSessionHistory(key: string | null): {
|
|||||||
&& candidate.beforeCursor === beforeCursor
|
&& candidate.beforeCursor === beforeCursor
|
||||||
);
|
);
|
||||||
loadingOlderRef.current = true;
|
loadingOlderRef.current = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
olderRequestAbortRef.current = controller;
|
||||||
setState((prev) => matchesRequest(prev)
|
setState((prev) => matchesRequest(prev)
|
||||||
? { ...prev, loadingOlder: true, error: null }
|
? { ...prev, loadingOlder: true, error: null }
|
||||||
: prev);
|
: prev);
|
||||||
try {
|
try {
|
||||||
const body = await fetchWebuiThread(token, requestKey, {
|
const body = await fetchWebuiThread(getToken(), requestKey, {
|
||||||
limit: OLDER_HISTORY_PAGE_LIMIT,
|
limit: OLDER_HISTORY_PAGE_LIMIT,
|
||||||
before: beforeCursor,
|
before: beforeCursor,
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
if (!matchesRequest(prev)) return prev;
|
if (!matchesRequest(prev)) return prev;
|
||||||
@@ -518,6 +571,7 @@ export function useSessionHistory(key: string | null): {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (isAbortError(e)) return;
|
||||||
setState((prev) => matchesRequest(prev)
|
setState((prev) => matchesRequest(prev)
|
||||||
? {
|
? {
|
||||||
...prev,
|
...prev,
|
||||||
@@ -526,7 +580,10 @@ export function useSessionHistory(key: string | null): {
|
|||||||
}
|
}
|
||||||
: prev);
|
: prev);
|
||||||
} finally {
|
} finally {
|
||||||
loadingOlderRef.current = false;
|
if (olderRequestAbortRef.current === controller) {
|
||||||
|
olderRequestAbortRef.current = null;
|
||||||
|
loadingOlderRef.current = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
key,
|
key,
|
||||||
@@ -534,7 +591,7 @@ export function useSessionHistory(key: string | null): {
|
|||||||
state.hasMoreBefore,
|
state.hasMoreBefore,
|
||||||
state.key,
|
state.key,
|
||||||
state.lineage,
|
state.lineage,
|
||||||
token,
|
getToken,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
|
|||||||
@@ -4,19 +4,28 @@ import { fetchSkills } from "@/lib/api";
|
|||||||
import { isSkillsPayload, SKILLS_CHANGED_EVENT } from "@/lib/skill-events";
|
import { isSkillsPayload, SKILLS_CHANGED_EVENT } from "@/lib/skill-events";
|
||||||
import type { SkillSummary } from "@/lib/types";
|
import type { SkillSummary } from "@/lib/types";
|
||||||
|
|
||||||
export function useSkills(token: string): SkillSummary[] {
|
export function useSkills(getToken: () => string): SkillSummary[] {
|
||||||
const [skills, setSkills] = useState<SkillSummary[]>([]);
|
const [skills, setSkills] = useState<SkillSummary[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let payloadVersion = 0;
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
fetchSkills(token)
|
const version = payloadVersion;
|
||||||
.then(({ skills: nextSkills }) => !cancelled && setSkills(nextSkills))
|
fetchSkills(getToken())
|
||||||
.catch(() => !cancelled && setSkills([]));
|
.then(({ skills: nextSkills }) => {
|
||||||
|
if (!cancelled && version === payloadVersion) setSkills(nextSkills);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled && version === payloadVersion) setSkills([]);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const onSkillsChanged = (event: Event) => {
|
const onSkillsChanged = (event: Event) => {
|
||||||
const payload = (event as CustomEvent<unknown>).detail;
|
const payload = (event as CustomEvent<unknown>).detail;
|
||||||
if (!cancelled && isSkillsPayload(payload)) setSkills(payload.skills);
|
if (!cancelled && isSkillsPayload(payload)) {
|
||||||
|
payloadVersion += 1;
|
||||||
|
setSkills(payload.skills);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
@@ -25,7 +34,7 @@ export function useSkills(token: string): SkillSummary[] {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.removeEventListener(SKILLS_CHANGED_EVENT, onSkillsChanged);
|
window.removeEventListener(SKILLS_CHANGED_EVENT, onSkillsChanged);
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [getToken]);
|
||||||
|
|
||||||
return skills;
|
return skills;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -773,6 +773,7 @@
|
|||||||
"signedInAs": "Signed in as {{account}}",
|
"signedInAs": "Signed in as {{account}}",
|
||||||
"signInHelp": "Sign in from this device; no API key is stored in config.",
|
"signInHelp": "Sign in from this device; no API key is stored in config.",
|
||||||
"remoteSignInHelp": "Select Sign in to open xAI on your computer, then paste the authorization code shown after login.",
|
"remoteSignInHelp": "Select Sign in to open xAI on your computer, then paste the authorization code shown after login.",
|
||||||
|
"codexRemoteSignInHelp": "Sign in through this browser, then paste the full localhost callback URL back into nanobot.",
|
||||||
"signInRequired": "Sign in required",
|
"signInRequired": "Sign in required",
|
||||||
"signInBeforeSaving": "Sign in before saving this provider in the preset.",
|
"signInBeforeSaving": "Sign in before saving this provider in the preset.",
|
||||||
"signedIn": "Signed in",
|
"signedIn": "Signed in",
|
||||||
@@ -782,7 +783,14 @@
|
|||||||
"saveProxy": "Save proxy",
|
"saveProxy": "Save proxy",
|
||||||
"localCodeHelp": "Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, paste the authorization code below.",
|
"localCodeHelp": "Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, paste the authorization code below.",
|
||||||
"remoteCodeHelp": "Select Sign in to open xAI on your computer. After signing in, paste the authorization code shown by xAI below.",
|
"remoteCodeHelp": "Select Sign in to open xAI on your computer. After signing in, paste the authorization code shown by xAI below.",
|
||||||
|
"localCallbackHelp": "Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, copy the full localhost callback URL from the address bar and paste it below.",
|
||||||
|
"remoteCallbackHelp": "Open ChatGPT in this browser and finish signing in. When the localhost page fails to load, copy the full URL from the address bar and paste it below.",
|
||||||
"authorizationCode": "Authorization code",
|
"authorizationCode": "Authorization code",
|
||||||
|
"callbackUrl": "Full callback URL",
|
||||||
|
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||||
|
"openChatGPT": "Open ChatGPT",
|
||||||
|
"pasteCallbackToContinue": "Paste the callback URL to continue.",
|
||||||
|
"waitingForCallback": "Waiting for the browser callback…",
|
||||||
"finishSignIn": "Finish sign-in"
|
"finishSignIn": "Finish sign-in"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
|
|||||||
@@ -760,6 +760,7 @@
|
|||||||
"signedInAs": "Sesión iniciada como {{account}}",
|
"signedInAs": "Sesión iniciada como {{account}}",
|
||||||
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
||||||
"remoteSignInHelp": "Selecciona Iniciar sesión para abrir xAI en tu computadora y luego pega el código de autorización que se muestra tras iniciar sesión.",
|
"remoteSignInHelp": "Selecciona Iniciar sesión para abrir xAI en tu computadora y luego pega el código de autorización que se muestra tras iniciar sesión.",
|
||||||
|
"codexRemoteSignInHelp": "Inicia sesión en este navegador y pega en nanobot la URL completa de devolución de localhost.",
|
||||||
"signInRequired": "Inicio de sesión requerido",
|
"signInRequired": "Inicio de sesión requerido",
|
||||||
"signInBeforeSaving": "Inicia sesión en este proveedor antes de guardar el preajuste.",
|
"signInBeforeSaving": "Inicia sesión en este proveedor antes de guardar el preajuste.",
|
||||||
"signedIn": "Sesión iniciada",
|
"signedIn": "Sesión iniciada",
|
||||||
@@ -769,7 +770,14 @@
|
|||||||
"saveProxy": "Guardar proxy",
|
"saveProxy": "Guardar proxy",
|
||||||
"localCodeHelp": "Completa el inicio de sesión en el navegador. nanobot suele finalizar automáticamente; si no lo hace, pega el código de autorización abajo.",
|
"localCodeHelp": "Completa el inicio de sesión en el navegador. nanobot suele finalizar automáticamente; si no lo hace, pega el código de autorización abajo.",
|
||||||
"remoteCodeHelp": "Selecciona Iniciar sesión para abrir xAI en tu computadora. Después de iniciar sesión, pega abajo el código de autorización que muestra xAI.",
|
"remoteCodeHelp": "Selecciona Iniciar sesión para abrir xAI en tu computadora. Después de iniciar sesión, pega abajo el código de autorización que muestra xAI.",
|
||||||
|
"localCallbackHelp": "Completa el inicio de sesión en el navegador. nanobot suele finalizar automáticamente; si no lo hace, copia de la barra de direcciones la URL completa de devolución de localhost y pégala abajo.",
|
||||||
|
"remoteCallbackHelp": "Abre ChatGPT en este navegador y completa el inicio de sesión. Cuando la página de localhost no cargue, copia la URL completa de la barra de direcciones y pégala abajo.",
|
||||||
"authorizationCode": "Código de autorización",
|
"authorizationCode": "Código de autorización",
|
||||||
|
"callbackUrl": "URL completa de devolución",
|
||||||
|
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||||
|
"openChatGPT": "Abrir ChatGPT",
|
||||||
|
"pasteCallbackToContinue": "Pega la URL de devolución para continuar.",
|
||||||
|
"waitingForCallback": "Esperando la devolución del navegador…",
|
||||||
"finishSignIn": "Completar inicio de sesión"
|
"finishSignIn": "Completar inicio de sesión"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
|
|||||||
@@ -759,6 +759,7 @@
|
|||||||
"signedInAs": "Connecté en tant que {{account}}",
|
"signedInAs": "Connecté en tant que {{account}}",
|
||||||
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
||||||
"remoteSignInHelp": "Sélectionnez Se connecter pour ouvrir xAI sur votre ordinateur, puis collez le code d’autorisation affiché après la connexion.",
|
"remoteSignInHelp": "Sélectionnez Se connecter pour ouvrir xAI sur votre ordinateur, puis collez le code d’autorisation affiché après la connexion.",
|
||||||
|
"codexRemoteSignInHelp": "Connectez-vous dans ce navigateur, puis recollez dans nanobot l’URL complète de rappel localhost.",
|
||||||
"signInRequired": "Connexion requise",
|
"signInRequired": "Connexion requise",
|
||||||
"signInBeforeSaving": "Connectez-vous à ce fournisseur avant d’enregistrer le préréglage.",
|
"signInBeforeSaving": "Connectez-vous à ce fournisseur avant d’enregistrer le préréglage.",
|
||||||
"signedIn": "Connecté",
|
"signedIn": "Connecté",
|
||||||
@@ -768,7 +769,14 @@
|
|||||||
"saveProxy": "Enregistrer le proxy",
|
"saveProxy": "Enregistrer le proxy",
|
||||||
"localCodeHelp": "Terminez la connexion dans votre navigateur. nanobot termine généralement automatiquement ; sinon, collez le code d’autorisation ci-dessous.",
|
"localCodeHelp": "Terminez la connexion dans votre navigateur. nanobot termine généralement automatiquement ; sinon, collez le code d’autorisation ci-dessous.",
|
||||||
"remoteCodeHelp": "Sélectionnez Se connecter pour ouvrir xAI sur votre ordinateur. Après la connexion, collez ci-dessous le code d’autorisation affiché par xAI.",
|
"remoteCodeHelp": "Sélectionnez Se connecter pour ouvrir xAI sur votre ordinateur. Après la connexion, collez ci-dessous le code d’autorisation affiché par xAI.",
|
||||||
|
"localCallbackHelp": "Terminez la connexion dans votre navigateur. nanobot termine généralement automatiquement ; sinon, copiez l’URL complète de rappel localhost depuis la barre d’adresse et collez-la ci-dessous.",
|
||||||
|
"remoteCallbackHelp": "Ouvrez ChatGPT dans ce navigateur et terminez la connexion. Lorsque la page localhost ne se charge pas, copiez l’URL complète de la barre d’adresse et collez-la ci-dessous.",
|
||||||
"authorizationCode": "Code d’autorisation",
|
"authorizationCode": "Code d’autorisation",
|
||||||
|
"callbackUrl": "URL complète de rappel",
|
||||||
|
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||||
|
"openChatGPT": "Ouvrir ChatGPT",
|
||||||
|
"pasteCallbackToContinue": "Collez l’URL de rappel pour continuer.",
|
||||||
|
"waitingForCallback": "En attente du rappel du navigateur…",
|
||||||
"finishSignIn": "Terminer la connexion"
|
"finishSignIn": "Terminer la connexion"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
|
|||||||
@@ -759,6 +759,7 @@
|
|||||||
"signedInAs": "Masuk sebagai {{account}}",
|
"signedInAs": "Masuk sebagai {{account}}",
|
||||||
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
||||||
"remoteSignInHelp": "Pilih Masuk untuk membuka xAI di komputer Anda, lalu tempel kode otorisasi yang ditampilkan setelah masuk.",
|
"remoteSignInHelp": "Pilih Masuk untuk membuka xAI di komputer Anda, lalu tempel kode otorisasi yang ditampilkan setelah masuk.",
|
||||||
|
"codexRemoteSignInHelp": "Masuk melalui browser ini, lalu tempel URL callback localhost lengkap kembali ke nanobot.",
|
||||||
"signInRequired": "Perlu masuk",
|
"signInRequired": "Perlu masuk",
|
||||||
"signInBeforeSaving": "Masuk ke penyedia ini sebelum menyimpan preset.",
|
"signInBeforeSaving": "Masuk ke penyedia ini sebelum menyimpan preset.",
|
||||||
"signedIn": "Sudah masuk",
|
"signedIn": "Sudah masuk",
|
||||||
@@ -768,7 +769,14 @@
|
|||||||
"saveProxy": "Simpan proksi",
|
"saveProxy": "Simpan proksi",
|
||||||
"localCodeHelp": "Selesaikan proses masuk di browser. nanobot biasanya menyelesaikannya secara otomatis; jika tidak, tempel kode otorisasi di bawah.",
|
"localCodeHelp": "Selesaikan proses masuk di browser. nanobot biasanya menyelesaikannya secara otomatis; jika tidak, tempel kode otorisasi di bawah.",
|
||||||
"remoteCodeHelp": "Pilih Masuk untuk membuka xAI di komputer Anda. Setelah masuk, tempel kode otorisasi yang ditampilkan xAI di bawah.",
|
"remoteCodeHelp": "Pilih Masuk untuk membuka xAI di komputer Anda. Setelah masuk, tempel kode otorisasi yang ditampilkan xAI di bawah.",
|
||||||
|
"localCallbackHelp": "Selesaikan proses masuk di browser. nanobot biasanya menyelesaikannya secara otomatis; jika tidak, salin URL callback localhost lengkap dari bilah alamat dan tempel di bawah.",
|
||||||
|
"remoteCallbackHelp": "Buka ChatGPT di browser ini dan selesaikan proses masuk. Saat halaman localhost gagal dimuat, salin URL lengkap dari bilah alamat dan tempel di bawah.",
|
||||||
"authorizationCode": "Kode otorisasi",
|
"authorizationCode": "Kode otorisasi",
|
||||||
|
"callbackUrl": "URL callback lengkap",
|
||||||
|
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||||
|
"openChatGPT": "Buka ChatGPT",
|
||||||
|
"pasteCallbackToContinue": "Tempel URL callback untuk melanjutkan.",
|
||||||
|
"waitingForCallback": "Menunggu callback browser…",
|
||||||
"finishSignIn": "Selesaikan masuk"
|
"finishSignIn": "Selesaikan masuk"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
|
|||||||
@@ -759,6 +759,7 @@
|
|||||||
"signedInAs": "{{account}} としてサインイン済み",
|
"signedInAs": "{{account}} としてサインイン済み",
|
||||||
"signInHelp": "このデバイスからサインインします。API key は config に保存されません。",
|
"signInHelp": "このデバイスからサインインします。API key は config に保存されません。",
|
||||||
"remoteSignInHelp": "「サインイン」を選択して自分のコンピューターで xAI を開き、サインイン後に表示される認証コードを貼り付けてください。",
|
"remoteSignInHelp": "「サインイン」を選択して自分のコンピューターで xAI を開き、サインイン後に表示される認証コードを貼り付けてください。",
|
||||||
|
"codexRemoteSignInHelp": "このブラウザーでサインインし、localhost の完全なコールバック URL を nanobot に貼り付けてください。",
|
||||||
"signInRequired": "サインインが必要です",
|
"signInRequired": "サインインが必要です",
|
||||||
"signInBeforeSaving": "プリセットを保存する前に、このプロバイダーへサインインしてください。",
|
"signInBeforeSaving": "プリセットを保存する前に、このプロバイダーへサインインしてください。",
|
||||||
"signedIn": "サインイン済み",
|
"signedIn": "サインイン済み",
|
||||||
@@ -768,7 +769,14 @@
|
|||||||
"saveProxy": "プロキシを保存",
|
"saveProxy": "プロキシを保存",
|
||||||
"localCodeHelp": "ブラウザーでサインインを完了してください。通常は nanobot が自動で完了します。完了しない場合は、認証コードを下に貼り付けてください。",
|
"localCodeHelp": "ブラウザーでサインインを完了してください。通常は nanobot が自動で完了します。完了しない場合は、認証コードを下に貼り付けてください。",
|
||||||
"remoteCodeHelp": "「サインイン」を選択して自分のコンピューターで xAI を開いてください。サインイン後、xAI に表示された認証コードを下に貼り付けてください。",
|
"remoteCodeHelp": "「サインイン」を選択して自分のコンピューターで xAI を開いてください。サインイン後、xAI に表示された認証コードを下に貼り付けてください。",
|
||||||
|
"localCallbackHelp": "ブラウザーでサインインを完了してください。通常は nanobot が自動で完了します。完了しない場合は、アドレスバーから localhost の完全なコールバック URL をコピーして下に貼り付けてください。",
|
||||||
|
"remoteCallbackHelp": "このブラウザーで ChatGPT を開いてサインインを完了してください。localhost ページを開けない場合は、アドレスバーの完全な URL をコピーして下に貼り付けてください。",
|
||||||
"authorizationCode": "認証コード",
|
"authorizationCode": "認証コード",
|
||||||
|
"callbackUrl": "完全なコールバック URL",
|
||||||
|
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||||
|
"openChatGPT": "ChatGPT を開く",
|
||||||
|
"pasteCallbackToContinue": "続行するにはコールバック URL を貼り付けてください。",
|
||||||
|
"waitingForCallback": "ブラウザーのコールバックを待機中…",
|
||||||
"finishSignIn": "サインインを完了"
|
"finishSignIn": "サインインを完了"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
|
|||||||
@@ -759,6 +759,7 @@
|
|||||||
"signedInAs": "{{account}}로 로그인됨",
|
"signedInAs": "{{account}}로 로그인됨",
|
||||||
"signInHelp": "이 기기에서 로그인합니다. API key는 config에 저장되지 않습니다.",
|
"signInHelp": "이 기기에서 로그인합니다. API key는 config에 저장되지 않습니다.",
|
||||||
"remoteSignInHelp": "로그인을 선택하여 사용자 컴퓨터에서 xAI를 연 다음, 로그인 후 표시되는 인증 코드를 붙여 넣으세요.",
|
"remoteSignInHelp": "로그인을 선택하여 사용자 컴퓨터에서 xAI를 연 다음, 로그인 후 표시되는 인증 코드를 붙여 넣으세요.",
|
||||||
|
"codexRemoteSignInHelp": "이 브라우저에서 로그인한 다음 전체 localhost 콜백 URL을 nanobot에 붙여 넣으세요.",
|
||||||
"signInRequired": "로그인이 필요합니다",
|
"signInRequired": "로그인이 필요합니다",
|
||||||
"signInBeforeSaving": "프리셋을 저장하기 전에 이 제공자에 로그인하세요.",
|
"signInBeforeSaving": "프리셋을 저장하기 전에 이 제공자에 로그인하세요.",
|
||||||
"signedIn": "로그인됨",
|
"signedIn": "로그인됨",
|
||||||
@@ -768,7 +769,14 @@
|
|||||||
"saveProxy": "프록시 저장",
|
"saveProxy": "프록시 저장",
|
||||||
"localCodeHelp": "브라우저에서 로그인을 완료하세요. 일반적으로 nanobot이 자동으로 완료합니다. 완료되지 않으면 인증 코드를 아래에 붙여 넣으세요.",
|
"localCodeHelp": "브라우저에서 로그인을 완료하세요. 일반적으로 nanobot이 자동으로 완료합니다. 완료되지 않으면 인증 코드를 아래에 붙여 넣으세요.",
|
||||||
"remoteCodeHelp": "로그인을 선택하여 사용자 컴퓨터에서 xAI를 여세요. 로그인 후 xAI에 표시된 인증 코드를 아래에 붙여 넣으세요.",
|
"remoteCodeHelp": "로그인을 선택하여 사용자 컴퓨터에서 xAI를 여세요. 로그인 후 xAI에 표시된 인증 코드를 아래에 붙여 넣으세요.",
|
||||||
|
"localCallbackHelp": "브라우저에서 로그인을 완료하세요. 일반적으로 nanobot이 자동으로 완료합니다. 완료되지 않으면 주소 표시줄에서 전체 localhost 콜백 URL을 복사해 아래에 붙여 넣으세요.",
|
||||||
|
"remoteCallbackHelp": "이 브라우저에서 ChatGPT를 열고 로그인을 완료하세요. localhost 페이지가 열리지 않으면 주소 표시줄의 전체 URL을 복사해 아래에 붙여 넣으세요.",
|
||||||
"authorizationCode": "인증 코드",
|
"authorizationCode": "인증 코드",
|
||||||
|
"callbackUrl": "전체 콜백 URL",
|
||||||
|
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||||
|
"openChatGPT": "ChatGPT 열기",
|
||||||
|
"pasteCallbackToContinue": "계속하려면 콜백 URL을 붙여 넣으세요.",
|
||||||
|
"waitingForCallback": "브라우저 콜백 대기 중…",
|
||||||
"finishSignIn": "로그인 완료"
|
"finishSignIn": "로그인 완료"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
|
|||||||
@@ -773,6 +773,7 @@
|
|||||||
"signedInAs": "Conectado como {{account}}",
|
"signedInAs": "Conectado como {{account}}",
|
||||||
"signInHelp": "Entre por este dispositivo; nenhuma chave de API é armazenada em config.",
|
"signInHelp": "Entre por este dispositivo; nenhuma chave de API é armazenada em config.",
|
||||||
"remoteSignInHelp": "Selecione Entrar para abrir a xAI no seu computador e depois cole o código de autorização exibido após o login.",
|
"remoteSignInHelp": "Selecione Entrar para abrir a xAI no seu computador e depois cole o código de autorização exibido após o login.",
|
||||||
|
"codexRemoteSignInHelp": "Entre por este navegador e cole no nanobot a URL completa de callback do localhost.",
|
||||||
"signInRequired": "Login necessário",
|
"signInRequired": "Login necessário",
|
||||||
"signInBeforeSaving": "Entre neste provedor antes de salvar a predefinição.",
|
"signInBeforeSaving": "Entre neste provedor antes de salvar a predefinição.",
|
||||||
"signedIn": "Conectado",
|
"signedIn": "Conectado",
|
||||||
@@ -782,7 +783,14 @@
|
|||||||
"saveProxy": "Salvar proxy",
|
"saveProxy": "Salvar proxy",
|
||||||
"localCodeHelp": "Conclua o login no navegador. O nanobot geralmente termina automaticamente; caso contrário, cole o código de autorização abaixo.",
|
"localCodeHelp": "Conclua o login no navegador. O nanobot geralmente termina automaticamente; caso contrário, cole o código de autorização abaixo.",
|
||||||
"remoteCodeHelp": "Selecione Entrar para abrir a xAI no seu computador. Após o login, cole abaixo o código de autorização exibido pela xAI.",
|
"remoteCodeHelp": "Selecione Entrar para abrir a xAI no seu computador. Após o login, cole abaixo o código de autorização exibido pela xAI.",
|
||||||
|
"localCallbackHelp": "Conclua o login no navegador. O nanobot geralmente termina automaticamente; caso contrário, copie da barra de endereço a URL completa de callback do localhost e cole abaixo.",
|
||||||
|
"remoteCallbackHelp": "Abra o ChatGPT neste navegador e conclua o login. Quando a página localhost não carregar, copie a URL completa da barra de endereço e cole abaixo.",
|
||||||
"authorizationCode": "Código de autorização",
|
"authorizationCode": "Código de autorização",
|
||||||
|
"callbackUrl": "URL completa de callback",
|
||||||
|
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||||
|
"openChatGPT": "Abrir ChatGPT",
|
||||||
|
"pasteCallbackToContinue": "Cole a URL de callback para continuar.",
|
||||||
|
"waitingForCallback": "Aguardando o callback do navegador…",
|
||||||
"finishSignIn": "Concluir login"
|
"finishSignIn": "Concluir login"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
|
|||||||
@@ -759,6 +759,7 @@
|
|||||||
"signedInAs": "Đã đăng nhập bằng {{account}}",
|
"signedInAs": "Đã đăng nhập bằng {{account}}",
|
||||||
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
||||||
"remoteSignInHelp": "Chọn Đăng nhập để mở xAI trên máy tính của bạn, sau đó dán mã ủy quyền được hiển thị sau khi đăng nhập.",
|
"remoteSignInHelp": "Chọn Đăng nhập để mở xAI trên máy tính của bạn, sau đó dán mã ủy quyền được hiển thị sau khi đăng nhập.",
|
||||||
|
"codexRemoteSignInHelp": "Đăng nhập trong trình duyệt này, sau đó dán lại URL callback localhost đầy đủ vào nanobot.",
|
||||||
"signInRequired": "Cần đăng nhập",
|
"signInRequired": "Cần đăng nhập",
|
||||||
"signInBeforeSaving": "Hãy đăng nhập nhà cung cấp này trước khi lưu cấu hình đặt trước.",
|
"signInBeforeSaving": "Hãy đăng nhập nhà cung cấp này trước khi lưu cấu hình đặt trước.",
|
||||||
"signedIn": "Đã đăng nhập",
|
"signedIn": "Đã đăng nhập",
|
||||||
@@ -768,7 +769,14 @@
|
|||||||
"saveProxy": "Lưu proxy",
|
"saveProxy": "Lưu proxy",
|
||||||
"localCodeHelp": "Hoàn tất đăng nhập trong trình duyệt. nanobot thường tự động hoàn tất; nếu không, hãy dán mã ủy quyền bên dưới.",
|
"localCodeHelp": "Hoàn tất đăng nhập trong trình duyệt. nanobot thường tự động hoàn tất; nếu không, hãy dán mã ủy quyền bên dưới.",
|
||||||
"remoteCodeHelp": "Chọn Đăng nhập để mở xAI trên máy tính của bạn. Sau khi đăng nhập, hãy dán mã ủy quyền do xAI hiển thị bên dưới.",
|
"remoteCodeHelp": "Chọn Đăng nhập để mở xAI trên máy tính của bạn. Sau khi đăng nhập, hãy dán mã ủy quyền do xAI hiển thị bên dưới.",
|
||||||
|
"localCallbackHelp": "Hoàn tất đăng nhập trong trình duyệt. nanobot thường tự động hoàn tất; nếu không, hãy sao chép URL callback localhost đầy đủ từ thanh địa chỉ và dán vào bên dưới.",
|
||||||
|
"remoteCallbackHelp": "Mở ChatGPT trong trình duyệt này và hoàn tất đăng nhập. Khi trang localhost không tải được, hãy sao chép URL đầy đủ từ thanh địa chỉ và dán vào bên dưới.",
|
||||||
"authorizationCode": "Mã ủy quyền",
|
"authorizationCode": "Mã ủy quyền",
|
||||||
|
"callbackUrl": "URL callback đầy đủ",
|
||||||
|
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||||
|
"openChatGPT": "Mở ChatGPT",
|
||||||
|
"pasteCallbackToContinue": "Dán URL callback để tiếp tục.",
|
||||||
|
"waitingForCallback": "Đang chờ callback từ trình duyệt…",
|
||||||
"finishSignIn": "Hoàn tất đăng nhập"
|
"finishSignIn": "Hoàn tất đăng nhập"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
|
|||||||
@@ -773,6 +773,7 @@
|
|||||||
"signedInAs": "已登录为 {{account}}",
|
"signedInAs": "已登录为 {{account}}",
|
||||||
"signInHelp": "从这台设备登录;不会在配置中保存 API key。",
|
"signInHelp": "从这台设备登录;不会在配置中保存 API key。",
|
||||||
"remoteSignInHelp": "点击“登录”在你的电脑上打开 xAI,完成登录后粘贴页面显示的授权码。",
|
"remoteSignInHelp": "点击“登录”在你的电脑上打开 xAI,完成登录后粘贴页面显示的授权码。",
|
||||||
|
"codexRemoteSignInHelp": "在此浏览器中登录,然后将完整的 localhost 回调 URL 粘贴回 nanobot。",
|
||||||
"signInRequired": "需要登录",
|
"signInRequired": "需要登录",
|
||||||
"signInBeforeSaving": "请先登录此提供商,再保存模型预设。",
|
"signInBeforeSaving": "请先登录此提供商,再保存模型预设。",
|
||||||
"signedIn": "已登录",
|
"signedIn": "已登录",
|
||||||
@@ -782,7 +783,14 @@
|
|||||||
"saveProxy": "保存代理",
|
"saveProxy": "保存代理",
|
||||||
"localCodeHelp": "请在浏览器中完成登录。nanobot 通常会自动完成;若未自动完成,请将授权码粘贴到下方。",
|
"localCodeHelp": "请在浏览器中完成登录。nanobot 通常会自动完成;若未自动完成,请将授权码粘贴到下方。",
|
||||||
"remoteCodeHelp": "点击“登录”在你的电脑上打开 xAI。完成登录后,请将 xAI 显示的授权码粘贴到下方。",
|
"remoteCodeHelp": "点击“登录”在你的电脑上打开 xAI。完成登录后,请将 xAI 显示的授权码粘贴到下方。",
|
||||||
|
"localCallbackHelp": "请在浏览器中完成登录。nanobot 通常会自动完成;若未自动完成,请复制地址栏中的完整 localhost 回调 URL 并粘贴到下方。",
|
||||||
|
"remoteCallbackHelp": "在此浏览器中打开 ChatGPT 并完成登录。当 localhost 页面无法打开时,请复制地址栏中的完整 URL 并粘贴到下方。",
|
||||||
"authorizationCode": "授权码",
|
"authorizationCode": "授权码",
|
||||||
|
"callbackUrl": "完整回调 URL",
|
||||||
|
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||||
|
"openChatGPT": "打开 ChatGPT",
|
||||||
|
"pasteCallbackToContinue": "粘贴回调 URL 以继续。",
|
||||||
|
"waitingForCallback": "正在等待浏览器回调…",
|
||||||
"finishSignIn": "完成登录"
|
"finishSignIn": "完成登录"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
|
|||||||
@@ -759,6 +759,7 @@
|
|||||||
"signedInAs": "已使用 {{account}} 登入",
|
"signedInAs": "已使用 {{account}} 登入",
|
||||||
"signInHelp": "請從這臺裝置登入;系統不會將 API 金鑰儲存在設定中。",
|
"signInHelp": "請從這臺裝置登入;系統不會將 API 金鑰儲存在設定中。",
|
||||||
"remoteSignInHelp": "點擊「登入」在你的電腦上開啟 xAI,完成登入後貼上頁面顯示的授權碼。",
|
"remoteSignInHelp": "點擊「登入」在你的電腦上開啟 xAI,完成登入後貼上頁面顯示的授權碼。",
|
||||||
|
"codexRemoteSignInHelp": "請在此瀏覽器中登入,然後將完整的 localhost 回呼 URL 貼回 nanobot。",
|
||||||
"signInRequired": "需要登入",
|
"signInRequired": "需要登入",
|
||||||
"signInBeforeSaving": "請先登入此供應商,再儲存模型預設。",
|
"signInBeforeSaving": "請先登入此供應商,再儲存模型預設。",
|
||||||
"signedIn": "已登入",
|
"signedIn": "已登入",
|
||||||
@@ -768,7 +769,14 @@
|
|||||||
"saveProxy": "儲存代理",
|
"saveProxy": "儲存代理",
|
||||||
"localCodeHelp": "請在瀏覽器中完成登入。nanobot 通常會自動完成;若未自動完成,請將授權碼貼到下方。",
|
"localCodeHelp": "請在瀏覽器中完成登入。nanobot 通常會自動完成;若未自動完成,請將授權碼貼到下方。",
|
||||||
"remoteCodeHelp": "點擊「登入」在你的電腦上開啟 xAI。完成登入後,請將 xAI 顯示的授權碼貼到下方。",
|
"remoteCodeHelp": "點擊「登入」在你的電腦上開啟 xAI。完成登入後,請將 xAI 顯示的授權碼貼到下方。",
|
||||||
|
"localCallbackHelp": "請在瀏覽器中完成登入。nanobot 通常會自動完成;若未自動完成,請複製網址列中的完整 localhost 回呼 URL 並貼到下方。",
|
||||||
|
"remoteCallbackHelp": "請在此瀏覽器中開啟 ChatGPT 並完成登入。當 localhost 頁面無法開啟時,請複製網址列中的完整 URL 並貼到下方。",
|
||||||
"authorizationCode": "授權碼",
|
"authorizationCode": "授權碼",
|
||||||
|
"callbackUrl": "完整回呼 URL",
|
||||||
|
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||||
|
"openChatGPT": "開啟 ChatGPT",
|
||||||
|
"pasteCallbackToContinue": "貼上回呼 URL 以繼續。",
|
||||||
|
"waitingForCallback": "正在等待瀏覽器回呼…",
|
||||||
"finishSignIn": "完成登入"
|
"finishSignIn": "完成登入"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
|
|||||||
+12
-2
@@ -61,6 +61,7 @@ function isSlashCommandLifecycle(value: unknown): value is SlashCommandLifecycle
|
|||||||
const CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values";
|
const CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values";
|
||||||
const API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values";
|
const API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values";
|
||||||
const OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code";
|
const OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code";
|
||||||
|
const OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback";
|
||||||
const PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values";
|
const PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -171,6 +172,7 @@ export interface FetchWebuiThreadOptions {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
direction?: "latest";
|
direction?: "latest";
|
||||||
before?: string | null;
|
before?: string | null;
|
||||||
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWebuiThread(
|
export async function fetchWebuiThread(
|
||||||
@@ -192,6 +194,7 @@ export async function fetchWebuiThread(
|
|||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
|
signal: options?.signal,
|
||||||
});
|
});
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
if (!res.ok) throw new ApiError(res.status, `HTTP ${res.status}`);
|
if (!res.ok) throw new ApiError(res.status, `HTTP ${res.status}`);
|
||||||
@@ -990,9 +993,11 @@ export async function loginProviderOAuth(
|
|||||||
token: string,
|
token: string,
|
||||||
provider: string,
|
provider: string,
|
||||||
base: string = "",
|
base: string = "",
|
||||||
|
remoteBrowserAccess: boolean = false,
|
||||||
): Promise<ProviderOAuthLoginResult> {
|
): Promise<ProviderOAuthLoginResult> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
query.set("provider", provider);
|
query.set("provider", provider);
|
||||||
|
if (remoteBrowserAccess) query.set("remote_browser", "true");
|
||||||
return request<ProviderOAuthLoginResult>(
|
return request<ProviderOAuthLoginResult>(
|
||||||
`${base}/api/settings/provider/oauth-login?${query}`,
|
`${base}/api/settings/provider/oauth-login?${query}`,
|
||||||
token,
|
token,
|
||||||
@@ -1004,13 +1009,18 @@ export async function completeProviderOAuth(
|
|||||||
token: string,
|
token: string,
|
||||||
provider: string,
|
provider: string,
|
||||||
flowId: string,
|
flowId: string,
|
||||||
authorizationCode?: string,
|
authorizationResponse?: string,
|
||||||
base: string = "",
|
base: string = "",
|
||||||
): Promise<ProviderOAuthCompletionResult> {
|
): Promise<ProviderOAuthCompletionResult> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
query.set("provider", provider);
|
query.set("provider", provider);
|
||||||
query.set("flow_id", flowId);
|
query.set("flow_id", flowId);
|
||||||
const headers = authorizationCode ? { [OAUTH_CODE_HEADER]: authorizationCode } : undefined;
|
const responseHeader = provider === "openai_codex"
|
||||||
|
? OAUTH_CALLBACK_HEADER
|
||||||
|
: OAUTH_CODE_HEADER;
|
||||||
|
const headers = authorizationResponse
|
||||||
|
? { [responseHeader]: authorizationResponse }
|
||||||
|
: undefined;
|
||||||
return request<ProviderOAuthCompletionResult>(
|
return request<ProviderOAuthCompletionResult>(
|
||||||
`${base}/api/settings/provider/oauth-login/complete?${query}`,
|
`${base}/api/settings/provider/oauth-login/complete?${query}`,
|
||||||
token,
|
token,
|
||||||
|
|||||||
+20
-10
@@ -12,22 +12,32 @@ export async function fetchWithTimeout(
|
|||||||
const controller = typeof AbortController !== "undefined"
|
const controller = typeof AbortController !== "undefined"
|
||||||
? new AbortController()
|
? new AbortController()
|
||||||
: null;
|
: null;
|
||||||
|
const externalSignal = init.signal;
|
||||||
|
const abortFromExternal = () => controller?.abort();
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
const request = fetch(input, {
|
if (controller && externalSignal) {
|
||||||
...init,
|
if (externalSignal.aborted) {
|
||||||
signal: controller?.signal ?? init.signal,
|
controller.abort();
|
||||||
});
|
} else {
|
||||||
const timeout = new Promise<Response>((_, reject) => {
|
externalSignal.addEventListener("abort", abortFromExternal, { once: true });
|
||||||
timeoutId = setTimeout(() => {
|
}
|
||||||
reject(new Error(`Request timed out after ${timeoutMs}ms`));
|
}
|
||||||
controller?.abort();
|
|
||||||
}, timeoutMs);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const request = fetch(input, {
|
||||||
|
...init,
|
||||||
|
signal: controller?.signal ?? externalSignal,
|
||||||
|
});
|
||||||
|
const timeout = new Promise<Response>((_, reject) => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
reject(new Error(`Request timed out after ${timeoutMs}ms`));
|
||||||
|
controller?.abort();
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
return await Promise.race([request, timeout]);
|
return await Promise.race([request, timeout]);
|
||||||
} finally {
|
} finally {
|
||||||
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
||||||
|
externalSignal?.removeEventListener("abort", abortFromExternal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -458,6 +458,7 @@ export interface ProviderOAuthAuthorizationRequired {
|
|||||||
flow_id: string;
|
flow_id: string;
|
||||||
authorization_url: string;
|
authorization_url: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
|
completion_input?: "authorization_code" | "callback_url";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderOAuthPending {
|
export interface ProviderOAuthPending {
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { createContext, useContext, type ReactNode } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
import type { NanobotClient } from "@/lib/nanobot-client";
|
import type { NanobotClient } from "@/lib/nanobot-client";
|
||||||
import type { WebUIIngressLimits } from "@/lib/types";
|
import type { WebUIIngressLimits } from "@/lib/types";
|
||||||
@@ -6,6 +13,7 @@ import type { WebUIIngressLimits } from "@/lib/types";
|
|||||||
interface ClientContextValue {
|
interface ClientContextValue {
|
||||||
client: NanobotClient;
|
client: NanobotClient;
|
||||||
token: string;
|
token: string;
|
||||||
|
getToken: () => string;
|
||||||
modelName: string | null;
|
modelName: string | null;
|
||||||
ingressLimits: WebUIIngressLimits | null;
|
ingressLimits: WebUIIngressLimits | null;
|
||||||
}
|
}
|
||||||
@@ -25,8 +33,16 @@ export function ClientProvider({
|
|||||||
ingressLimits?: WebUIIngressLimits | null;
|
ingressLimits?: WebUIIngressLimits | null;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const tokenRef = useRef(token);
|
||||||
|
tokenRef.current = token;
|
||||||
|
const getToken = useCallback(() => tokenRef.current, []);
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ client, token, getToken, modelName, ingressLimits }),
|
||||||
|
[client, getToken, ingressLimits, modelName, token],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientContext.Provider value={{ client, token, modelName, ingressLimits }}>
|
<ClientContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</ClientContext.Provider>
|
</ClientContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -103,6 +103,25 @@ describe("webui API helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("aborts a WebUI thread request when its caller signal is aborted", async () => {
|
||||||
|
let requestSignal: AbortSignal | null = null;
|
||||||
|
vi.mocked(fetch).mockImplementation((_input, init) => new Promise((_resolve, reject) => {
|
||||||
|
requestSignal = init?.signal ?? null;
|
||||||
|
requestSignal?.addEventListener("abort", () => {
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const request = fetchWebuiThread("tok", "websocket:chat-1", {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(request).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
expect(requestSignal?.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("percent-encodes websocket keys and paths when fetching file previews", async () => {
|
it("percent-encodes websocket keys and paths when fetching file previews", async () => {
|
||||||
await fetchFilePreview("tok", "websocket:chat-1", "/tmp/project/hook.py:12");
|
await fetchFilePreview("tok", "websocket:chat-1", "/tmp/project/hook.py:12");
|
||||||
|
|
||||||
@@ -632,6 +651,14 @@ describe("webui API helpers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await loginProviderOAuth("tok", "openai_codex", "", true);
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true",
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await completeProviderOAuth("tok", "xai_grok", "flow-123");
|
await completeProviderOAuth("tok", "xai_grok", "flow-123");
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
|
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
|
||||||
@@ -656,6 +683,23 @@ describe("webui API helpers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await completeProviderOAuth(
|
||||||
|
"tok",
|
||||||
|
"openai_codex",
|
||||||
|
"flow-codex",
|
||||||
|
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||||
|
);
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex",
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer tok",
|
||||||
|
"X-Nanobot-OAuth-Callback":
|
||||||
|
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await logoutProviderOAuth("tok", "openai_codex");
|
await logoutProviderOAuth("tok", "openai_codex");
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"/api/settings/provider/oauth-logout?provider=openai_codex",
|
"/api/settings/provider/oauth-logout?provider=openai_codex",
|
||||||
|
|||||||
@@ -2642,4 +2642,54 @@ describe("App layout", () => {
|
|||||||
expect(updateUrlSpy).toHaveBeenCalledWith("ws://test?token=tok-2");
|
expect(updateUrlSpy).toHaveBeenCalledWith("ws://test?token=tok-2");
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reuses an in-flight pairing poll when the page becomes visible again", async () => {
|
||||||
|
let resolvePairing!: (response: Response) => void;
|
||||||
|
const pendingPairing = new Promise<Response>((resolve) => {
|
||||||
|
resolvePairing = resolve;
|
||||||
|
});
|
||||||
|
const fetchMock = vi.fn((input: RequestInfo | URL) => (
|
||||||
|
String(input) === "/api/settings/pairing"
|
||||||
|
? pendingPairing
|
||||||
|
: Promise.resolve({ ok: false, status: 404 } as Response)
|
||||||
|
));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||||
|
|
||||||
|
const setVisibility = (state: DocumentVisibilityState) => {
|
||||||
|
Object.defineProperty(document, "visibilityState", {
|
||||||
|
configurable: true,
|
||||||
|
value: state,
|
||||||
|
});
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(<App />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||||
|
String(input) === "/api/settings/pairing"
|
||||||
|
))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => setVisibility("hidden"));
|
||||||
|
act(() => setVisibility("visible"));
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||||
|
String(input) === "/api/settings/pairing"
|
||||||
|
))).toHaveLength(1);
|
||||||
|
await act(async () => {
|
||||||
|
resolvePairing(jsonResponse({ requests: [] }));
|
||||||
|
await pendingPairing;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (visibilityDescriptor) {
|
||||||
|
Object.defineProperty(document, "visibilityState", visibilityDescriptor);
|
||||||
|
} else {
|
||||||
|
delete (document as Document & {
|
||||||
|
visibilityState?: DocumentVisibilityState;
|
||||||
|
}).visibilityState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { act, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
||||||
|
import { setAppLanguage } from "@/i18n";
|
||||||
import { fetchFilePreview } from "@/lib/api";
|
import { fetchFilePreview } from "@/lib/api";
|
||||||
|
|
||||||
vi.mock("@/components/CodeBlock", () => ({
|
vi.mock("@/components/CodeBlock", () => ({
|
||||||
@@ -34,7 +35,8 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("FilePreviewPanel", () => {
|
describe("FilePreviewPanel", () => {
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
|
await setAppLanguage("en");
|
||||||
vi.mocked(fetchFilePreview).mockReset();
|
vi.mocked(fetchFilePreview).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,4 +75,32 @@ describe("FilePreviewPanel", () => {
|
|||||||
await user.click(closeButton);
|
await user.click(closeButton);
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates translated chrome without refetching the open file", async () => {
|
||||||
|
vi.mocked(fetchFilePreview).mockResolvedValue({
|
||||||
|
path: "/workspace/notes.md",
|
||||||
|
display_path: "notes.md",
|
||||||
|
language: "markdown",
|
||||||
|
content: "# Notes",
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FilePreviewPanel
|
||||||
|
sessionKey="websocket:chat-1"
|
||||||
|
path="notes.md"
|
||||||
|
token="tok"
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByTestId("mock-code-block");
|
||||||
|
expect(fetchFilePreview).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await setAppLanguage("zh-CN");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchFilePreview).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
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";
|
||||||
|
|
||||||
@@ -141,4 +141,34 @@ describe("SessionInfoPopover", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByText("No automations in this session yet.")).toBeInTheDocument();
|
expect(screen.getByText("No automations in this session yet.")).toBeInTheDocument();
|
||||||
}, 8000);
|
}, 8000);
|
||||||
|
|
||||||
|
it("coalesces focus refreshes while a session automation request is in flight", async () => {
|
||||||
|
let resolveRequest!: (response: Response) => void;
|
||||||
|
const pendingRequest = new Promise<Response>((resolve) => {
|
||||||
|
resolveRequest = resolve;
|
||||||
|
});
|
||||||
|
const fetchMock = vi.fn(() => pendingRequest);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SessionInfoPopover
|
||||||
|
sessionKey="websocket:chat-1"
|
||||||
|
token="tok"
|
||||||
|
title="Release work"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Session details" }));
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
window.dispatchEvent(new Event("focus"));
|
||||||
|
window.dispatchEvent(new Event("focus"));
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
await act(async () => {
|
||||||
|
resolveRequest(automationsResponse([]));
|
||||||
|
await pendingRequest;
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { SettingsView } from "@/components/settings/SettingsView";
|
import { SettingsView } from "@/components/settings/SettingsView";
|
||||||
@@ -439,6 +439,42 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
|
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("coalesces focus refreshes while automations are already loading", async () => {
|
||||||
|
let resolveAutomations!: (response: Response) => void;
|
||||||
|
const pendingAutomations = new Promise<Response>((resolve) => {
|
||||||
|
resolveAutomations = resolve;
|
||||||
|
});
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||||
|
if (url === "/api/webui/automations") return pendingAutomations;
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
renderSettingsView({
|
||||||
|
initialSection: "automations",
|
||||||
|
initialSettings: settingsPayload(),
|
||||||
|
showSidebar: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||||
|
String(input) === "/api/webui/automations"
|
||||||
|
))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
window.dispatchEvent(new Event("focus"));
|
||||||
|
window.dispatchEvent(new Event("focus"));
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||||
|
String(input) === "/api/webui/automations"
|
||||||
|
))).toHaveLength(1);
|
||||||
|
await act(async () => {
|
||||||
|
resolveAutomations(jsonResponse({ jobs: [] }));
|
||||||
|
await pendingAutomations;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("starts the managed API server from System", async () => {
|
it("starts the managed API server from System", async () => {
|
||||||
const base = settingsPayload();
|
const base = settingsPayload();
|
||||||
const stopped = {
|
const stopped = {
|
||||||
@@ -468,7 +504,9 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
|
|
||||||
renderSettingsView({ initialSection: "runtime", initialSettings: base, showSidebar: true });
|
renderSettingsView({ initialSection: "runtime", initialSettings: base, showSidebar: true });
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Start API server" }));
|
const startButton = await screen.findByRole("button", { name: "Start API server" });
|
||||||
|
await waitFor(() => expect(startButton).toBeEnabled());
|
||||||
|
fireEvent.click(startButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
@@ -1968,6 +2006,53 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
expect(screen.queryByText("Peak tokens")).not.toBeInTheDocument();
|
expect(screen.queryByText("Peak tokens")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("coalesces focus refreshes while usage is already loading", async () => {
|
||||||
|
const payload: SettingsPayload = {
|
||||||
|
...settingsPayload(),
|
||||||
|
usage: {
|
||||||
|
days: [],
|
||||||
|
total_tokens: 0,
|
||||||
|
total_tokens_30d: 0,
|
||||||
|
total_tokens_365d: 0,
|
||||||
|
peak_day_tokens: 0,
|
||||||
|
current_streak_days: 0,
|
||||||
|
longest_streak_days: 0,
|
||||||
|
active_days_30d: 0,
|
||||||
|
requests_30d: 0,
|
||||||
|
updated_at: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let resolveUsage!: (response: Response) => void;
|
||||||
|
const pendingUsage = new Promise<Response>((resolve) => {
|
||||||
|
resolveUsage = resolve;
|
||||||
|
});
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings") return jsonResponse(payload);
|
||||||
|
if (url === "/api/settings/usage") return pendingUsage;
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
renderSettingsView({ initialSection: "overview", initialSettings: payload });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||||
|
String(input) === "/api/settings/usage"
|
||||||
|
))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
window.dispatchEvent(new Event("focus"));
|
||||||
|
window.dispatchEvent(new Event("focus"));
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||||
|
String(input) === "/api/settings/usage"
|
||||||
|
))).toHaveLength(1);
|
||||||
|
await act(async () => {
|
||||||
|
resolveUsage(jsonResponse(payload.usage));
|
||||||
|
await pendingUsage;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("aligns token activity days with the configured timezone", async () => {
|
it("aligns token activity days with the configured timezone", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date("2026-06-02T18:00:00Z"));
|
vi.setSystemTime(new Date("2026-06-02T18:00:00Z"));
|
||||||
@@ -2668,6 +2753,219 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("polls local OpenAI Codex sign-in until the loopback callback completes", async () => {
|
||||||
|
const base = settingsPayload();
|
||||||
|
const codexProvider = {
|
||||||
|
name: "openai_codex",
|
||||||
|
label: "OpenAI Codex",
|
||||||
|
configured: false,
|
||||||
|
auth_type: "oauth" as const,
|
||||||
|
api_key_required: false,
|
||||||
|
api_key_hint: null,
|
||||||
|
api_base: null,
|
||||||
|
default_api_base: "https://chatgpt.com/backend-api",
|
||||||
|
model_catalog: "builtin",
|
||||||
|
oauth_account: null,
|
||||||
|
oauth_expires_at: null,
|
||||||
|
oauth_login_supported: true,
|
||||||
|
};
|
||||||
|
const payload: SettingsPayload = { ...base, providers: [codexProvider] };
|
||||||
|
const signedIn: SettingsPayload = {
|
||||||
|
...payload,
|
||||||
|
providers: [{ ...codexProvider, configured: true, oauth_account: "acct-codex" }],
|
||||||
|
};
|
||||||
|
const authorization = {
|
||||||
|
status: "authorization_required",
|
||||||
|
provider: "openai_codex",
|
||||||
|
flow_id: "flow-codex-local",
|
||||||
|
authorization_url: "https://auth.openai.com/oauth/authorize?state=local",
|
||||||
|
expires_in: 600,
|
||||||
|
completion_input: "callback_url",
|
||||||
|
};
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings") return jsonResponse(payload);
|
||||||
|
if (url === "/api/settings/provider/oauth-login?provider=openai_codex") {
|
||||||
|
return jsonResponse(authorization);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
url ===
|
||||||
|
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex-local"
|
||||||
|
) {
|
||||||
|
expect(init?.headers).not.toHaveProperty("X-Nanobot-OAuth-Callback");
|
||||||
|
return jsonResponse(signedIn);
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/cli-apps") {
|
||||||
|
return jsonResponse({ apps: [], installed_count: 0 });
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/mcp-presets") {
|
||||||
|
return jsonResponse({ presets: [], installed_count: 0 });
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const openMock = vi.fn();
|
||||||
|
vi.stubGlobal("open", openMock);
|
||||||
|
|
||||||
|
renderSettingsView({ initialSection: "models", initialSettings: payload });
|
||||||
|
|
||||||
|
await chooseProviderToConfigure("OpenAI Codex");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/settings/provider/oauth-login?provider=openai_codex",
|
||||||
|
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||||
|
);
|
||||||
|
expect(openMock).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
within(dialog).getByText(
|
||||||
|
"Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, copy the full localhost callback URL from the address bar and paste it below.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByText("Waiting for the browser callback…")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(dialog).queryByText("Paste the callback URL to continue."),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Signed in as acct-codex", {}, { timeout: 2500 }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes remote OpenAI Codex sign-in with the full callback URL", async () => {
|
||||||
|
const happyWindow = window as typeof window & {
|
||||||
|
happyDOM: { setURL: (url: string) => void };
|
||||||
|
};
|
||||||
|
const originalUrl = window.location.href;
|
||||||
|
happyWindow.happyDOM.setURL("http://203.0.113.10:18887/#/settings?section=models");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = settingsPayload();
|
||||||
|
const codexProvider = {
|
||||||
|
name: "openai_codex",
|
||||||
|
label: "OpenAI Codex",
|
||||||
|
configured: false,
|
||||||
|
auth_type: "oauth" as const,
|
||||||
|
api_key_required: false,
|
||||||
|
api_key_hint: null,
|
||||||
|
api_base: null,
|
||||||
|
default_api_base: "https://chatgpt.com/backend-api",
|
||||||
|
model_catalog: "builtin",
|
||||||
|
oauth_account: null,
|
||||||
|
oauth_expires_at: null,
|
||||||
|
oauth_login_supported: true,
|
||||||
|
};
|
||||||
|
const payload: SettingsPayload = { ...base, providers: [codexProvider] };
|
||||||
|
const signedIn: SettingsPayload = {
|
||||||
|
...payload,
|
||||||
|
providers: [{ ...codexProvider, configured: true, oauth_account: "acct-codex" }],
|
||||||
|
};
|
||||||
|
const authorization = {
|
||||||
|
status: "authorization_required",
|
||||||
|
provider: "openai_codex",
|
||||||
|
flow_id: "flow-codex",
|
||||||
|
authorization_url: "https://auth.openai.com/oauth/authorize?state=test",
|
||||||
|
expires_in: 600,
|
||||||
|
completion_input: "callback_url",
|
||||||
|
};
|
||||||
|
const callbackUrl =
|
||||||
|
"http://localhost:1455/auth/callback?code=secret&state=test";
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings") return jsonResponse(payload);
|
||||||
|
if (
|
||||||
|
url ===
|
||||||
|
"/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true"
|
||||||
|
) {
|
||||||
|
return jsonResponse(authorization);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
url ===
|
||||||
|
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex"
|
||||||
|
) {
|
||||||
|
const headers = init?.headers as Record<string, string>;
|
||||||
|
if (headers?.["X-Nanobot-OAuth-Callback"]) {
|
||||||
|
expect(headers["X-Nanobot-OAuth-Callback"]).toBe(callbackUrl);
|
||||||
|
return jsonResponse(signedIn);
|
||||||
|
}
|
||||||
|
return jsonResponse({
|
||||||
|
status: "pending",
|
||||||
|
provider: "openai_codex",
|
||||||
|
flow_id: "flow-codex",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/cli-apps") {
|
||||||
|
return jsonResponse({ apps: [], installed_count: 0 });
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/mcp-presets") {
|
||||||
|
return jsonResponse({ presets: [], installed_count: 0 });
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const popup = {
|
||||||
|
opener: window,
|
||||||
|
location: { href: "about:blank" },
|
||||||
|
close: vi.fn(),
|
||||||
|
};
|
||||||
|
const openMock = vi.fn(() => popup);
|
||||||
|
vi.stubGlobal("open", openMock);
|
||||||
|
|
||||||
|
renderSettingsView({ initialSection: "models", initialSettings: payload });
|
||||||
|
|
||||||
|
await chooseProviderToConfigure("OpenAI Codex");
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"Sign in through this browser, then paste the full localhost callback URL back into nanobot.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
|
||||||
|
expect(openMock).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
within(dialog).getByText(
|
||||||
|
"Open ChatGPT in this browser and finish signing in. When the localhost page fails to load, copy the full URL from the address bar and paste it below.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByText("Paste the callback URL to continue.")).toBeInTheDocument();
|
||||||
|
const callbackInput = within(dialog).getByRole("textbox", {
|
||||||
|
name: "Full callback URL",
|
||||||
|
});
|
||||||
|
expect(callbackInput).toHaveAttribute(
|
||||||
|
"placeholder",
|
||||||
|
"http://localhost:1455/auth/callback?code=…&state=…",
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Open ChatGPT" }));
|
||||||
|
expect(openMock).toHaveBeenCalledWith(
|
||||||
|
authorization.authorization_url,
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer",
|
||||||
|
);
|
||||||
|
expect(popup.opener).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.change(callbackInput, { target: { value: callbackUrl } });
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Finish sign-in" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex",
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
"X-Nanobot-OAuth-Callback": callbackUrl,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("Signed in as acct-codex")).toBeInTheDocument();
|
||||||
|
} finally {
|
||||||
|
happyWindow.happyDOM.setURL(originalUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("saves scoped proxies for xAI and OpenAI Codex OAuth providers", async () => {
|
it("saves scoped proxies for xAI and OpenAI Codex OAuth providers", async () => {
|
||||||
const base = settingsPayload();
|
const base = settingsPayload();
|
||||||
const providers: SettingsPayload["providers"] = [
|
const providers: SettingsPayload["providers"] = [
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { SkillsMarketplace } from "@/components/settings/SkillsMarketplace";
|
||||||
|
import {
|
||||||
|
fetchSkills,
|
||||||
|
fetchTrendingMarketplaceSkills,
|
||||||
|
searchMarketplaceSkills,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import type { NanobotClient } from "@/lib/nanobot-client";
|
||||||
|
import { SKILLS_CHANGED_EVENT } from "@/lib/skill-events";
|
||||||
|
import { ClientProvider } from "@/providers/ClientProvider";
|
||||||
|
import { useSkills } from "@/hooks/useSkills";
|
||||||
|
|
||||||
|
vi.mock("@/lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
fetchSkills: vi.fn(),
|
||||||
|
fetchTrendingMarketplaceSkills: vi.fn(),
|
||||||
|
searchMarketplaceSkills: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = {} as NanobotClient;
|
||||||
|
|
||||||
|
function marketplace(token: string) {
|
||||||
|
return (
|
||||||
|
<ClientProvider client={client} token={token}>
|
||||||
|
<SkillsMarketplace
|
||||||
|
installedSkills={[]}
|
||||||
|
installing=""
|
||||||
|
onInstallingChange={() => {}}
|
||||||
|
/>
|
||||||
|
</ClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useSkills", () => {
|
||||||
|
it("does not let an older request overwrite a newer skill event", async () => {
|
||||||
|
let resolveSkills!: (value: Awaited<ReturnType<typeof fetchSkills>>) => void;
|
||||||
|
vi.mocked(fetchSkills).mockReset().mockImplementationOnce(
|
||||||
|
() => new Promise((resolve) => {
|
||||||
|
resolveSkills = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const installed = {
|
||||||
|
name: "react-testing",
|
||||||
|
description: "Test React apps.",
|
||||||
|
source: "workspace",
|
||||||
|
available: true,
|
||||||
|
};
|
||||||
|
const getToken = () => "tok";
|
||||||
|
const { result } = renderHook(() => useSkills(getToken));
|
||||||
|
|
||||||
|
expect(fetchSkills).toHaveBeenCalledTimes(1);
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new CustomEvent(SKILLS_CHANGED_EVENT, {
|
||||||
|
detail: { skills: [installed] },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
expect(result.current).toEqual([installed]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveSkills({ skills: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toEqual([installed]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SkillsMarketplace", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.mocked(fetchTrendingMarketplaceSkills).mockReset().mockResolvedValue({
|
||||||
|
period: "mixed",
|
||||||
|
provider: "all",
|
||||||
|
install_supported: true,
|
||||||
|
skills: [],
|
||||||
|
});
|
||||||
|
vi.mocked(searchMarketplaceSkills).mockReset().mockImplementation(
|
||||||
|
async (_token, query) => ({
|
||||||
|
query,
|
||||||
|
provider: "all",
|
||||||
|
install_supported: true,
|
||||||
|
skills: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps loaded marketplace data stable when the auth token rotates", async () => {
|
||||||
|
const { rerender } = render(marketplace("tok-old"));
|
||||||
|
|
||||||
|
await act(async () => {});
|
||||||
|
expect(fetchTrendingMarketplaceSkills).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchTrendingMarketplaceSkills).toHaveBeenCalledWith("tok-old");
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Search skills" }), {
|
||||||
|
target: { value: "React" },
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
});
|
||||||
|
expect(searchMarketplaceSkills).toHaveBeenCalledTimes(1);
|
||||||
|
expect(searchMarketplaceSkills).toHaveBeenLastCalledWith("tok-old", "React");
|
||||||
|
|
||||||
|
rerender(marketplace("tok-new"));
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchTrendingMarketplaceSkills).toHaveBeenCalledTimes(1);
|
||||||
|
expect(searchMarketplaceSkills).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Search skills" }), {
|
||||||
|
target: { value: "Vue" },
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
});
|
||||||
|
expect(searchMarketplaceSkills).toHaveBeenCalledTimes(2);
|
||||||
|
expect(searchMarketplaceSkills).toHaveBeenLastCalledWith("tok-new", "Vue");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -180,11 +180,16 @@ function makeClient() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrap(client: ReturnType<typeof makeClient>, children: ReactNode, modelName?: string | null) {
|
function wrap(
|
||||||
|
client: ReturnType<typeof makeClient>,
|
||||||
|
children: ReactNode,
|
||||||
|
modelName?: string | null,
|
||||||
|
token = "tok",
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<ClientProvider
|
<ClientProvider
|
||||||
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
|
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
|
||||||
token="tok"
|
token={token}
|
||||||
modelName={modelName ?? null}
|
modelName={modelName ?? null}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -241,6 +246,26 @@ function httpJson(body: unknown) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setDocumentVisibility(value: DocumentVisibilityState): void {
|
||||||
|
Object.defineProperty(document, "visibilityState", {
|
||||||
|
configurable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDocumentVisibility(
|
||||||
|
descriptor: PropertyDescriptor | undefined,
|
||||||
|
): void {
|
||||||
|
if (descriptor) {
|
||||||
|
Object.defineProperty(document, "visibilityState", descriptor);
|
||||||
|
} else {
|
||||||
|
delete (document as Document & {
|
||||||
|
visibilityState?: DocumentVisibilityState;
|
||||||
|
}).visibilityState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ThreadResizeObserverInstance {
|
interface ThreadResizeObserverInstance {
|
||||||
elements: Element[];
|
elements: Element[];
|
||||||
callback: ResizeObserverCallback;
|
callback: ResizeObserverCallback;
|
||||||
@@ -1758,7 +1783,7 @@ describe("ThreadShell", () => {
|
|||||||
expect(screen.getByText("row from the expired latest window")).toBeInTheDocument(),
|
expect(screen.getByText("row from the expired latest window")).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("window-reset-chat", "thread"));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByText("answer in the new latest window")).toBeInTheDocument(),
|
expect(screen.getByText("answer in the new latest window")).toBeInTheDocument(),
|
||||||
@@ -1776,7 +1801,7 @@ describe("ThreadShell", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recovers an uncommitted reset lineage on the next foreground hydrate", async () => {
|
it("recovers an uncommitted reset lineage on the next canonical hydrate", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
let chatACalls = 0;
|
let chatACalls = 0;
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
@@ -1826,7 +1851,7 @@ describe("ThreadShell", () => {
|
|||||||
expect(screen.getByText("committed old lineage")).toBeInTheDocument();
|
expect(screen.getByText("committed old lineage")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("disjoint new lineage")).not.toBeInTheDocument();
|
expect(screen.queryByText("disjoint new lineage")).not.toBeInTheDocument();
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("lineage-chat-a", "thread"));
|
||||||
|
|
||||||
await waitFor(() => expect(chatACalls).toBe(3));
|
await waitFor(() => expect(chatACalls).toBe(3));
|
||||||
await waitFor(() => expect(screen.getByText("disjoint new lineage")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("disjoint new lineage")).toBeInTheDocument());
|
||||||
@@ -1874,7 +1899,7 @@ describe("ThreadShell", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("old canonical row")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("old canonical row")).toBeInTheDocument());
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("reset-tail-race", "thread"));
|
||||||
await waitFor(() => expect(historyCalls).toBe(2));
|
await waitFor(() => expect(historyCalls).toBe(2));
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -1938,7 +1963,7 @@ describe("ThreadShell", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||||
await waitFor(() => expect(screen.getByText("rejected local turn")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("rejected local turn")).toBeInTheDocument());
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("empty-reset-chat", "thread"));
|
||||||
|
|
||||||
await waitFor(() => expect(historyCalls).toBe(2));
|
await waitFor(() => expect(historyCalls).toBe(2));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -2023,7 +2048,7 @@ describe("ThreadShell", () => {
|
|||||||
});
|
});
|
||||||
canonicalComplete = true;
|
canonicalComplete = true;
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("strict-canonical", "thread"));
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("strict canonical answer")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("strict canonical answer")).toBeInTheDocument());
|
||||||
expect(client.reconcileCanonicalCompletion).toHaveBeenCalledTimes(1);
|
expect(client.reconcileCanonicalCompletion).toHaveBeenCalledTimes(1);
|
||||||
@@ -2094,7 +2119,7 @@ describe("ThreadShell", () => {
|
|||||||
.mockImplementationOnce(() => false)
|
.mockImplementationOnce(() => false)
|
||||||
.mockImplementation((...args) => reconcileAfterReject?.(...args) ?? false);
|
.mockImplementation((...args) => reconcileAfterReject?.(...args) ?? false);
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("layout-recheck", "thread"));
|
||||||
|
|
||||||
await waitFor(() => expect(historyCalls).toBe(2));
|
await waitFor(() => expect(historyCalls).toBe(2));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -2104,7 +2129,7 @@ describe("ThreadShell", () => {
|
|||||||
expect(screen.queryByText("layout canonical answer")).not.toBeInTheDocument();
|
expect(screen.queryByText("layout canonical answer")).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("layout-recheck", "thread"));
|
||||||
|
|
||||||
await waitFor(() => expect(historyCalls).toBe(3));
|
await waitFor(() => expect(historyCalls).toBe(3));
|
||||||
await waitFor(() => expect(screen.getByText("layout canonical answer")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("layout canonical answer")).toBeInTheDocument());
|
||||||
@@ -2258,7 +2283,7 @@ describe("ThreadShell", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument());
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("run-generation-chat", "thread"));
|
||||||
await waitFor(() => expect(historyCalls).toBe(2));
|
await waitFor(() => expect(historyCalls).toBe(2));
|
||||||
|
|
||||||
const newTurnId = "turn-started-during-refresh";
|
const newTurnId = "turn-started-during-refresh";
|
||||||
@@ -2351,7 +2376,7 @@ describe("ThreadShell", () => {
|
|||||||
});
|
});
|
||||||
await waitFor(() => expect(screen.getByText("partial")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("partial")).toBeInTheDocument());
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("late-frame-chat", "thread"));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByText("canonical complete answer")).toBeInTheDocument(),
|
expect(screen.getByText("canonical complete answer")).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
@@ -2435,7 +2460,7 @@ describe("ThreadShell", () => {
|
|||||||
});
|
});
|
||||||
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("visibility-complete-a", "thread"));
|
||||||
await waitFor(() => expect(screen.getByText("completed while hidden")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("completed while hidden")).toBeInTheDocument());
|
||||||
expect(screen.queryByRole("button", { name: "Stop response" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Stop response" })).not.toBeInTheDocument();
|
||||||
expect(client.getRunStartedAt("visibility-complete-a")).toBeNull();
|
expect(client.getRunStartedAt("visibility-complete-a")).toBeNull();
|
||||||
@@ -2495,7 +2520,7 @@ describe("ThreadShell", () => {
|
|||||||
});
|
});
|
||||||
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("empty-answer", "thread"));
|
||||||
|
|
||||||
await waitFor(() => expect(historyCalls).toBe(2));
|
await waitFor(() => expect(historyCalls).toBe(2));
|
||||||
await waitFor(() => expect(client.reconcileCanonicalCompletion).toHaveBeenCalledWith(
|
await waitFor(() => expect(client.reconcileCanonicalCompletion).toHaveBeenCalledWith(
|
||||||
@@ -2717,6 +2742,7 @@ describe("ThreadShell", () => {
|
|||||||
it("refreshes the current thread when the page returns to the foreground", async () => {
|
it("refreshes the current thread when the page returns to the foreground", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
let historyCalls = 0;
|
let historyCalls = 0;
|
||||||
|
const turnId = "turn-visible-chat";
|
||||||
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@@ -2724,16 +2750,22 @@ describe("ThreadShell", () => {
|
|||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url.includes("websocket%3Avisible-chat/webui-thread")) {
|
if (url.includes("websocket%3Avisible-chat/webui-thread")) {
|
||||||
historyCalls += 1;
|
historyCalls += 1;
|
||||||
return httpJson(
|
return httpJson({
|
||||||
transcriptFromSimpleMessages(
|
...transcriptFromSimpleMessages(
|
||||||
historyCalls === 1
|
historyCalls === 1
|
||||||
? [{ role: "user", content: "question" }]
|
? [{ role: "user", content: "question", turnId }]
|
||||||
: [
|
: [
|
||||||
{ role: "user", content: "question" },
|
{ role: "user", content: "question", turnId },
|
||||||
{ role: "assistant", content: "answer completed in background" },
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: "answer completed in background",
|
||||||
|
turnId,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
has_pending_tool_calls: historyCalls === 1,
|
||||||
|
completed_turn_ids: historyCalls === 1 ? [] : [turnId],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -2757,22 +2789,23 @@ describe("ThreadShell", () => {
|
|||||||
);
|
);
|
||||||
await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument());
|
||||||
expect(historyCalls).toBe(1);
|
expect(historyCalls).toBe(1);
|
||||||
|
act(() => {
|
||||||
|
client._emitChat("visible-chat", {
|
||||||
|
event: "goal_status",
|
||||||
|
chat_id: "visible-chat",
|
||||||
|
status: "running",
|
||||||
|
started_at: 6_000,
|
||||||
|
turn_id: turnId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
Object.defineProperty(document, "visibilityState", {
|
setDocumentVisibility("hidden");
|
||||||
configurable: true,
|
|
||||||
value: "hidden",
|
|
||||||
});
|
|
||||||
document.dispatchEvent(new Event("visibilitychange"));
|
|
||||||
});
|
});
|
||||||
expect(historyCalls).toBe(1);
|
expect(historyCalls).toBe(1);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
Object.defineProperty(document, "visibilityState", {
|
setDocumentVisibility("visible");
|
||||||
configurable: true,
|
|
||||||
value: "visible",
|
|
||||||
});
|
|
||||||
document.dispatchEvent(new Event("visibilitychange"));
|
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2781,11 +2814,114 @@ describe("ThreadShell", () => {
|
|||||||
expect(screen.getByText("answer completed in background")).toBeInTheDocument(),
|
expect(screen.getByText("answer completed in background")).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (visibilityDescriptor) {
|
restoreDocumentVisibility(visibilityDescriptor);
|
||||||
Object.defineProperty(document, "visibilityState", visibilityDescriptor);
|
}
|
||||||
} else {
|
});
|
||||||
delete (document as Document & { visibilityState?: DocumentVisibilityState }).visibilityState;
|
|
||||||
}
|
it("does not refresh an idle thread for visibility notifications", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
let historyCalls = 0;
|
||||||
|
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
if (String(input).includes("websocket%3Aidle-visible-chat/webui-thread")) {
|
||||||
|
historyCalls += 1;
|
||||||
|
return httpJson(transcriptFromSimpleMessages([
|
||||||
|
{ role: "assistant", content: "settled answer" },
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(
|
||||||
|
wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("idle-visible-chat")}
|
||||||
|
title="Idle visible chat"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
onNewChat={() => {}}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.getByText("settled answer")).toBeInTheDocument());
|
||||||
|
|
||||||
|
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||||
|
act(() => {
|
||||||
|
setDocumentVisibility("hidden");
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
setDocumentVisibility("visible");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(historyCalls).toBe(1);
|
||||||
|
} finally {
|
||||||
|
restoreDocumentVisibility(visibilityDescriptor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries a failed hydration when the page returns to the foreground", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
let historyCalls = 0;
|
||||||
|
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
if (String(input).includes("websocket%3Aretry-visible-chat/webui-thread")) {
|
||||||
|
historyCalls += 1;
|
||||||
|
if (historyCalls === 1) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return httpJson(transcriptFromSimpleMessages([
|
||||||
|
{ role: "assistant", content: "recovered answer" },
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(
|
||||||
|
wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("retry-visible-chat")}
|
||||||
|
title="Retry visible chat"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
onNewChat={() => {}}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(historyCalls).toBe(1));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setDocumentVisibility("hidden");
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
setDocumentVisibility("visible");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(historyCalls).toBe(2));
|
||||||
|
expect(await screen.findByText("recovered answer")).toBeInTheDocument();
|
||||||
|
} finally {
|
||||||
|
restoreDocumentVisibility(visibilityDescriptor);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2847,7 +2983,7 @@ describe("ThreadShell", () => {
|
|||||||
expect(historyCalls).toBe(1);
|
expect(historyCalls).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not refetch thread history for metadata-only session updates", async () => {
|
it("keeps rendered media mounted for metadata-only session updates", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
let historyCalls = 0;
|
let historyCalls = 0;
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
@@ -2856,12 +2992,16 @@ describe("ThreadShell", () => {
|
|||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url.includes("websocket%3Achat-a/webui-thread")) {
|
if (url.includes("websocket%3Achat-a/webui-thread")) {
|
||||||
historyCalls += 1;
|
historyCalls += 1;
|
||||||
return httpJson(
|
const thread = transcriptFromSimpleMessages([
|
||||||
transcriptFromSimpleMessages([
|
{ role: "user", content: "question" },
|
||||||
{ role: "user", content: "question" },
|
{ role: "assistant", content: "answer" },
|
||||||
{ role: "assistant", content: "answer" },
|
]);
|
||||||
]),
|
thread.messages[1]!.media = [{
|
||||||
);
|
kind: "image",
|
||||||
|
url: "/api/media/stable/image",
|
||||||
|
name: "answer.png",
|
||||||
|
}];
|
||||||
|
return httpJson(thread);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -2884,6 +3024,7 @@ describe("ThreadShell", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("answer")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("answer")).toBeInTheDocument());
|
||||||
|
const image = screen.getByRole("img", { name: "answer.png" });
|
||||||
expect(historyCalls).toBe(1);
|
expect(historyCalls).toBe(1);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -2891,6 +3032,56 @@ describe("ThreadShell", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(historyCalls).toBe(1);
|
expect(historyCalls).toBe(1);
|
||||||
|
expect(screen.getByRole("img", { name: "answer.png" })).toBe(image);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps rendered media mounted when the auth token rotates", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
let historyCalls = 0;
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
if (String(input).includes("websocket%3Atoken-media/webui-thread")) {
|
||||||
|
historyCalls += 1;
|
||||||
|
const thread = transcriptFromSimpleMessages([
|
||||||
|
{ role: "user", content: "question" },
|
||||||
|
{ role: "assistant", content: "answer" },
|
||||||
|
]);
|
||||||
|
thread.messages[1]!.media = [{
|
||||||
|
kind: "image",
|
||||||
|
url: "/api/media/stable/token-image",
|
||||||
|
name: "token-answer.png",
|
||||||
|
}];
|
||||||
|
return httpJson(thread);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const view = (token: string) => wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("token-media")}
|
||||||
|
title="Token media"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
onNewChat={() => {}}
|
||||||
|
/>,
|
||||||
|
null,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
const { rerender } = render(view("tok-old"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("answer")).toBeInTheDocument());
|
||||||
|
const image = screen.getByRole("img", { name: "token-answer.png" });
|
||||||
|
|
||||||
|
rerender(view("tok-new"));
|
||||||
|
await act(async () => Promise.resolve());
|
||||||
|
|
||||||
|
expect(historyCalls).toBe(1);
|
||||||
|
expect(screen.getByRole("img", { name: "token-answer.png" })).toBe(image);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not scroll again when canonical history refreshes after a session update", async () => {
|
it("does not scroll again when canonical history refreshes after a session update", async () => {
|
||||||
@@ -3454,6 +3645,68 @@ describe("ThreadShell", () => {
|
|||||||
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
|
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not let an older catalog request overwrite a newer install event", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
let resolveCatalog!: (response: Response) => void;
|
||||||
|
const pendingCatalog = new Promise<Response>((resolve) => {
|
||||||
|
resolveCatalog = resolve;
|
||||||
|
});
|
||||||
|
vi.mocked(fetch).mockImplementation((input) => {
|
||||||
|
if (String(input).includes("/api/settings/cli-apps?installed_only=1")) {
|
||||||
|
return pendingCatalog;
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
});
|
||||||
|
render(wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("chat-cli-race")}
|
||||||
|
title="Chat chat-cli-race"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
onGoHome={() => {}}
|
||||||
|
onNewChat={() => {}}
|
||||||
|
/>,
|
||||||
|
));
|
||||||
|
|
||||||
|
const input = await screen.findByLabelText("Message input");
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/settings/cli-apps?installed_only=1",
|
||||||
|
expect.anything(),
|
||||||
|
));
|
||||||
|
const payload: CliAppsPayload = {
|
||||||
|
apps: [{
|
||||||
|
name: "gimp",
|
||||||
|
display_name: "GIMP",
|
||||||
|
category: "image",
|
||||||
|
description: "Image editing",
|
||||||
|
requires: "",
|
||||||
|
source: "harness",
|
||||||
|
entry_point: "cli-anything-gimp",
|
||||||
|
install_supported: true,
|
||||||
|
installed: true,
|
||||||
|
available: true,
|
||||||
|
status: "installed",
|
||||||
|
logo_url: null,
|
||||||
|
brand_color: "#5C5543",
|
||||||
|
skill_installed: true,
|
||||||
|
}],
|
||||||
|
installed_count: 1,
|
||||||
|
catalog_updated_at: "2026-07-30",
|
||||||
|
};
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(new CustomEvent(CLI_APPS_CHANGED_EVENT, { detail: payload }));
|
||||||
|
resolveCatalog(httpJson({ apps: [], installed_count: 0 }));
|
||||||
|
await pendingCatalog;
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
||||||
|
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps installed app mentions available during transient catalog refresh failures", async () => {
|
it("keeps installed app mentions available during transient catalog refresh failures", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
const payload: CliAppsPayload = {
|
const payload: CliAppsPayload = {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user