perf(tui): reduce cold-start latency

This commit is contained in:
chengyongru
2026-08-18 16:28:46 +08:00
committed by chengyongru
parent 369a3443eb
commit df14259717
19 changed files with 669 additions and 160 deletions
+2 -2
View File
@@ -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 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. 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. `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
View File
@@ -2,7 +2,7 @@
Entry point for running nanobot as a module: python -m nanobot 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__": if __name__ == "__main__":
app() main()
+61 -34
View File
@@ -1,6 +1,7 @@
"""Direct and interactive agent CLI command.""" """Direct and interactive agent CLI command."""
import asyncio import asyncio
import importlib
import signal import signal
import sys import sys
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
@@ -11,17 +12,6 @@ import typer
from rich.console import Console from rich.console import Console
from nanobot import __logo__ 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.log_control import _set_nanobot_logs
from nanobot.cli.runtime_config import ( from nanobot.cli.runtime_config import (
_load_runtime_config, _load_runtime_config,
@@ -29,22 +19,37 @@ from nanobot.cli.runtime_config import (
_model_display, _model_display,
_print_agent_start_error, _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() 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( def agent(
message: str | None = typer.Option(None, "--message", "-m", help="Message to send to the 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.""" """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) runtime_config = _load_runtime_config(config, workspace)
theme = theme.strip().lower() theme = theme.strip().lower()
if theme not in {"auto", "dark", "light"}: if theme not in {"auto", "dark", "light"}:
@@ -117,6 +117,33 @@ def agent(
raise typer.Exit(exit_code) raise typer.Exit(exit_code)
return 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" session_id = session_id or "cli:direct"
try: try:
@@ -142,7 +169,7 @@ def agent(
_set_nanobot_logs(logs) _set_nanobot_logs(logs)
try: try:
agent_loop = AgentLoop.from_config( agent_loop = agent_loop_class.from_config(
runtime_config, runtime_config,
bus, bus,
provider=provider, provider=provider,
@@ -171,7 +198,7 @@ def agent(
_thinking: ThinkingSpinner | None = None _thinking: ThinkingSpinner | None = None
def _make_progress( def _make_progress(
renderer: StreamRenderer | None = None, renderer: Any | None = None,
) -> Callable[..., Awaitable[None]]: ) -> Callable[..., Awaitable[None]]:
reasoning_buffer = cli_terminal._ReasoningBuffer() reasoning_buffer = cli_terminal._ReasoningBuffer()
@@ -212,7 +239,7 @@ def agent(
async def run_once() -> None: async def run_once() -> None:
try: try:
await mcp_provider.connect() await mcp_provider.connect()
renderer = StreamRenderer( renderer = stream_renderer_class(
render_markdown=markdown, render_markdown=markdown,
bot_name=runtime_config.agents.defaults.bot_name, bot_name=runtime_config.agents.defaults.bot_name,
bot_icon=runtime_config.agents.defaults.bot_icon, bot_icon=runtime_config.agents.defaults.bot_icon,
@@ -278,7 +305,7 @@ def agent(
turn_done = asyncio.Event() turn_done = asyncio.Event()
turn_done.set() turn_done.set()
turn_response: list[Any] = [] turn_response: list[Any] = []
renderer: StreamRenderer | None = None renderer: Any | None = None
reasoning_buffer = cli_terminal._ReasoningBuffer() reasoning_buffer = cli_terminal._ReasoningBuffer()
async def _consume_outbound() -> None: async def _consume_outbound() -> None:
@@ -361,7 +388,7 @@ def agent(
turn_done.clear() turn_done.clear()
turn_response.clear() turn_response.clear()
reasoning_buffer.clear() reasoning_buffer.clear()
renderer = StreamRenderer( renderer = stream_renderer_class(
render_markdown=markdown, render_markdown=markdown,
bot_name=runtime_config.agents.defaults.bot_name, bot_name=runtime_config.agents.defaults.bot_name,
bot_icon=runtime_config.agents.defaults.bot_icon, bot_icon=runtime_config.agents.defaults.bot_icon,
+51
View File
@@ -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
View File
@@ -11,7 +11,6 @@ import shutil
import subprocess import subprocess
import time import time
import urllib.error import urllib.error
import urllib.parse
import urllib.request import urllib.request
import zipfile import zipfile
from dataclasses import dataclass from dataclasses import dataclass
@@ -22,9 +21,8 @@ from nanobot import __version__
from nanobot.cli.runtime_config import _model_display from nanobot.cli.runtime_config import _model_display
from nanobot.cli.webui_support import ( from nanobot.cli.webui_support import (
_gateway_health_ready, _gateway_health_ready,
_webui_browser_url, _host_for_local_browser,
_webui_endpoint_reachable, _webui_endpoint_reachable,
webui_bootstrap_secret,
) )
from nanobot.config.paths import get_data_dir from nanobot.config.paths import get_data_dir
from nanobot.config.schema import Config from nanobot.config.schema import Config
@@ -82,22 +80,17 @@ def launch_tui(
state_path = config_path.parent / "tui" / "state.json" state_path = config_path.parent / "tui" / "state.json"
chat_id = _initial_tui_chat_id(session_id, state_path) chat_id = _initial_tui_chat_id(session_id, state_path)
command = _resolve_tui_command() command = _resolve_tui_command()
gateway = _ensure_gateway( base_url, bootstrap_secret = _tui_gateway_connection(config)
config, gateway: _GatewayHandle | None = None
config_path=config_path, process: subprocess.Popen[Any] | None = None
workspace_override=workspace_override,
)
try: try:
bootstrap = _fetch_bootstrap(
gateway.base_url,
secret=webui_bootstrap_secret(config),
)
env = os.environ.copy() env = os.environ.copy()
env.pop("NANOBOT_TUI_WS_URL", None)
env.pop("NANOBOT_TUI_API_TOKEN", None)
env.update( env.update(
{ {
"NANOBOT_TUI_WS_URL": _authenticated_ws_url(bootstrap), "NANOBOT_TUI_BOOTSTRAP_URL": f"{base_url}/webui/bootstrap",
"NANOBOT_TUI_API_URL": gateway.base_url, "NANOBOT_TUI_API_URL": base_url,
"NANOBOT_TUI_API_TOKEN": str(bootstrap.get("api_token") or ""),
"NANOBOT_TUI_MODEL": _model_display(config)[0], "NANOBOT_TUI_MODEL": _model_display(config)[0],
"NANOBOT_TUI_MODEL_PRESET": config.agents.defaults.model_preset or "default", "NANOBOT_TUI_MODEL_PRESET": config.agents.defaults.model_preset or "default",
"NANOBOT_TUI_WORKSPACE": str(config.workspace_path), "NANOBOT_TUI_WORKSPACE": str(config.workspace_path),
@@ -108,18 +101,42 @@ def launch_tui(
"NANOBOT_TUI_THEME": theme, "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) env["NANOBOT_TUI_STATE_PATH"] = str(state_path)
if chat_id: if chat_id:
env["NANOBOT_TUI_CHAT_ID"] = chat_id env["NANOBOT_TUI_CHAT_ID"] = chat_id
else: else:
env.pop("NANOBOT_TUI_CHAT_ID", None) env.pop("NANOBOT_TUI_CHAT_ID", None)
return subprocess.run(command, env=env, check=False).returncode try:
except OSError as exc: process = subprocess.Popen(command, env=env)
raise TuiUnavailableError(f"could not start the native TUI: {exc}") from exc 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: finally:
lease = getattr(gateway, "lease", None) lease = getattr(gateway, "lease", None) if gateway is not None else None
if lease is not 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]: def _resolve_tui_command() -> list[str]:
@@ -364,6 +381,7 @@ def _ensure_gateway(
*, *,
config_path: Path, config_path: Path,
workspace_override: str | None, workspace_override: str | None,
wait_until_ready: bool = True,
) -> _GatewayHandle: ) -> _GatewayHandle:
from nanobot.gateway import ( from nanobot.gateway import (
GatewayClientLease, GatewayClientLease,
@@ -371,7 +389,7 @@ def _ensure_gateway(
GatewayRuntime, GatewayRuntime,
) )
base_url = _webui_browser_url(config).split("/#/", 1)[0].rstrip("/") base_url, _bootstrap_secret = _tui_gateway_connection(config)
instance = GatewayInstance.resolve( instance = GatewayInstance.resolve(
config_path=config_path, config_path=config_path,
workspace=workspace_override, workspace=workspace_override,
@@ -388,7 +406,7 @@ def _ensure_gateway(
"the matching gateway instance is running on a different port; " "the matching gateway instance is running on a different port; "
"restart it or use `nanobot agent --classic`" "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) return _GatewayHandle(base_url=base_url, lease=lease)
elif endpoint_reachable: elif endpoint_reachable:
raise TuiUnavailableError( raise TuiUnavailableError(
@@ -405,6 +423,9 @@ def _ensure_gateway(
f"logs: {result.status.log_path}" f"logs: {result.status.log_path}"
) )
if not wait_until_ready:
return _GatewayHandle(base_url=base_url, lease=lease)
deadline = time.monotonic() + 20 deadline = time.monotonic() + 20
while time.monotonic() < deadline: while time.monotonic() < deadline:
if _webui_endpoint_reachable(base_url): if _webui_endpoint_reachable(base_url):
@@ -427,37 +448,22 @@ def _ensure_gateway(
raise raise
def _fetch_bootstrap(base_url: str, *, secret: str) -> dict[str, Any]: def _tui_gateway_connection(config: Config) -> tuple[str, str]:
headers = {"X-Nanobot-Auth": secret} if secret else {} """Read the small bootstrap subset without importing the WebSocket runtime."""
request = urllib.request.Request(f"{base_url}/webui/bootstrap", headers=headers) 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: try:
with urllib.request.urlopen(request, timeout=5) as response: port = int(settings.get("port") or 8765)
raw_payload: Any = json.loads(response.read().decode("utf-8")) except (TypeError, ValueError):
except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError) as exc: port = 8765
raise TuiUnavailableError( secret = str(
f"could not authenticate with the local gateway: {exc}" settings.get("tokenIssueSecret")
) from exc or settings.get("token_issue_secret")
if not isinstance(raw_payload, dict): or settings.get("token")
raise TuiUnavailableError("gateway bootstrap response is missing ws_path") or ""
payload = cast(dict[str, Any], raw_payload) ).strip()
if not payload.get("ws_path"): return f"http://{host}:{port}", secret
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)
)
def _websocket_chat_id(session_id: str) -> str | None: def _websocket_chat_id(session_id: str) -> str | None:
+3 -3
View File
@@ -466,8 +466,8 @@ class GatewayClientLease:
self._write_state(state) self._write_state(state)
return True return True
def release(self, *, timeout_s: int = 20) -> bool: def release(self, *, timeout_s: int = 20, wait_for_stop: bool = True) -> bool:
"""Release this client and stop an ephemeral gateway when it was the last.""" """Release this client, optionally leaving last-client shutdown to the monitor."""
if not self._acquired: if not self._acquired:
return False return False
while True: while True:
@@ -482,7 +482,7 @@ class GatewayClientLease:
self._acquired = False self._acquired = False
should_stop = not clients and bool(state.get("auto_stop")) should_stop = not clients and bool(state.get("auto_stop"))
self._write_or_clear(state) self._write_or_clear(state)
if not should_stop: if not should_stop or not wait_for_stop:
return False return False
result = self.runtime._stop(timeout_s=timeout_s) result = self.runtime._stop(timeout_s=timeout_s)
stopped = result.ok or result.message in { stopped = result.ok or result.message in {
+1 -1
View File
@@ -107,7 +107,7 @@ dev = [
] ]
[project.scripts] [project.scripts]
nanobot = "nanobot.cli.commands:app" nanobot = "nanobot.cli.entry:main"
# Third-party tool plugins register here. Built-in tools are discovered # Third-party tool plugins register here. Built-in tools are discovered
# automatically via pkgutil scanning in ToolLoader.discover(). # automatically via pkgutil scanning in ToolLoader.discover().
+15
View File
@@ -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"])
+96 -28
View File
@@ -13,7 +13,6 @@ from nanobot.cli.agent import agent
from nanobot.cli.tui_launcher import ( from nanobot.cli.tui_launcher import (
TuiSessionError, TuiSessionError,
TuiUnavailableError, TuiUnavailableError,
_authenticated_ws_url,
_download_release_tui, _download_release_tui,
_ensure_gateway, _ensure_gateway,
_initial_tui_chat_id, _initial_tui_chat_id,
@@ -52,14 +51,6 @@ def _release_archive(
return payload, checksum 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( @pytest.mark.parametrize(
("session_id", "expected"), ("session_id", "expected"),
[ [
@@ -104,40 +95,43 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
config = Config() config = Config(
channels={"websocket": {"tokenIssueSecret": "bootstrap-secret"}},
)
config.model_presets["Deep Research"] = ModelPresetConfig(model="openai/gpt-5.6") config.model_presets["Deep Research"] = ModelPresetConfig(model="openai/gpt-5.6")
config.agents.defaults.model_preset = "Deep Research" config.agents.defaults.model_preset = "Deep Research"
captured: dict[str, str] = {} captured: dict[str, str] = {}
events: list[str] = []
released: list[bool] = [] released: list[bool] = []
class FakeLease: class FakeLease:
def release(self) -> None: def release(self, *, wait_for_stop: bool = True) -> None:
assert wait_for_stop is False
released.append(True) released.append(True)
monkeypatch.setattr("nanobot.cli.tui_launcher._resolve_tui_command", lambda: ["nanobot-tui"]) monkeypatch.setattr("nanobot.cli.tui_launcher._resolve_tui_command", lambda: ["nanobot-tui"])
monkeypatch.setattr( def ensure_gateway(*args: object, **kwargs: object) -> SimpleNamespace:
"nanobot.cli.tui_launcher._ensure_gateway", assert events == ["spawned"]
lambda *args, **kwargs: SimpleNamespace( assert kwargs["wait_until_ready"] is False
return SimpleNamespace(
base_url="http://127.0.0.1:8765", base_url="http://127.0.0.1:8765",
lease=FakeLease(), 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 command == ["nanobot-tui"]
assert check is False
captured.update(env) 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( result = launch_tui(
config, config,
@@ -150,10 +144,57 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
assert result == 0 assert result == 0
assert captured["NANOBOT_TUI_MODEL"] == "openai/gpt-5.6" assert captured["NANOBOT_TUI_MODEL"] == "openai/gpt-5.6"
assert captured["NANOBOT_TUI_MODEL_PRESET"] == "Deep Research" 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 "NANOBOT_TUI_CHAT_ID" not in captured
assert events == ["spawned", "waited"]
assert released == [True] 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( def test_explicit_tui_binary_must_exist(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, 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" 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( def test_gateway_started_for_tui_stops_when_its_last_lease_exits(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
+19
View File
@@ -479,6 +479,25 @@ def test_last_interactive_client_stops_an_on_demand_gateway(tmp_path, monkeypatc
assert not webui.state_path.exists() 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): def test_last_client_shutdown_preserves_a_replacement_lease(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux") runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid) monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
+1 -1
View File
@@ -9,7 +9,7 @@ bun run --cwd tui test
bun run --cwd tui build 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. 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.
+9 -5
View File
@@ -37,7 +37,9 @@ def _wait_for(process: Any, needle: str, timeout: float) -> str:
output.append(_read(process, min(0.1, deadline - time.monotonic()))) output.append(_read(process, min(0.1, deadline - time.monotonic())))
text = "".join(output) text = "".join(output)
if needle not in text: 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 return text
@@ -47,7 +49,7 @@ def _wait_for_exit(process: Any, timeout: float) -> int:
time.sleep(0.05) time.sleep(0.05)
if process.isalive(): if process.isalive():
process.close(force=True) 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) return int(process.exitstatus or 0)
@@ -78,6 +80,8 @@ def main() -> int:
# width probes. Keep this smoke test focused on application behavior. # width probes. Keep this smoke test focused on application behavior.
"OPENTUI_FORCE_EXPLICIT_WIDTH": "false", "OPENTUI_FORCE_EXPLICIT_WIDTH": "false",
} }
env.pop("HERDR_ENV", None)
env.pop("HERDR_PANE_ID", None)
process = PtyProcess.spawn( process = PtyProcess.spawn(
[bun, "src/index.ts"], [bun, "src/index.ts"],
cwd=str(ROOT), cwd=str(ROOT),
@@ -100,11 +104,11 @@ def main() -> int:
if "\x1b[18;" not in resized or ";42H" not in resized: if "\x1b[18;" not in resized or ";42H" not in resized:
raise AssertionError("TUI did not repaint to the resized ConPTY dimensions") raise AssertionError("TUI did not repaint to the resized ConPTY dimensions")
# First Ctrl+C clears the draft. The second exits the app and must # Ctrl+C clears the draft. The local exit command must still work while
# restore the alternate screen without an unhandled exception. # the intentionally unavailable gateway has not attached a chat.
process.sendcontrol("c") process.sendcontrol("c")
output.append(_read(process, 0.2)) output.append(_read(process, 0.2))
process.sendcontrol("c") process.write("exit\r")
output.append(_wait_for(process, LEAVE_ALT_SCREEN, 8)) output.append(_wait_for(process, LEAVE_ALT_SCREEN, 8))
exit_code = _wait_for_exit(process, 8) exit_code = _wait_for_exit(process, 8)
finally: finally:
+6 -4
View File
@@ -68,7 +68,7 @@ def _wait_for_exit(pid: int, timeout: float) -> int:
time.sleep(0.05) time.sleep(0.05)
os.kill(pid, signal.SIGKILL) os.kill(pid, signal.SIGKILL)
os.waitpid(pid, 0) 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: def main() -> int:
@@ -90,6 +90,8 @@ def main() -> int:
# terminal emulator's optional OSC 10/11 response. # terminal emulator's optional OSC 10/11 response.
"NANOBOT_TUI_THEME": "dark", "NANOBOT_TUI_THEME": "dark",
} }
env.pop("HERDR_ENV", None)
env.pop("HERDR_PANE_ID", None)
pid, master = pty.fork() pid, master = pty.fork()
if pid == 0: if pid == 0:
@@ -117,11 +119,11 @@ def main() -> int:
if b"\x1b[18;" not in resized or b";42H" not in resized: if b"\x1b[18;" not in resized or b";42H" not in resized:
raise AssertionError("TUI did not repaint to the resized PTY dimensions") raise AssertionError("TUI did not repaint to the resized PTY dimensions")
# First Ctrl+C clears the draft; the second exits and must restore the # Ctrl+C clears the draft. The local exit command must still work while
# alternate screen without a prompt_toolkit-style traceback. # the intentionally unavailable gateway has not attached a chat.
os.write(master, b"\x03") os.write(master, b"\x03")
output.extend(_read(master, 0.2)) 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)) output.extend(_wait_for(master, LEAVE_ALT_SCREEN, 5))
exit_code = _wait_for_exit(pid, 5) exit_code = _wait_for_exit(pid, 5)
reaped = True reaped = True
+23
View File
@@ -1488,7 +1488,9 @@ describe("NanobotTui layout", () => {
test("overlaps automatic terminal detection with connection startup", async () => { test("overlaps automatic terminal detection with connection startup", async () => {
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
let connected = false let connected = false
let rendered = false
let resolveMode: (mode: "light") => void = () => undefined let resolveMode: (mode: "light") => void = () => undefined
setup.renderer.start = () => { rendered = true }
setup.renderer.waitForThemeMode = () => new Promise((resolve) => { setup.renderer.waitForThemeMode = () => new Promise((resolve) => {
resolveMode = resolve resolveMode = resolve
}) })
@@ -1505,6 +1507,7 @@ describe("NanobotTui layout", () => {
const starting = app.start() const starting = app.start()
await Bun.sleep(1) await Bun.sleep(1)
expect(connected).toBe(true) expect(connected).toBe(true)
expect(rendered).toBe(true)
resolveMode("light") resolveMode("light")
await starting await starting
@@ -1994,6 +1997,26 @@ describe("NanobotTui layout", () => {
expect(closed).toBe(true) expect(closed).toBe(true)
expect(setup.renderer.isDestroyed).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", () => { describe("NanobotTui in a Herdr pane", () => {
+41 -12
View File
@@ -21,6 +21,7 @@ import {
import { import {
NanobotClient, NanobotClient,
fetchHistory, fetchHistory,
fetchGatewayConnection,
fetchMentionCandidates, fetchMentionCandidates,
fetchSessionContext, fetchSessionContext,
fetchSessions, fetchSessions,
@@ -78,7 +79,9 @@ import {
import { createTuiHost, currentGitBranch, type TuiHost } from "./host" import { createTuiHost, currentGitBranch, type TuiHost } from "./host"
interface AppOptions { interface AppOptions {
wsUrl: string wsUrl?: string
bootstrapUrl?: string
bootstrapSecret?: string
apiUrl: string apiUrl: string
apiToken: string apiToken: string
chatId?: string chatId?: string
@@ -472,7 +475,23 @@ export class NanobotTui {
) )
this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette)) this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette))
this.client = client || new NanobotClient({ 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, chatId: options.chatId,
onEvent: (event) => this.accept(event), onEvent: (event) => this.accept(event),
onStatus: (status, detail) => this.handleStatus(status, detail), onStatus: (status, detail) => this.handleStatus(status, detail),
@@ -719,16 +738,15 @@ export class NanobotTui {
void this.loadCommands() void this.loadCommands()
void this.loadMentions() void this.loadMentions()
this.runtimeControls.preload() this.runtimeControls.preload()
this.renderer.start()
// OpenTUI learns the real terminal background through OSC 10/11. Wait for // OpenTUI learns the real terminal background through OSC 10/11. Wait for
// that bounded probe before first paint, as OpenCode does, so a light // that bounded probe after first paint. The neutral terminal background is
// terminal does not briefly render the dark palette. The app already owns // safe to render immediately, and the detected palette can be applied later.
// the renderer here, so a signal during the probe can still restore it.
if (this.options.theme === "auto") await this.renderer.waitForThemeMode(1_000) if (this.options.theme === "auto") await this.renderer.waitForThemeMode(1_000)
if (this.quitting) return if (this.quitting) return
if (this.options.theme === "auto" && this.renderer.themeMode) { if (this.options.theme === "auto" && this.renderer.themeMode) {
this.applyTheme(this.renderer.themeMode) this.applyTheme(this.renderer.themeMode)
} }
this.renderer.start()
} }
stop(): void { stop(): void {
@@ -775,6 +793,10 @@ export class NanobotTui {
return return
} }
if (!visibleContent) return if (!visibleContent) return
if (["exit", "quit", "/exit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
this.quit()
return
}
const completion = this.commandMenu.completion(visibleContent) const completion = this.commandMenu.completion(visibleContent)
if (completion) { if (completion) {
this.setComposer(completion) this.setComposer(completion)
@@ -804,10 +826,6 @@ export class NanobotTui {
this.status.content = "Preparing chat…" this.status.content = "Preparing chat…"
return return
} }
if (["exit", "quit", "/exit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
this.quit()
return
}
const prompt = { content, options: mentionOptions(content, this.availableMentions()) } const prompt = { content, options: mentionOptions(content, this.availableMentions()) }
if (this.activeTurn) { if (this.activeTurn) {
this.sendPrompt(prompt, true) this.sendPrompt(prompt, true)
@@ -1132,6 +1150,14 @@ export class NanobotTui {
for (const event of events || []) this.accept(event) 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 { private handleStatus(status: ConnectionStatus, detail?: string): void {
if (status === "connected") { if (status === "connected") {
this.ready = false this.ready = false
@@ -1141,9 +1167,12 @@ export class NanobotTui {
} }
if (status === "connecting") { if (status === "connecting") {
this.ready = false 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) if (detail) this.setActive(false)
this.status.content = detail ? "Reconnecting…" : "Connecting…" this.status.content = `${label}`
return return
} }
if (status === "error") { if (status === "error") {
+11 -7
View File
@@ -1,12 +1,6 @@
import { NanobotTui, type AppOptions } from "./app" import { NanobotTui, type AppOptions } from "./app"
import { currentGitBranch } from "./host" 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"] { function themePreference(): AppOptions["theme"] {
const value = process.env.NANOBOT_TUI_THEME?.trim() || "auto" const value = process.env.NANOBOT_TUI_THEME?.trim() || "auto"
if (value === "auto" || value === "dark" || value === "light") return value 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 workspace = process.env.NANOBOT_TUI_WORKSPACE?.trim() || ""
const hostWorkspace = process.cwd() 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 = { 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() || "", apiUrl: process.env.NANOBOT_TUI_API_URL?.trim() || "",
apiToken: process.env.NANOBOT_TUI_API_TOKEN?.trim() || "", apiToken: process.env.NANOBOT_TUI_API_TOKEN?.trim() || "",
chatId: process.env.NANOBOT_TUI_CHAT_ID?.trim() || undefined, chatId: process.env.NANOBOT_TUI_CHAT_ID?.trim() || undefined,
+152
View File
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"
import { import {
NanobotClient, NanobotClient,
GatewayConnectionError,
fetchGatewayConnection,
fetchHistory, fetchHistory,
fetchMentionCandidates, fetchMentionCandidates,
fetchRuntimeControls, fetchRuntimeControls,
@@ -37,6 +39,156 @@ class FakeSocket {
} }
describe("gateway protocol", () => { 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", () => { test("represents lifecycle frames without browser state", () => {
const events: InboundEvent[] = [ const events: InboundEvent[] = [
{ {
+110 -9
View File
@@ -151,13 +151,30 @@ type OutboundEvent =
} }
export interface ClientOptions { export interface ClientOptions {
url: string url?: string
resolveConnection?: () => Promise<GatewayConnection>
onConnection?: (connection: GatewayConnection) => void
connectionRetryLabel?: string
startupRetryMaxDelayMs?: number
chatId?: string chatId?: string
reconnectDelayMs?: number reconnectDelayMs?: number
onEvent: (event: InboundEvent) => void onEvent: (event: InboundEvent) => void
onStatus: (status: ConnectionStatus, detail?: string) => 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 { export interface HistoryMessage {
role: "user" | "assistant" | "activity" role: "user" | "assistant" | "activity"
content: string content: string
@@ -747,12 +764,63 @@ function sessionLabelForMention(session: SessionSummary): string {
return (session.title || session.preview || "Untitled chat").replace(/\s+/gu, " ").trim() 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 { export class NanobotClient {
private socket: WebSocket | null = null private socket: WebSocket | null = null
private chatId = "" private chatId = ""
private reconnectTimer: ReturnType<typeof setTimeout> | null = null private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempt = 0 private reconnectAttempt = 0
private closedByClient = false private closedByClient = false
private opening = false
private connectedOnce = false
constructor(private readonly options: ClientOptions) {} constructor(private readonly options: ClientOptions) {}
@@ -762,16 +830,46 @@ export class NanobotClient {
connect(): void { connect(): void {
this.closedByClient = false this.closedByClient = false
this.open() void this.open()
} }
private open(): void { private async open(): Promise<void> {
if (this.socket) return if (this.socket || this.opening || this.closedByClient) return
this.opening = true
this.options.onStatus("connecting") 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 this.socket = socket
socket.addEventListener("open", () => { socket.addEventListener("open", () => {
if (this.socket !== socket) return if (this.socket !== socket) return
this.connectedOnce = true
this.reconnectAttempt = 0 this.reconnectAttempt = 0
this.options.onStatus("connected") this.options.onStatus("connected")
}) })
@@ -872,14 +970,17 @@ export class NanobotClient {
this.options.onEvent(event) this.options.onEvent(event)
} }
private scheduleReconnect(): void { private scheduleReconnect(announce = true): void {
if (this.reconnectTimer || this.closedByClient) return if (this.reconnectTimer || this.closedByClient) return
const base = this.options.reconnectDelayMs ?? 500 const base = this.options.reconnectDelayMs ?? 500
const delay = Math.min(8_000, base * 2 ** Math.min(this.reconnectAttempt++, 4)) const maxDelay = this.connectedOnce
this.options.onStatus("connecting", `reconnecting in ${delay}ms`) ? 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 = setTimeout(() => {
this.reconnectTimer = null this.reconnectTimer = null
this.open() void this.open()
}, delay) }, delay)
} }
+8
View File
@@ -112,6 +112,14 @@ export class RuntimeControls {
void this.load().catch(() => {}) 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 { updateWorkspaceScope(scope: WorkspaceScopePayload): void {
this.scope = scope this.scope = scope
this.render() this.render()