mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
perf(tui): reduce cold-start latency
This commit is contained in:
@@ -121,9 +121,9 @@ nanobot sessions restore-workspace --config ./bot-a/config.json --workspace ./bo
|
||||
The command never deletes the external store and refuses to overwrite a different existing
|
||||
workspace file. Back up both the config directory and workspace before changing versions.
|
||||
|
||||
Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, either client starts it on demand. Exiting one TUI or WebUI launcher releases only that client; the last interactive launcher stops the on-demand gateway. A small gateway watchdog also reclaims an on-demand process if its last client crashes. Only an explicit `nanobot gateway --background` promotes it to persistent mode. `nanobot gateway restart` restarts a detached gateway without changing that lifetime; restart an attached foreground gateway in its owning terminal. `nanobot gateway stop` ends either mode.
|
||||
Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, either client starts it on demand. The TUI paints immediately while the local gateway starts, then obtains fresh bootstrap credentials and connects in the background. Exiting one TUI or WebUI launcher releases only that client; the last interactive launcher stops the on-demand gateway. A small gateway watchdog also reclaims an on-demand process if its last client crashes. Only an explicit `nanobot gateway --background` promotes it to persistent mode. `nanobot gateway restart` restarts a detached gateway without changing that lifetime; restart an attached foreground gateway in its owning terminal. `nanobot gateway stop` ends either mode.
|
||||
|
||||
The default `--theme auto` mode probes the terminal's real foreground and background colors before first paint and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably. The model preset and workspace access labels above the composer can be clicked to open their selectors; arrow keys, `Enter`, and `Esc` provide the same controls without a mouse. Access changes still pass through the gateway's local-trust and active-turn policy checks.
|
||||
The default `--theme auto` mode paints first with the terminal's default background, probes the real foreground and background colors asynchronously, and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably. The model preset and workspace access labels above the composer can be clicked to open their selectors; arrow keys, `Enter`, and `Esc` provide the same controls without a mouse. Access changes still pass through the gateway's local-trust and active-turn policy checks.
|
||||
|
||||
`Enter` sends the current message. While a turn is active, `Enter` steers it immediately, `Tab` queues a visible follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on Windows/Linux) returns the latest queued message to the composer. Press `Shift+Enter` to add a newline; `Ctrl+J` is the universal fallback when a terminal cannot distinguish modified Enter keys. `Alt+Enter` and `Ctrl+Enter` are also accepted when distinguishable. Use `Up`/`Down` at the composer edge to recall prompts from the current saved session. Large pastes appear as a compact placeholder in the composer but are sent unchanged. Type `/` to discover nanobot commands and terminal navigation in one palette, or type `@` to complete installed apps, configured MCP servers, and saved sessions. Use the arrow keys to choose an item and `Tab` to complete it. `/sessions` opens a searchable conversation picker, `/new-chat` preserves the current conversation and starts another one, and `/branch` forks from a completed reply. `/diff` opens a read-only unified diff for the newest turn; use `Left`/`Right` to switch edits and `Esc` to close it. The core `/new` command retains its cross-channel behavior and resets the current chat. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. When you leave the bottom, the TUI shows a scrollbar and a `Ctrl+End` hint until you return. The footer reports provider token/cache usage when available. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen.
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
Entry point for running nanobot as a module: python -m nanobot
|
||||
"""
|
||||
|
||||
from nanobot.cli.commands import app
|
||||
from nanobot.cli.entry import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
main()
|
||||
|
||||
+61
-34
@@ -1,6 +1,7 @@
|
||||
"""Direct and interactive agent CLI command."""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import signal
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable
|
||||
@@ -11,17 +12,6 @@ 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.agent.tools.mcp import MCPProvider
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
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,
|
||||
@@ -29,22 +19,37 @@ from nanobot.cli.runtime_config import (
|
||||
_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()
|
||||
|
||||
_CLASSIC_DEPENDENCIES = {
|
||||
"AgentLoop": ("nanobot.agent.loop", "AgentLoop"),
|
||||
"StreamRenderer": ("nanobot.cli.stream", "StreamRenderer"),
|
||||
"consume_restart_notice_from_env": (
|
||||
"nanobot.utils.restart",
|
||||
"consume_restart_notice_from_env",
|
||||
),
|
||||
"is_default_workspace": ("nanobot.config.paths", "is_default_workspace"),
|
||||
"sync_workspace_templates": ("nanobot.utils.helpers", "sync_workspace_templates"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Preserve patchable classic-agent symbols without loading them for the TUI."""
|
||||
dependency = _CLASSIC_DEPENDENCIES.get(name)
|
||||
if dependency is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module_name, attribute = dependency
|
||||
value = getattr(importlib.import_module(module_name), attribute)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def _classic_dependency(name: str) -> Any:
|
||||
if name in globals():
|
||||
return globals()[name]
|
||||
return __getattr__(name)
|
||||
|
||||
|
||||
def agent(
|
||||
message: str | None = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
|
||||
@@ -74,11 +79,6 @@ def agent(
|
||||
),
|
||||
):
|
||||
"""Chat in the terminal or send one message non-interactively."""
|
||||
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)
|
||||
theme = theme.strip().lower()
|
||||
if theme not in {"auto", "dark", "light"}:
|
||||
@@ -117,6 +117,33 @@ def agent(
|
||||
raise typer.Exit(exit_code)
|
||||
return
|
||||
|
||||
from nanobot.agent.hooks import create_file_edit_activity_hook
|
||||
from nanobot.agent.tools.mcp import MCPProvider
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.bus.outbound_events import (
|
||||
StreamDeltaEvent,
|
||||
StreamedResponseEvent,
|
||||
StreamEndEvent,
|
||||
outbound_event_from_message,
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.cli import terminal as cli_terminal
|
||||
from nanobot.cli.stream import ThinkingSpinner
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.providers.factory import make_provider
|
||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||
from nanobot.utils.helpers import sanitize_surrogates as _sanitize_surrogates
|
||||
from nanobot.utils.restart import (
|
||||
format_restart_completed_message,
|
||||
should_show_cli_restart_notice,
|
||||
)
|
||||
|
||||
agent_loop_class = _classic_dependency("AgentLoop")
|
||||
stream_renderer_class = _classic_dependency("StreamRenderer")
|
||||
consume_restart_notice_from_env = _classic_dependency("consume_restart_notice_from_env")
|
||||
is_default_workspace = _classic_dependency("is_default_workspace")
|
||||
sync_workspace_templates = _classic_dependency("sync_workspace_templates")
|
||||
|
||||
session_id = session_id or "cli:direct"
|
||||
|
||||
try:
|
||||
@@ -142,7 +169,7 @@ def agent(
|
||||
_set_nanobot_logs(logs)
|
||||
|
||||
try:
|
||||
agent_loop = AgentLoop.from_config(
|
||||
agent_loop = agent_loop_class.from_config(
|
||||
runtime_config,
|
||||
bus,
|
||||
provider=provider,
|
||||
@@ -171,7 +198,7 @@ def agent(
|
||||
_thinking: ThinkingSpinner | None = None
|
||||
|
||||
def _make_progress(
|
||||
renderer: StreamRenderer | None = None,
|
||||
renderer: Any | None = None,
|
||||
) -> Callable[..., Awaitable[None]]:
|
||||
reasoning_buffer = cli_terminal._ReasoningBuffer()
|
||||
|
||||
@@ -212,7 +239,7 @@ def agent(
|
||||
async def run_once() -> None:
|
||||
try:
|
||||
await mcp_provider.connect()
|
||||
renderer = StreamRenderer(
|
||||
renderer = stream_renderer_class(
|
||||
render_markdown=markdown,
|
||||
bot_name=runtime_config.agents.defaults.bot_name,
|
||||
bot_icon=runtime_config.agents.defaults.bot_icon,
|
||||
@@ -278,7 +305,7 @@ def agent(
|
||||
turn_done = asyncio.Event()
|
||||
turn_done.set()
|
||||
turn_response: list[Any] = []
|
||||
renderer: StreamRenderer | None = None
|
||||
renderer: Any | None = None
|
||||
reasoning_buffer = cli_terminal._ReasoningBuffer()
|
||||
|
||||
async def _consume_outbound() -> None:
|
||||
@@ -361,7 +388,7 @@ def agent(
|
||||
turn_done.clear()
|
||||
turn_response.clear()
|
||||
reasoning_buffer.clear()
|
||||
renderer = StreamRenderer(
|
||||
renderer = stream_renderer_class(
|
||||
render_markdown=markdown,
|
||||
bot_name=runtime_config.agents.defaults.bot_name,
|
||||
bot_icon=runtime_config.agents.defaults.bot_icon,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Low-overhead console entrypoint for the native terminal client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
|
||||
|
||||
def _native_tui_candidate(args: list[str]) -> bool:
|
||||
"""Return whether ``agent`` can start without the classic agent stack."""
|
||||
if not args or args[0] != "agent":
|
||||
return False
|
||||
for argument in args[1:]:
|
||||
if argument in {"--classic", "--no-tui", "-m", "--message"}:
|
||||
return False
|
||||
if argument.startswith("--message=") or (
|
||||
argument.startswith("-m") and not argument.startswith("--")
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _configure_windows_console() -> None:
|
||||
if sys.platform != "win32" or sys.stdout.encoding == "utf-8":
|
||||
return
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
with suppress(Exception):
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if callable(reconfigure):
|
||||
reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Dispatch native TUI startup without importing the complete CLI graph."""
|
||||
_configure_windows_console()
|
||||
if _native_tui_candidate(sys.argv[1:]):
|
||||
import typer
|
||||
|
||||
from nanobot.cli.agent import agent
|
||||
|
||||
fast_app = typer.Typer(add_completion=False)
|
||||
fast_app.command()(agent)
|
||||
command = typer.main.get_command(fast_app)
|
||||
command.main(args=sys.argv[2:], prog_name="nanobot agent")
|
||||
return
|
||||
|
||||
from nanobot.cli.commands import app
|
||||
|
||||
app()
|
||||
+58
-52
@@ -11,7 +11,6 @@ import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
@@ -22,9 +21,8 @@ from nanobot import __version__
|
||||
from nanobot.cli.runtime_config import _model_display
|
||||
from nanobot.cli.webui_support import (
|
||||
_gateway_health_ready,
|
||||
_webui_browser_url,
|
||||
_host_for_local_browser,
|
||||
_webui_endpoint_reachable,
|
||||
webui_bootstrap_secret,
|
||||
)
|
||||
from nanobot.config.paths import get_data_dir
|
||||
from nanobot.config.schema import Config
|
||||
@@ -82,22 +80,17 @@ def launch_tui(
|
||||
state_path = config_path.parent / "tui" / "state.json"
|
||||
chat_id = _initial_tui_chat_id(session_id, state_path)
|
||||
command = _resolve_tui_command()
|
||||
gateway = _ensure_gateway(
|
||||
config,
|
||||
config_path=config_path,
|
||||
workspace_override=workspace_override,
|
||||
)
|
||||
base_url, bootstrap_secret = _tui_gateway_connection(config)
|
||||
gateway: _GatewayHandle | None = None
|
||||
process: subprocess.Popen[Any] | None = None
|
||||
try:
|
||||
bootstrap = _fetch_bootstrap(
|
||||
gateway.base_url,
|
||||
secret=webui_bootstrap_secret(config),
|
||||
)
|
||||
env = os.environ.copy()
|
||||
env.pop("NANOBOT_TUI_WS_URL", None)
|
||||
env.pop("NANOBOT_TUI_API_TOKEN", None)
|
||||
env.update(
|
||||
{
|
||||
"NANOBOT_TUI_WS_URL": _authenticated_ws_url(bootstrap),
|
||||
"NANOBOT_TUI_API_URL": gateway.base_url,
|
||||
"NANOBOT_TUI_API_TOKEN": str(bootstrap.get("api_token") or ""),
|
||||
"NANOBOT_TUI_BOOTSTRAP_URL": f"{base_url}/webui/bootstrap",
|
||||
"NANOBOT_TUI_API_URL": base_url,
|
||||
"NANOBOT_TUI_MODEL": _model_display(config)[0],
|
||||
"NANOBOT_TUI_MODEL_PRESET": config.agents.defaults.model_preset or "default",
|
||||
"NANOBOT_TUI_WORKSPACE": str(config.workspace_path),
|
||||
@@ -108,18 +101,42 @@ def launch_tui(
|
||||
"NANOBOT_TUI_THEME": theme,
|
||||
}
|
||||
)
|
||||
if bootstrap_secret:
|
||||
env["NANOBOT_TUI_BOOTSTRAP_SECRET"] = bootstrap_secret
|
||||
else:
|
||||
env.pop("NANOBOT_TUI_BOOTSTRAP_SECRET", None)
|
||||
env["NANOBOT_TUI_STATE_PATH"] = str(state_path)
|
||||
if chat_id:
|
||||
env["NANOBOT_TUI_CHAT_ID"] = chat_id
|
||||
else:
|
||||
env.pop("NANOBOT_TUI_CHAT_ID", None)
|
||||
return subprocess.run(command, env=env, check=False).returncode
|
||||
except OSError as exc:
|
||||
raise TuiUnavailableError(f"could not start the native TUI: {exc}") from exc
|
||||
try:
|
||||
process = subprocess.Popen(command, env=env)
|
||||
except OSError as exc:
|
||||
raise TuiUnavailableError(f"could not start the native TUI: {exc}") from exc
|
||||
gateway = _ensure_gateway(
|
||||
config,
|
||||
config_path=config_path,
|
||||
workspace_override=workspace_override,
|
||||
wait_until_ready=False,
|
||||
)
|
||||
return process.wait()
|
||||
except BaseException:
|
||||
if process is not None and process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise
|
||||
finally:
|
||||
lease = getattr(gateway, "lease", None)
|
||||
lease = getattr(gateway, "lease", None) if gateway is not None else None
|
||||
if lease is not None:
|
||||
lease.release()
|
||||
# Returning to the shell must not wait for process termination. The
|
||||
# gateway's client monitor observes the released last lease and owns
|
||||
# the orderly on-demand shutdown.
|
||||
lease.release(wait_for_stop=False)
|
||||
|
||||
|
||||
def _resolve_tui_command() -> list[str]:
|
||||
@@ -364,6 +381,7 @@ def _ensure_gateway(
|
||||
*,
|
||||
config_path: Path,
|
||||
workspace_override: str | None,
|
||||
wait_until_ready: bool = True,
|
||||
) -> _GatewayHandle:
|
||||
from nanobot.gateway import (
|
||||
GatewayClientLease,
|
||||
@@ -371,7 +389,7 @@ def _ensure_gateway(
|
||||
GatewayRuntime,
|
||||
)
|
||||
|
||||
base_url = _webui_browser_url(config).split("/#/", 1)[0].rstrip("/")
|
||||
base_url, _bootstrap_secret = _tui_gateway_connection(config)
|
||||
instance = GatewayInstance.resolve(
|
||||
config_path=config_path,
|
||||
workspace=workspace_override,
|
||||
@@ -388,7 +406,7 @@ def _ensure_gateway(
|
||||
"the matching gateway instance is running on a different port; "
|
||||
"restart it or use `nanobot agent --classic`"
|
||||
)
|
||||
if endpoint_reachable:
|
||||
if endpoint_reachable or not wait_until_ready:
|
||||
return _GatewayHandle(base_url=base_url, lease=lease)
|
||||
elif endpoint_reachable:
|
||||
raise TuiUnavailableError(
|
||||
@@ -405,6 +423,9 @@ def _ensure_gateway(
|
||||
f"logs: {result.status.log_path}"
|
||||
)
|
||||
|
||||
if not wait_until_ready:
|
||||
return _GatewayHandle(base_url=base_url, lease=lease)
|
||||
|
||||
deadline = time.monotonic() + 20
|
||||
while time.monotonic() < deadline:
|
||||
if _webui_endpoint_reachable(base_url):
|
||||
@@ -427,37 +448,22 @@ def _ensure_gateway(
|
||||
raise
|
||||
|
||||
|
||||
def _fetch_bootstrap(base_url: str, *, secret: str) -> dict[str, Any]:
|
||||
headers = {"X-Nanobot-Auth": secret} if secret else {}
|
||||
request = urllib.request.Request(f"{base_url}/webui/bootstrap", headers=headers)
|
||||
def _tui_gateway_connection(config: Config) -> tuple[str, str]:
|
||||
"""Read the small bootstrap subset without importing the WebSocket runtime."""
|
||||
raw: object = getattr(config.channels, "websocket", None)
|
||||
settings = cast(dict[str, Any], raw) if isinstance(raw, dict) else {}
|
||||
host = _host_for_local_browser(str(settings.get("host") or "127.0.0.1"))
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
raw_payload: Any = json.loads(response.read().decode("utf-8"))
|
||||
except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
||||
raise TuiUnavailableError(
|
||||
f"could not authenticate with the local gateway: {exc}"
|
||||
) from exc
|
||||
if not isinstance(raw_payload, dict):
|
||||
raise TuiUnavailableError("gateway bootstrap response is missing ws_path")
|
||||
payload = cast(dict[str, Any], raw_payload)
|
||||
if not payload.get("ws_path"):
|
||||
raise TuiUnavailableError("gateway bootstrap response is missing ws_path")
|
||||
return payload
|
||||
|
||||
|
||||
def _authenticated_ws_url(bootstrap: dict[str, Any]) -> str:
|
||||
raw_url = str(bootstrap.get("ws_url") or "").strip()
|
||||
if not raw_url:
|
||||
raise TuiUnavailableError("gateway bootstrap response is missing ws_url")
|
||||
parsed = urllib.parse.urlsplit(raw_url)
|
||||
query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
|
||||
token = str(bootstrap.get("token") or "").strip()
|
||||
if token:
|
||||
query.append(("token", token))
|
||||
query.append(("client_id", f"tui-{os.getpid()}"))
|
||||
return urllib.parse.urlunsplit(
|
||||
(parsed.scheme, parsed.netloc, parsed.path, urllib.parse.urlencode(query), parsed.fragment)
|
||||
)
|
||||
port = int(settings.get("port") or 8765)
|
||||
except (TypeError, ValueError):
|
||||
port = 8765
|
||||
secret = str(
|
||||
settings.get("tokenIssueSecret")
|
||||
or settings.get("token_issue_secret")
|
||||
or settings.get("token")
|
||||
or ""
|
||||
).strip()
|
||||
return f"http://{host}:{port}", secret
|
||||
|
||||
|
||||
def _websocket_chat_id(session_id: str) -> str | None:
|
||||
|
||||
@@ -466,8 +466,8 @@ class GatewayClientLease:
|
||||
self._write_state(state)
|
||||
return True
|
||||
|
||||
def release(self, *, timeout_s: int = 20) -> bool:
|
||||
"""Release this client and stop an ephemeral gateway when it was the last."""
|
||||
def release(self, *, timeout_s: int = 20, wait_for_stop: bool = True) -> bool:
|
||||
"""Release this client, optionally leaving last-client shutdown to the monitor."""
|
||||
if not self._acquired:
|
||||
return False
|
||||
while True:
|
||||
@@ -482,7 +482,7 @@ class GatewayClientLease:
|
||||
self._acquired = False
|
||||
should_stop = not clients and bool(state.get("auto_stop"))
|
||||
self._write_or_clear(state)
|
||||
if not should_stop:
|
||||
if not should_stop or not wait_for_stop:
|
||||
return False
|
||||
result = self.runtime._stop(timeout_s=timeout_s)
|
||||
stopped = result.ok or result.message in {
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ dev = [
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
nanobot = "nanobot.cli.commands:app"
|
||||
nanobot = "nanobot.cli.entry:main"
|
||||
|
||||
# Third-party tool plugins register here. Built-in tools are discovered
|
||||
# automatically via pkgutil scanning in ToolLoader.discover().
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from nanobot.cli.entry import _native_tui_candidate
|
||||
|
||||
|
||||
def test_native_agent_invocations_use_the_lightweight_entrypoint() -> None:
|
||||
assert _native_tui_candidate(["agent"])
|
||||
assert _native_tui_candidate(["agent", "--session", "websocket:chat"])
|
||||
assert _native_tui_candidate(["agent", "--theme=light"])
|
||||
|
||||
|
||||
def test_classic_and_one_shot_agent_invocations_keep_the_full_cli_entrypoint() -> None:
|
||||
assert not _native_tui_candidate(["agent", "--classic"])
|
||||
assert not _native_tui_candidate(["agent", "-m", "hello"])
|
||||
assert not _native_tui_candidate(["agent", "-mhello"])
|
||||
assert not _native_tui_candidate(["agent", "--message=hello"])
|
||||
assert not _native_tui_candidate(["status"])
|
||||
@@ -13,7 +13,6 @@ from nanobot.cli.agent import agent
|
||||
from nanobot.cli.tui_launcher import (
|
||||
TuiSessionError,
|
||||
TuiUnavailableError,
|
||||
_authenticated_ws_url,
|
||||
_download_release_tui,
|
||||
_ensure_gateway,
|
||||
_initial_tui_chat_id,
|
||||
@@ -52,14 +51,6 @@ def _release_archive(
|
||||
return payload, checksum
|
||||
|
||||
|
||||
def test_authenticated_ws_url_preserves_existing_query(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher.os.getpid", lambda: 42)
|
||||
url = _authenticated_ws_url(
|
||||
{"ws_url": "ws://127.0.0.1:8765/ws?mode=local", "token": "a b"}
|
||||
)
|
||||
assert url == "ws://127.0.0.1:8765/ws?mode=local&token=a+b&client_id=tui-42"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("session_id", "expected"),
|
||||
[
|
||||
@@ -104,40 +95,43 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config = Config()
|
||||
config = Config(
|
||||
channels={"websocket": {"tokenIssueSecret": "bootstrap-secret"}},
|
||||
)
|
||||
config.model_presets["Deep Research"] = ModelPresetConfig(model="openai/gpt-5.6")
|
||||
config.agents.defaults.model_preset = "Deep Research"
|
||||
captured: dict[str, str] = {}
|
||||
events: list[str] = []
|
||||
released: list[bool] = []
|
||||
|
||||
class FakeLease:
|
||||
def release(self) -> None:
|
||||
def release(self, *, wait_for_stop: bool = True) -> None:
|
||||
assert wait_for_stop is False
|
||||
released.append(True)
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher._resolve_tui_command", lambda: ["nanobot-tui"])
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.tui_launcher._ensure_gateway",
|
||||
lambda *args, **kwargs: SimpleNamespace(
|
||||
def ensure_gateway(*args: object, **kwargs: object) -> SimpleNamespace:
|
||||
assert events == ["spawned"]
|
||||
assert kwargs["wait_until_ready"] is False
|
||||
return SimpleNamespace(
|
||||
base_url="http://127.0.0.1:8765",
|
||||
lease=FakeLease(),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.tui_launcher._fetch_bootstrap",
|
||||
lambda *args, **kwargs: {
|
||||
"ws_url": "ws://127.0.0.1:8765/ws",
|
||||
"token": "socket-token",
|
||||
"api_token": "api-token",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def run(command: list[str], *, env: dict[str, str], check: bool) -> subprocess.CompletedProcess:
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher._ensure_gateway", ensure_gateway)
|
||||
|
||||
class FakeProcess:
|
||||
def wait(self) -> int:
|
||||
events.append("waited")
|
||||
return 0
|
||||
|
||||
def popen(command: list[str], *, env: dict[str, str]) -> FakeProcess:
|
||||
assert command == ["nanobot-tui"]
|
||||
assert check is False
|
||||
captured.update(env)
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
events.append("spawned")
|
||||
return FakeProcess()
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", run)
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.Popen", popen)
|
||||
|
||||
result = launch_tui(
|
||||
config,
|
||||
@@ -150,10 +144,57 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
|
||||
assert result == 0
|
||||
assert captured["NANOBOT_TUI_MODEL"] == "openai/gpt-5.6"
|
||||
assert captured["NANOBOT_TUI_MODEL_PRESET"] == "Deep Research"
|
||||
assert captured["NANOBOT_TUI_BOOTSTRAP_URL"] == (
|
||||
"http://127.0.0.1:8765/webui/bootstrap"
|
||||
)
|
||||
assert captured["NANOBOT_TUI_BOOTSTRAP_SECRET"] == "bootstrap-secret"
|
||||
assert "NANOBOT_TUI_WS_URL" not in captured
|
||||
assert "NANOBOT_TUI_API_TOKEN" not in captured
|
||||
assert "NANOBOT_TUI_CHAT_ID" not in captured
|
||||
assert events == ["spawned", "waited"]
|
||||
assert released == [True]
|
||||
|
||||
|
||||
def test_launcher_terminates_the_tui_when_gateway_start_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config = Config()
|
||||
terminated: list[bool] = []
|
||||
|
||||
class FakeProcess:
|
||||
def poll(self) -> None:
|
||||
return None
|
||||
|
||||
def terminate(self) -> None:
|
||||
terminated.append(True)
|
||||
|
||||
def wait(self, timeout: float | None = None) -> int:
|
||||
assert timeout == 5
|
||||
return 1
|
||||
|
||||
def fail_gateway(*args: object, **kwargs: object) -> None:
|
||||
raise RuntimeError("gateway failed")
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher._resolve_tui_command", lambda: ["nanobot-tui"])
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.tui_launcher.subprocess.Popen",
|
||||
lambda *args, **kwargs: FakeProcess(),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher._ensure_gateway", fail_gateway)
|
||||
|
||||
with pytest.raises(RuntimeError, match="gateway failed"):
|
||||
launch_tui(
|
||||
config,
|
||||
config_path=tmp_path / "config.json",
|
||||
workspace_override=None,
|
||||
session_id=None,
|
||||
theme="dark",
|
||||
)
|
||||
|
||||
assert terminated == [True]
|
||||
|
||||
|
||||
def test_explicit_tui_binary_must_exist(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
@@ -599,6 +640,33 @@ def test_gateway_reuses_the_matching_managed_instance(
|
||||
assert gateway.base_url == "http://127.0.0.1:8765"
|
||||
|
||||
|
||||
def test_gateway_reuse_can_return_before_the_webui_endpoint_is_ready(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config = Config()
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, *, paths: object) -> None:
|
||||
self.paths = paths
|
||||
|
||||
def status(self) -> SimpleNamespace:
|
||||
return SimpleNamespace(running=True, port=config.gateway.port)
|
||||
|
||||
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", FakeRuntime)
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher._webui_endpoint_reachable", lambda _url: False)
|
||||
|
||||
gateway = _ensure_gateway(
|
||||
config,
|
||||
config_path=tmp_path / "config.json",
|
||||
workspace_override=None,
|
||||
wait_until_ready=False,
|
||||
)
|
||||
|
||||
assert gateway.base_url == "http://127.0.0.1:8765"
|
||||
assert gateway.lease is not None
|
||||
|
||||
|
||||
def test_gateway_started_for_tui_stops_when_its_last_lease_exits(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -479,6 +479,25 @@ def test_last_interactive_client_stops_an_on_demand_gateway(tmp_path, monkeypatc
|
||||
assert not webui.state_path.exists()
|
||||
|
||||
|
||||
def test_last_client_can_leave_shutdown_to_the_gateway_monitor(tmp_path, monkeypatch):
|
||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
||||
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
|
||||
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
|
||||
monkeypatch.setattr(
|
||||
runtime,
|
||||
"_stop",
|
||||
lambda **_kwargs: pytest.fail("deferred release must not stop synchronously"),
|
||||
)
|
||||
client = GatewayClientLease(runtime, kind="tui", pid=os.getpid(), token="tui")
|
||||
client.acquire()
|
||||
client.mark_ephemeral()
|
||||
|
||||
assert client.release(wait_for_stop=False) is False
|
||||
|
||||
state = json.loads(client.state_path.read_text(encoding="utf-8"))
|
||||
assert state == {"auto_stop": True, "clients": {}}
|
||||
|
||||
|
||||
def test_last_client_shutdown_preserves_a_replacement_lease(tmp_path, monkeypatch):
|
||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
||||
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ bun run --cwd tui test
|
||||
bun run --cwd tui build
|
||||
```
|
||||
|
||||
`nanobot agent` launches this client, leases the shared local gateway or starts it on demand, and passes an authenticated local endpoint through environment variables. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. Only `nanobot gateway --background` makes it persistent without clients. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified archive that keeps the executable together with its licenses, notices, corresponding application source, source offer, and relinking instructions. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
|
||||
`nanobot agent` launches this client, leases the shared local gateway or starts it on demand, and passes the local bootstrap endpoint through environment variables. The client paints before gateway readiness, retries bootstrap in the background, and obtains fresh WebSocket and REST credentials for each connection. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. Only `nanobot gateway --background` makes it persistent without clients. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified archive that keeps the executable together with its licenses, notices, corresponding application source, source offer, and relinking instructions. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
|
||||
|
||||
Standalone terminals use OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen.
|
||||
|
||||
|
||||
@@ -37,7 +37,9 @@ def _wait_for(process: Any, needle: str, timeout: float) -> str:
|
||||
output.append(_read(process, min(0.1, deadline - time.monotonic())))
|
||||
text = "".join(output)
|
||||
if needle not in text:
|
||||
raise AssertionError(f"terminal output did not contain {needle!r}")
|
||||
raise AssertionError(
|
||||
f"terminal output did not contain {needle!r}; recent output: {text[-2000:]!r}"
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
@@ -47,7 +49,7 @@ def _wait_for_exit(process: Any, timeout: float) -> int:
|
||||
time.sleep(0.05)
|
||||
if process.isalive():
|
||||
process.close(force=True)
|
||||
raise AssertionError("TUI did not exit after Ctrl+C")
|
||||
raise AssertionError("TUI did not exit after the exit command")
|
||||
return int(process.exitstatus or 0)
|
||||
|
||||
|
||||
@@ -78,6 +80,8 @@ def main() -> int:
|
||||
# width probes. Keep this smoke test focused on application behavior.
|
||||
"OPENTUI_FORCE_EXPLICIT_WIDTH": "false",
|
||||
}
|
||||
env.pop("HERDR_ENV", None)
|
||||
env.pop("HERDR_PANE_ID", None)
|
||||
process = PtyProcess.spawn(
|
||||
[bun, "src/index.ts"],
|
||||
cwd=str(ROOT),
|
||||
@@ -100,11 +104,11 @@ def main() -> int:
|
||||
if "\x1b[18;" not in resized or ";42H" not in resized:
|
||||
raise AssertionError("TUI did not repaint to the resized ConPTY dimensions")
|
||||
|
||||
# First Ctrl+C clears the draft. The second exits the app and must
|
||||
# restore the alternate screen without an unhandled exception.
|
||||
# Ctrl+C clears the draft. The local exit command must still work while
|
||||
# the intentionally unavailable gateway has not attached a chat.
|
||||
process.sendcontrol("c")
|
||||
output.append(_read(process, 0.2))
|
||||
process.sendcontrol("c")
|
||||
process.write("exit\r")
|
||||
output.append(_wait_for(process, LEAVE_ALT_SCREEN, 8))
|
||||
exit_code = _wait_for_exit(process, 8)
|
||||
finally:
|
||||
|
||||
@@ -68,7 +68,7 @@ def _wait_for_exit(pid: int, timeout: float) -> int:
|
||||
time.sleep(0.05)
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
os.waitpid(pid, 0)
|
||||
raise AssertionError("TUI did not exit after Ctrl+C")
|
||||
raise AssertionError("TUI did not exit after the exit command")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -90,6 +90,8 @@ def main() -> int:
|
||||
# terminal emulator's optional OSC 10/11 response.
|
||||
"NANOBOT_TUI_THEME": "dark",
|
||||
}
|
||||
env.pop("HERDR_ENV", None)
|
||||
env.pop("HERDR_PANE_ID", None)
|
||||
|
||||
pid, master = pty.fork()
|
||||
if pid == 0:
|
||||
@@ -117,11 +119,11 @@ def main() -> int:
|
||||
if b"\x1b[18;" not in resized or b";42H" not in resized:
|
||||
raise AssertionError("TUI did not repaint to the resized PTY dimensions")
|
||||
|
||||
# First Ctrl+C clears the draft; the second exits and must restore the
|
||||
# alternate screen without a prompt_toolkit-style traceback.
|
||||
# Ctrl+C clears the draft. The local exit command must still work while
|
||||
# the intentionally unavailable gateway has not attached a chat.
|
||||
os.write(master, b"\x03")
|
||||
output.extend(_read(master, 0.2))
|
||||
os.write(master, b"\x03")
|
||||
os.write(master, b"exit\r")
|
||||
output.extend(_wait_for(master, LEAVE_ALT_SCREEN, 5))
|
||||
exit_code = _wait_for_exit(pid, 5)
|
||||
reaped = True
|
||||
|
||||
@@ -1488,7 +1488,9 @@ describe("NanobotTui layout", () => {
|
||||
test("overlaps automatic terminal detection with connection startup", async () => {
|
||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||
let connected = false
|
||||
let rendered = false
|
||||
let resolveMode: (mode: "light") => void = () => undefined
|
||||
setup.renderer.start = () => { rendered = true }
|
||||
setup.renderer.waitForThemeMode = () => new Promise((resolve) => {
|
||||
resolveMode = resolve
|
||||
})
|
||||
@@ -1505,6 +1507,7 @@ describe("NanobotTui layout", () => {
|
||||
const starting = app.start()
|
||||
await Bun.sleep(1)
|
||||
expect(connected).toBe(true)
|
||||
expect(rendered).toBe(true)
|
||||
|
||||
resolveMode("light")
|
||||
await starting
|
||||
@@ -1994,6 +1997,26 @@ describe("NanobotTui layout", () => {
|
||||
expect(closed).toBe(true)
|
||||
expect(setup.renderer.isDestroyed).toBe(true)
|
||||
})
|
||||
|
||||
test("accepts the exit command before the gateway connection is ready", async () => {
|
||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||
let closed = false
|
||||
const transport = client()
|
||||
transport.close = () => { closed = true }
|
||||
const app = NanobotTui.mount(
|
||||
setup.renderer,
|
||||
options,
|
||||
transport,
|
||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||
)
|
||||
const composer = (app as unknown as { composer: TextareaRenderable }).composer
|
||||
|
||||
composer.setText("exit")
|
||||
composer.submit()
|
||||
await waitUntil(() => closed)
|
||||
|
||||
expect(setup.renderer.isDestroyed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("NanobotTui in a Herdr pane", () => {
|
||||
|
||||
+41
-12
@@ -21,6 +21,7 @@ import {
|
||||
import {
|
||||
NanobotClient,
|
||||
fetchHistory,
|
||||
fetchGatewayConnection,
|
||||
fetchMentionCandidates,
|
||||
fetchSessionContext,
|
||||
fetchSessions,
|
||||
@@ -78,7 +79,9 @@ import {
|
||||
import { createTuiHost, currentGitBranch, type TuiHost } from "./host"
|
||||
|
||||
interface AppOptions {
|
||||
wsUrl: string
|
||||
wsUrl?: string
|
||||
bootstrapUrl?: string
|
||||
bootstrapSecret?: string
|
||||
apiUrl: string
|
||||
apiToken: string
|
||||
chatId?: string
|
||||
@@ -472,7 +475,23 @@ export class NanobotTui {
|
||||
)
|
||||
this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette))
|
||||
this.client = client || new NanobotClient({
|
||||
url: options.wsUrl,
|
||||
...(options.bootstrapUrl
|
||||
? {
|
||||
resolveConnection: () => fetchGatewayConnection(
|
||||
options.bootstrapUrl || "",
|
||||
options.bootstrapSecret || "",
|
||||
options.apiUrl,
|
||||
`tui-${process.pid}`,
|
||||
),
|
||||
onConnection: (connection) => this.useGatewayConnection(
|
||||
connection.apiUrl,
|
||||
connection.apiToken,
|
||||
),
|
||||
connectionRetryLabel: "Starting local gateway",
|
||||
reconnectDelayMs: 100,
|
||||
startupRetryMaxDelayMs: 250,
|
||||
}
|
||||
: { url: options.wsUrl }),
|
||||
chatId: options.chatId,
|
||||
onEvent: (event) => this.accept(event),
|
||||
onStatus: (status, detail) => this.handleStatus(status, detail),
|
||||
@@ -719,16 +738,15 @@ export class NanobotTui {
|
||||
void this.loadCommands()
|
||||
void this.loadMentions()
|
||||
this.runtimeControls.preload()
|
||||
this.renderer.start()
|
||||
// OpenTUI learns the real terminal background through OSC 10/11. Wait for
|
||||
// that bounded probe before first paint, as OpenCode does, so a light
|
||||
// terminal does not briefly render the dark palette. The app already owns
|
||||
// the renderer here, so a signal during the probe can still restore it.
|
||||
// that bounded probe after first paint. The neutral terminal background is
|
||||
// safe to render immediately, and the detected palette can be applied later.
|
||||
if (this.options.theme === "auto") await this.renderer.waitForThemeMode(1_000)
|
||||
if (this.quitting) return
|
||||
if (this.options.theme === "auto" && this.renderer.themeMode) {
|
||||
this.applyTheme(this.renderer.themeMode)
|
||||
}
|
||||
this.renderer.start()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
@@ -775,6 +793,10 @@ export class NanobotTui {
|
||||
return
|
||||
}
|
||||
if (!visibleContent) return
|
||||
if (["exit", "quit", "/exit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
|
||||
this.quit()
|
||||
return
|
||||
}
|
||||
const completion = this.commandMenu.completion(visibleContent)
|
||||
if (completion) {
|
||||
this.setComposer(completion)
|
||||
@@ -804,10 +826,6 @@ export class NanobotTui {
|
||||
this.status.content = "Preparing chat…"
|
||||
return
|
||||
}
|
||||
if (["exit", "quit", "/exit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
|
||||
this.quit()
|
||||
return
|
||||
}
|
||||
const prompt = { content, options: mentionOptions(content, this.availableMentions()) }
|
||||
if (this.activeTurn) {
|
||||
this.sendPrompt(prompt, true)
|
||||
@@ -1132,6 +1150,14 @@ export class NanobotTui {
|
||||
for (const event of events || []) this.accept(event)
|
||||
}
|
||||
|
||||
private useGatewayConnection(apiUrl: string, apiToken: string): void {
|
||||
this.options.apiUrl = apiUrl
|
||||
this.options.apiToken = apiToken
|
||||
this.runtimeControls.useApiConnection(apiUrl, apiToken)
|
||||
void this.loadCommands()
|
||||
void this.loadMentions()
|
||||
}
|
||||
|
||||
private handleStatus(status: ConnectionStatus, detail?: string): void {
|
||||
if (status === "connected") {
|
||||
this.ready = false
|
||||
@@ -1141,9 +1167,12 @@ export class NanobotTui {
|
||||
}
|
||||
if (status === "connecting") {
|
||||
this.ready = false
|
||||
this.host.reportState("unknown", detail ? "Reconnecting" : "Connecting")
|
||||
const label = detail === "Starting local gateway"
|
||||
? detail
|
||||
: detail ? "Reconnecting" : "Connecting"
|
||||
this.host.reportState("unknown", label)
|
||||
if (detail) this.setActive(false)
|
||||
this.status.content = detail ? "Reconnecting…" : "Connecting…"
|
||||
this.status.content = `${label}…`
|
||||
return
|
||||
}
|
||||
if (status === "error") {
|
||||
|
||||
+11
-7
@@ -1,12 +1,6 @@
|
||||
import { NanobotTui, type AppOptions } from "./app"
|
||||
import { currentGitBranch } from "./host"
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) throw new Error(`${name} is required`)
|
||||
return value
|
||||
}
|
||||
|
||||
function themePreference(): AppOptions["theme"] {
|
||||
const value = process.env.NANOBOT_TUI_THEME?.trim() || "auto"
|
||||
if (value === "auto" || value === "dark" || value === "light") return value
|
||||
@@ -15,8 +9,18 @@ function themePreference(): AppOptions["theme"] {
|
||||
|
||||
const workspace = process.env.NANOBOT_TUI_WORKSPACE?.trim() || ""
|
||||
const hostWorkspace = process.cwd()
|
||||
const bootstrapUrl = process.env.NANOBOT_TUI_BOOTSTRAP_URL?.trim() || ""
|
||||
const wsUrl = process.env.NANOBOT_TUI_WS_URL?.trim() || ""
|
||||
if (!bootstrapUrl && !wsUrl) {
|
||||
throw new Error("NANOBOT_TUI_BOOTSTRAP_URL or NANOBOT_TUI_WS_URL is required")
|
||||
}
|
||||
const options: AppOptions = {
|
||||
wsUrl: required("NANOBOT_TUI_WS_URL"),
|
||||
...(bootstrapUrl
|
||||
? {
|
||||
bootstrapUrl,
|
||||
bootstrapSecret: process.env.NANOBOT_TUI_BOOTSTRAP_SECRET?.trim() || "",
|
||||
}
|
||||
: { wsUrl }),
|
||||
apiUrl: process.env.NANOBOT_TUI_API_URL?.trim() || "",
|
||||
apiToken: process.env.NANOBOT_TUI_API_TOKEN?.trim() || "",
|
||||
chatId: process.env.NANOBOT_TUI_CHAT_ID?.trim() || undefined,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"
|
||||
|
||||
import {
|
||||
NanobotClient,
|
||||
GatewayConnectionError,
|
||||
fetchGatewayConnection,
|
||||
fetchHistory,
|
||||
fetchMentionCandidates,
|
||||
fetchRuntimeControls,
|
||||
@@ -37,6 +39,156 @@ class FakeSocket {
|
||||
}
|
||||
|
||||
describe("gateway protocol", () => {
|
||||
test("bootstraps fresh websocket and API credentials", async () => {
|
||||
const original = globalThis.fetch
|
||||
let headers: Headers | undefined
|
||||
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
headers = new Headers(init?.headers)
|
||||
return new Response(JSON.stringify({
|
||||
ws_url: "ws://nanobot.test/ws?mode=local",
|
||||
token: "socket token",
|
||||
api_token: "api-token",
|
||||
}))
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const connection = await fetchGatewayConnection(
|
||||
"http://nanobot.test/webui/bootstrap",
|
||||
"bootstrap-secret",
|
||||
"http://nanobot.test",
|
||||
"tui-42",
|
||||
)
|
||||
expect(headers?.get("X-Nanobot-Auth")).toBe("bootstrap-secret")
|
||||
expect(connection).toEqual({
|
||||
wsUrl: "ws://nanobot.test/ws?mode=local&token=socket+token&client_id=tui-42",
|
||||
apiUrl: "http://nanobot.test",
|
||||
apiToken: "api-token",
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects malformed bootstrap responses without retrying", async () => {
|
||||
const original = globalThis.fetch
|
||||
globalThis.fetch = (() => Promise.resolve(new Response("not json"))) as unknown as typeof fetch
|
||||
|
||||
try {
|
||||
await expect(fetchGatewayConnection(
|
||||
"http://nanobot.test/webui/bootstrap",
|
||||
"bootstrap-secret",
|
||||
"http://nanobot.test",
|
||||
"tui-42",
|
||||
)).rejects.toMatchObject({
|
||||
message: "gateway bootstrap response is invalid",
|
||||
retryable: false,
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
})
|
||||
|
||||
test("waits for bootstrap before opening the websocket", async () => {
|
||||
const original = globalThis.WebSocket
|
||||
let resolveConnection: ((value: {
|
||||
wsUrl: string
|
||||
apiUrl: string
|
||||
apiToken: string
|
||||
}) => void) | undefined
|
||||
let requestedUrl = ""
|
||||
Object.defineProperty(globalThis, "WebSocket", {
|
||||
configurable: true,
|
||||
value: class extends FakeSocket {
|
||||
constructor(url: string) {
|
||||
super()
|
||||
requestedUrl = url
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const connections: string[] = []
|
||||
const client = new NanobotClient({
|
||||
resolveConnection: () => new Promise((resolve) => { resolveConnection = resolve }),
|
||||
onConnection: (connection) => connections.push(connection.apiToken),
|
||||
onEvent: () => undefined,
|
||||
onStatus: () => undefined,
|
||||
})
|
||||
client.connect()
|
||||
await Bun.sleep(1)
|
||||
expect(requestedUrl).toBe("")
|
||||
resolveConnection?.({
|
||||
wsUrl: "ws://nanobot.test/ws?token=fresh",
|
||||
apiUrl: "http://nanobot.test",
|
||||
apiToken: "fresh-api-token",
|
||||
})
|
||||
await Bun.sleep(1)
|
||||
expect(requestedUrl).toBe("ws://nanobot.test/ws?token=fresh")
|
||||
expect(connections).toEqual(["fresh-api-token"])
|
||||
client.close()
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
|
||||
}
|
||||
})
|
||||
|
||||
test("retries bootstrap while the local gateway starts", async () => {
|
||||
const original = globalThis.WebSocket
|
||||
let attempts = 0
|
||||
let requestedUrl = ""
|
||||
Object.defineProperty(globalThis, "WebSocket", {
|
||||
configurable: true,
|
||||
value: class extends FakeSocket {
|
||||
constructor(url: string) {
|
||||
super()
|
||||
requestedUrl = url
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const client = new NanobotClient({
|
||||
resolveConnection: async () => {
|
||||
attempts += 1
|
||||
if (attempts === 1) throw new Error("gateway is still starting")
|
||||
return {
|
||||
wsUrl: "ws://nanobot.test/ws?token=second",
|
||||
apiUrl: "http://nanobot.test",
|
||||
apiToken: "second-api-token",
|
||||
}
|
||||
},
|
||||
reconnectDelayMs: 1,
|
||||
onEvent: () => undefined,
|
||||
onStatus: () => undefined,
|
||||
})
|
||||
client.connect()
|
||||
for (let index = 0; index < 20 && !requestedUrl; index += 1) await Bun.sleep(2)
|
||||
expect(attempts).toBe(2)
|
||||
expect(requestedUrl).toBe("ws://nanobot.test/ws?token=second")
|
||||
client.close()
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
|
||||
}
|
||||
})
|
||||
|
||||
test("reports a permanent bootstrap rejection without retrying", async () => {
|
||||
let attempts = 0
|
||||
const statuses: string[] = []
|
||||
const client = new NanobotClient({
|
||||
resolveConnection: async () => {
|
||||
attempts += 1
|
||||
throw new GatewayConnectionError("gateway bootstrap failed: HTTP 401", false)
|
||||
},
|
||||
reconnectDelayMs: 1,
|
||||
onEvent: () => undefined,
|
||||
onStatus: (status, detail) => statuses.push(`${status}:${detail || ""}`),
|
||||
})
|
||||
client.connect()
|
||||
await Bun.sleep(5)
|
||||
expect(attempts).toBe(1)
|
||||
expect(statuses.at(-1)).toBe("error:gateway bootstrap failed: HTTP 401")
|
||||
client.close()
|
||||
})
|
||||
|
||||
test("represents lifecycle frames without browser state", () => {
|
||||
const events: InboundEvent[] = [
|
||||
{
|
||||
|
||||
+110
-9
@@ -151,13 +151,30 @@ type OutboundEvent =
|
||||
}
|
||||
|
||||
export interface ClientOptions {
|
||||
url: string
|
||||
url?: string
|
||||
resolveConnection?: () => Promise<GatewayConnection>
|
||||
onConnection?: (connection: GatewayConnection) => void
|
||||
connectionRetryLabel?: string
|
||||
startupRetryMaxDelayMs?: number
|
||||
chatId?: string
|
||||
reconnectDelayMs?: number
|
||||
onEvent: (event: InboundEvent) => void
|
||||
onStatus: (status: ConnectionStatus, detail?: string) => void
|
||||
}
|
||||
|
||||
export interface GatewayConnection {
|
||||
wsUrl: string
|
||||
apiUrl: string
|
||||
apiToken: string
|
||||
}
|
||||
|
||||
export class GatewayConnectionError extends Error {
|
||||
constructor(message: string, readonly retryable: boolean) {
|
||||
super(message)
|
||||
this.name = "GatewayConnectionError"
|
||||
}
|
||||
}
|
||||
|
||||
export interface HistoryMessage {
|
||||
role: "user" | "assistant" | "activity"
|
||||
content: string
|
||||
@@ -747,12 +764,63 @@ function sessionLabelForMention(session: SessionSummary): string {
|
||||
return (session.title || session.preview || "Untitled chat").replace(/\s+/gu, " ").trim()
|
||||
}
|
||||
|
||||
/** Resolve fresh short-lived credentials once the local gateway is reachable. */
|
||||
export async function fetchGatewayConnection(
|
||||
bootstrapUrl: string,
|
||||
bootstrapSecret: string,
|
||||
apiUrl: string,
|
||||
clientId: string,
|
||||
): Promise<GatewayConnection> {
|
||||
const response = await fetch(bootstrapUrl, {
|
||||
headers: bootstrapSecret ? { "X-Nanobot-Auth": bootstrapSecret } : {},
|
||||
})
|
||||
if (!response.ok) {
|
||||
const retryable = response.status === 408 || response.status === 429 || response.status >= 500
|
||||
throw new GatewayConnectionError(
|
||||
`gateway bootstrap failed: HTTP ${response.status}`,
|
||||
retryable,
|
||||
)
|
||||
}
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = await response.json()
|
||||
} catch {
|
||||
throw new GatewayConnectionError("gateway bootstrap response is invalid", false)
|
||||
}
|
||||
if (!isRecord(payload)) {
|
||||
throw new GatewayConnectionError("gateway bootstrap response is invalid", false)
|
||||
}
|
||||
if (typeof payload.ws_url !== "string" || !payload.ws_url.trim()) {
|
||||
throw new GatewayConnectionError("gateway bootstrap response is missing ws_url", false)
|
||||
}
|
||||
let wsUrl: URL
|
||||
try {
|
||||
wsUrl = new URL(payload.ws_url)
|
||||
} catch {
|
||||
throw new GatewayConnectionError("gateway bootstrap response has an invalid ws_url", false)
|
||||
}
|
||||
if (wsUrl.protocol !== "ws:" && wsUrl.protocol !== "wss:") {
|
||||
throw new GatewayConnectionError("gateway bootstrap response has an invalid ws_url", false)
|
||||
}
|
||||
if (typeof payload.token === "string" && payload.token) {
|
||||
wsUrl.searchParams.append("token", payload.token)
|
||||
}
|
||||
wsUrl.searchParams.append("client_id", clientId)
|
||||
return {
|
||||
wsUrl: wsUrl.toString(),
|
||||
apiUrl,
|
||||
apiToken: typeof payload.api_token === "string" ? payload.api_token : "",
|
||||
}
|
||||
}
|
||||
|
||||
export class NanobotClient {
|
||||
private socket: WebSocket | null = null
|
||||
private chatId = ""
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private reconnectAttempt = 0
|
||||
private closedByClient = false
|
||||
private opening = false
|
||||
private connectedOnce = false
|
||||
|
||||
constructor(private readonly options: ClientOptions) {}
|
||||
|
||||
@@ -762,16 +830,46 @@ export class NanobotClient {
|
||||
|
||||
connect(): void {
|
||||
this.closedByClient = false
|
||||
this.open()
|
||||
void this.open()
|
||||
}
|
||||
|
||||
private open(): void {
|
||||
if (this.socket) return
|
||||
private async open(): Promise<void> {
|
||||
if (this.socket || this.opening || this.closedByClient) return
|
||||
this.opening = true
|
||||
this.options.onStatus("connecting")
|
||||
const socket = new WebSocket(this.options.url)
|
||||
let url = this.options.url
|
||||
try {
|
||||
if (this.options.resolveConnection) {
|
||||
const connection = await this.options.resolveConnection()
|
||||
if (this.closedByClient) return
|
||||
this.options.onConnection?.(connection)
|
||||
url = connection.wsUrl
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.closedByClient) {
|
||||
if (error instanceof GatewayConnectionError && !error.retryable) {
|
||||
this.options.onStatus("error", error.message)
|
||||
return
|
||||
}
|
||||
this.options.onStatus(
|
||||
"connecting",
|
||||
this.options.connectionRetryLabel || "gateway unavailable",
|
||||
)
|
||||
this.scheduleReconnect(false)
|
||||
}
|
||||
return
|
||||
} finally {
|
||||
this.opening = false
|
||||
}
|
||||
if (!url) {
|
||||
this.options.onStatus("error", "gateway URL is not configured")
|
||||
return
|
||||
}
|
||||
const socket = new WebSocket(url)
|
||||
this.socket = socket
|
||||
socket.addEventListener("open", () => {
|
||||
if (this.socket !== socket) return
|
||||
this.connectedOnce = true
|
||||
this.reconnectAttempt = 0
|
||||
this.options.onStatus("connected")
|
||||
})
|
||||
@@ -872,14 +970,17 @@ export class NanobotClient {
|
||||
this.options.onEvent(event)
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
private scheduleReconnect(announce = true): void {
|
||||
if (this.reconnectTimer || this.closedByClient) return
|
||||
const base = this.options.reconnectDelayMs ?? 500
|
||||
const delay = Math.min(8_000, base * 2 ** Math.min(this.reconnectAttempt++, 4))
|
||||
this.options.onStatus("connecting", `reconnecting in ${delay}ms`)
|
||||
const maxDelay = this.connectedOnce
|
||||
? 8_000
|
||||
: this.options.startupRetryMaxDelayMs ?? 8_000
|
||||
const delay = Math.min(maxDelay, base * 2 ** Math.min(this.reconnectAttempt++, 4))
|
||||
if (announce) this.options.onStatus("connecting", `reconnecting in ${delay}ms`)
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.open()
|
||||
void this.open()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,14 @@ export class RuntimeControls {
|
||||
void this.load().catch(() => {})
|
||||
}
|
||||
|
||||
useApiConnection(apiUrl: string, apiToken: string): void {
|
||||
this.options.apiUrl = apiUrl
|
||||
this.options.apiToken = apiToken
|
||||
this.controlsLoaded = false
|
||||
this.controlsLoadedAt = 0
|
||||
this.preload()
|
||||
}
|
||||
|
||||
updateWorkspaceScope(scope: WorkspaceScopePayload): void {
|
||||
this.scope = scope
|
||||
this.render()
|
||||
|
||||
Reference in New Issue
Block a user