Compare commits

..
Author SHA1 Message Date
chengyongru e341bb2661 perf(tui): skip redundant dependency installs 2026-08-27 12:05:16 +08:00
chengyongruandGitHub 3a62b0b744 fix(tui): surface chat connection failures (#5543)
* fix(tui): surface chat connection failures

* fix(tui): keep connection copy implementation-agnostic

* test(tui): avoid millisecond timing boundary

* fix(tui): use gateway health for connection status

* fix(tui): use product-level readiness copy
2026-08-27 11:37:30 +08:00
chengyongruandchengyongru 55f85b3c1f refactor(agent): remove duplicate progress streaming path 2026-08-27 10:56:07 +08:00
Xubin Ren 2113870e27 fix(cli): foreground the WebUI browser 2026-08-27 10:45:40 +08:00
Xubin Ren b632186b5f perf(webui): smooth paged history scrolling 2026-08-27 10:45:40 +08:00
Xubin Ren d7d03b25ef fix(webui): preserve semantic history anchors 2026-08-27 10:45:40 +08:00
Xubin Ren 6a3f53a917 fix(webui): stabilize fast history scrolling 2026-08-27 10:45:40 +08:00
Xubin Ren 25e20a1458 feat(webui): link model picker to settings 2026-08-27 10:45:40 +08:00
Xubin Ren 5678f83290 fix(webui): tighten conversation top spacing 2026-08-27 10:45:40 +08:00
Xubin Ren cb7b640d36 fix(webui): soften composer scroll boundary 2026-08-27 10:45:40 +08:00
Xubin Ren 701926eba1 fix(webui): preserve file edit activity order 2026-08-27 10:45:40 +08:00
Xubin Ren bbbfacbc64 fix(webui): compact single-pane chat header 2026-08-27 10:45:40 +08:00
17 changed files with 800 additions and 288 deletions
+1 -49
View File
@@ -46,13 +46,11 @@ from nanobot.runtime_context import (
from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY
from nanobot.utils.helpers import (
IncrementalThinkExtractor,
build_assistant_message,
estimate_message_tokens,
estimate_prompt_tokens_chain,
extract_reasoning,
strip_reasoning_tags,
strip_think,
)
from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.utils.prompt_templates import render_template
@@ -67,7 +65,6 @@ from nanobot.utils.runtime import (
)
ContinuationCallback = Callable[[], str | None]
ProgressCallback = Callable[[str], Awaitable[None]]
RetryWaitCallback = Callable[[str], Awaitable[None]]
CheckpointCallback = Callable[[dict[str, Any]], Awaitable[None]]
InjectionCallback = Callable[..., Awaitable[Iterable[Any] | None]]
@@ -112,7 +109,6 @@ class AgentRunSpec:
session_key: str | None = None
context_block_limit: int | None = None
provider_retry_mode: str = "standard"
progress_callback: ProgressCallback | None = None
retry_wait_callback: RetryWaitCallback | None = None
checkpoint_callback: CheckpointCallback | None = None
injection_callback: InjectionCallback | None = None
@@ -951,14 +947,7 @@ class AgentRunner:
tools=spec.tools.get_definitions(),
)
wants_streaming = hook.wants_streaming()
progress_callback = spec.progress_callback
wants_progress_streaming = (
not wants_streaming
and progress_callback is not None
and getattr(spec.runtime.provider, "supports_progress_deltas", False) is True
)
progress_state: dict[str, bool] | None = None
active_hosted_tools: dict[str, dict[str, Any]] = {}
request_started_at = 0.0
first_output_at: float | None = None
@@ -1029,40 +1018,6 @@ class AgentRunner:
on_tool_call_delta=_provider_tool_event,
on_stream_recover=_stream_recover,
)
elif wants_progress_streaming:
stream_buf = ""
think_extractor = IncrementalThinkExtractor()
progress_state = {"reasoning_open": False}
async def _stream_progress(delta: str) -> None:
nonlocal stream_buf
if not delta:
return
_generation_delta(delta)
prev_clean = strip_think(stream_buf)
stream_buf += delta
new_clean = strip_think(stream_buf)
incremental = new_clean[len(prev_clean):]
if await think_extractor.feed(stream_buf, hook.emit_reasoning):
context.streamed_reasoning = True
progress_state["reasoning_open"] = True
if incremental:
if progress_state["reasoning_open"]:
await hook.emit_reasoning_end()
progress_state["reasoning_open"] = False
context.streamed_content = True
callback = progress_callback
if callback is not None:
await callback(incremental)
coro = spec.runtime.provider.chat_stream_with_retry(
**kwargs,
provider_context=provider_context,
on_content_delta=_stream_progress,
on_tool_call_delta=_provider_tool_event,
)
else:
coro = spec.runtime.provider.chat_with_retry(
**kwargs,
@@ -1074,10 +1029,9 @@ class AgentRunner:
# very slow deltas can still run forever. Use a more generous wall-clock
# timeout for streaming while preserving NANOBOT_LLM_TIMEOUT_S=0 as an
# opt-out for all LLM wall-clock timeouts.
is_streaming_request = wants_streaming or wants_progress_streaming
outer_timeout_s = (
max(300.0, timeout_s * 2)
if is_streaming_request and timeout_s is not None
if wants_streaming and timeout_s is not None
else timeout_s
)
request_started_at = time.perf_counter()
@@ -1115,8 +1069,6 @@ class AgentRunner:
"error": response.content
or "Model request failed before the provider-hosted tool completed.",
})
if progress_state and progress_state.get("reasoning_open"):
await hook.emit_reasoning_end()
dropped, all_dropped, original_finish_reason = (
self._drop_malformed_tool_calls(response)
)
+53
View File
@@ -21,6 +21,7 @@ from nanobot.cli.process_identity import named_executable
from nanobot.cli.runtime_config import _model_display
from nanobot.cli.webui_support import (
_gateway_health_ready,
_gateway_health_url,
_gateway_instance_command,
_host_for_local_browser,
_webui_endpoint_reachable,
@@ -66,6 +67,8 @@ _TUI_RELEASE_LIMITS = {
_TUI_DETACH_EXIT_CODE = 90
_GATEWAY_READY_TIMEOUT_S = 20.0
_GATEWAY_READY_POLL_S = 0.1
_TUI_DEPENDENCY_METADATA = ("package.json", "bun.lock")
_TUI_DEPENDENCY_CACHE = ".nanobot-install.sha256"
@dataclass(frozen=True)
@@ -96,6 +99,10 @@ def launch_tui(
env.update(
{
"NANOBOT_TUI_BOOTSTRAP_URL": f"{base_url}/webui/bootstrap",
"NANOBOT_TUI_HEALTH_URL": _gateway_health_url(
config.gateway.host,
config.gateway.port,
),
"NANOBOT_TUI_API_URL": base_url,
"NANOBOT_TUI_MODEL": _model_display(config)[0],
"NANOBOT_TUI_MODEL_PRESET": config.agents.defaults.model_preset or "default",
@@ -219,6 +226,21 @@ def _tui_source_dir(project_root: Path) -> Path | None:
def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]:
dependency = source_dir / "node_modules" / "@opentui" / "core"
cache = source_dir / "node_modules" / _TUI_DEPENDENCY_CACHE
fingerprint = _tui_dependency_fingerprint(source_dir)
if dependency.is_dir() and fingerprint is not None:
try:
if cache.read_text(encoding="ascii") == f"{fingerprint}\n":
return _source_tui_command(source_dir, bun)
except (OSError, UnicodeError):
pass
try:
cache.unlink(missing_ok=True)
except OSError as exc:
raise TuiUnavailableError(
f"could not prepare the TUI dependency install: {exc}"
) from exc
try:
install = subprocess.run(
[bun, "install", "--frozen-lockfile"],
@@ -233,6 +255,37 @@ def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]:
detail = (install.stderr or install.stdout).strip().splitlines()
suffix = f": {detail[-1]}" if detail else ""
raise TuiUnavailableError(f"could not install TUI dependencies{suffix}")
current_fingerprint = _tui_dependency_fingerprint(source_dir)
if fingerprint is not None and current_fingerprint == fingerprint:
pending = cache.with_name(f"{cache.name}.tmp-{os.getpid()}")
try:
pending.write_text(f"{fingerprint}\n", encoding="ascii")
pending.replace(cache)
except OSError:
try:
pending.unlink(missing_ok=True)
except OSError:
pass
return _source_tui_command(source_dir, bun)
def _tui_dependency_fingerprint(source_dir: Path) -> str | None:
digest = hashlib.sha256()
try:
for name in _TUI_DEPENDENCY_METADATA:
content = (source_dir / name).read_bytes()
digest.update(name.encode())
digest.update(b"\0")
digest.update(len(content).to_bytes(8, "big"))
digest.update(content)
except OSError:
return None
return digest.hexdigest()
def _source_tui_command(source_dir: Path, bun: str) -> list[str]:
executable = named_executable(
bun,
name="nanobot-tui",
-2
View File
@@ -603,8 +603,6 @@ _SYNTHETIC_USER_CONTENT = "(conversation continued)"
class LLMProvider(ABC):
"""Base class for LLM providers."""
supports_progress_deltas = False
_CHAT_RETRY_DELAYS = (1, 2, 4)
_PERSISTENT_MAX_DELAY = 60
_PERSISTENT_IDENTICAL_ERROR_LIMIT = 10
-4
View File
@@ -157,10 +157,6 @@ class FallbackProvider(LLMProvider):
super().set_llm_call_observer(observer)
self._primary.set_llm_call_observer(observer)
@property
def supports_progress_deltas(self) -> bool:
return bool(getattr(self._primary, "supports_progress_deltas", False))
def can_resume_conversation_state(
self,
state: ProviderConversationState,
@@ -44,8 +44,6 @@ _COMPACTION_RETAINED_CHAR_BUDGET = 256_000
class OpenAICodexProvider(LLMProvider):
"""Use Codex OAuth to call the Responses API."""
supports_progress_deltas = True
def __init__(
self,
default_model: str = "openai-codex/gpt-5.6-sol",
-2
View File
@@ -63,8 +63,6 @@ def _is_named_x_search_tool(value: object) -> bool:
class XAIGrokProvider(LLMProvider):
"""Call xAI's subscription proxy and expose supported hosted tools."""
supports_progress_deltas = True
def __init__(
self,
default_model: str = DEFAULT_XAI_GROK_MODEL,
+2 -12
View File
@@ -373,7 +373,6 @@ class TestToolEventProgress:
"""The /goal command rewrites the prompt but must not bypass WebUI file-edit progress."""
bus = MessageBus()
provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "test-model"
call_count = 0
@@ -460,7 +459,6 @@ class TestToolEventProgress:
"""Non-streaming channels should get one final reply, not token progress spam."""
bus = MessageBus()
provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "openai-codex/gpt-5.5"
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Hello", tool_calls=[]))
provider.chat_stream_with_retry = AsyncMock()
@@ -493,7 +491,6 @@ class TestToolEventProgress:
"""Streaming channels still receive provider deltas through stream events."""
bus = MessageBus()
provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "openai-codex/gpt-5.5"
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
@@ -544,7 +541,6 @@ class TestToolEventProgress:
) -> None:
bus = MessageBus()
provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "test-model"
responses = iter([
LLMResponse(content="first-", finish_reason="length"),
@@ -590,7 +586,6 @@ class TestToolEventProgress:
) -> None:
bus = MessageBus()
provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "test-model"
call_count = 0
@@ -637,7 +632,6 @@ class TestToolEventProgress:
) -> None:
bus = MessageBus()
provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "test-model"
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
@@ -728,7 +722,6 @@ class TestToolEventProgress:
"""A no-tools finalization must not be dropped after empty stream retries."""
bus = MessageBus()
provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "openai-codex/gpt-5.5"
provider.chat_stream_with_retry = AsyncMock(side_effect=[
LLMResponse(content=None, tool_calls=[]),
@@ -776,7 +769,6 @@ class TestToolEventProgress:
) -> None:
bus = MessageBus()
provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "openai-codex/gpt-5.5"
first_request_started = asyncio.Event()
release_first_request = asyncio.Event()
@@ -935,7 +927,6 @@ class TestToolEventProgress:
"""Recovered streaming output should use a new stream segment."""
bus = MessageBus()
provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "openai-codex/gpt-5.5"
async def chat_stream_with_retry(*, on_content_delta, on_stream_recover, **kwargs):
@@ -988,13 +979,12 @@ class TestToolEventProgress:
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_streamed_progress_is_not_repeated_before_tool_execution(
async def test_streamed_content_is_not_repeated_before_tool_execution(
self,
tmp_path: Path,
) -> None:
"""If content was already streamed as progress, tool setup should not repeat it."""
"""If content was already streamed, tool setup should not repeat it."""
loop = _make_loop(tmp_path)
loop.provider.supports_progress_deltas = True
tool_call = ToolCallRequest(id="call1", name="custom_tool", arguments={"path": "foo.txt"})
calls = iter([
LLMResponse(content="I will inspect it.", tool_calls=[tool_call]),
+2 -68
View File
@@ -798,64 +798,6 @@ async def test_runner_times_out_never_ending_streaming_request():
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_closes_progress_reasoning_on_streaming_wall_timeout():
from nanobot.agent.hook import AgentHook
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
provider.supports_progress_deltas = True
events: list[tuple[str, str | None]] = []
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
try:
await on_content_delta("<think>working...</think>")
await asyncio.sleep(3600)
finally:
events.append(("provider_cancelled", None))
provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
tools = MagicMock()
tools.get_definitions.return_value = []
class ProgressReasoningHook(AgentHook):
async def emit_reasoning(self, reasoning_content: str | None) -> None:
if reasoning_content:
events.append(("reasoning", reasoning_content))
async def emit_reasoning_end(self) -> None:
events.append(("reasoning_end", None))
real_wait_for = asyncio.wait_for
async def fake_wait_for(coro, *, timeout):
assert timeout == 300.0
return await real_wait_for(coro, timeout=0.01)
runner = AgentRunner()
with patch("nanobot.agent.runner.asyncio.wait_for", fake_wait_for):
result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "think forever"}],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=ProgressReasoningHook(),
progress_callback=AsyncMock(),
llm_timeout_s=1,
))
assert result.stop_reason == "error"
assert result.final_content == "Error calling LLM: timed out after 300s"
assert events == [
("reasoning", "working..."),
("provider_cancelled", None),
("reasoning_end", None),
]
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_replaces_empty_tool_result_with_marker():
from nanobot.agent.runner import AgentRunner
@@ -1285,13 +1227,8 @@ async def test_runner_accumulates_usage_and_preserves_cache_reads():
@pytest.mark.asyncio
async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress():
"""Regression: provider retry heartbeats must route through
``retry_wait_callback``, not ``progress_callback``. Binding them to
the progress callback (as an earlier runtime refactor did) caused
internal retry diagnostics like "Model request failed, retry in 1s"
to leak to end-user channels as normal progress updates.
"""
async def test_runner_binds_on_retry_wait_callback():
"""Provider retry heartbeats use the explicitly supplied callback."""
from nanobot.agent.runner import AgentRunner
captured: dict = {}
@@ -1305,7 +1242,6 @@ async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress():
tools = MagicMock()
tools.get_definitions.return_value = []
progress_cb = AsyncMock()
retry_wait_cb = AsyncMock()
runner = AgentRunner()
@@ -1318,12 +1254,10 @@ async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress():
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
retry_wait_callback=retry_wait_cb,
))
assert captured["on_retry_wait"] is retry_wait_cb
assert captured["on_retry_wait"] is not progress_cb
# ---------------------------------------------------------------------------
+20 -68
View File
@@ -1,4 +1,4 @@
"""Tests for provider progress delta routing in the shared runner."""
"""Tests for runner progress hooks and provider event routing."""
import asyncio
from unittest.mock import AsyncMock, MagicMock
@@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from agent.runner_helpers import make_run_spec
from nanobot.agent.hook import CompositeHook
from nanobot.agent.hooks import FileEditActivityHook
from nanobot.agent.progress_hook import AgentProgressHook
from nanobot.agent.runner import AgentRunner
@@ -17,45 +16,9 @@ from nanobot.providers.base import LLMResponse, ToolCallRequest
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@pytest.mark.asyncio
async def test_runner_streams_provider_progress_deltas_by_default():
"""Direct runner users keep the existing opt-in provider progress behavior."""
provider = MagicMock()
provider.supports_progress_deltas = True
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
await on_content_delta("he")
await on_content_delta("llo")
return LLMResponse(content="hello", tool_calls=[], usage=None)
provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
tools = MagicMock()
tools.get_definitions.return_value = []
progress_cb = AsyncMock()
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[
{"role": "system", "content": "system"},
{"role": "user", "content": "hi"},
],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
))
assert result.final_content == "hello"
assert [call.args[0] for call in progress_cb.await_args_list] == ["he", "llo"]
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_routes_hosted_tool_events_to_structured_progress():
provider = MagicMock()
provider.supports_progress_deltas = True
async def chat_stream_with_retry(*, on_content_delta, on_tool_call_delta, **kwargs):
await on_tool_call_delta({
@@ -88,13 +51,17 @@ async def test_runner_routes_hosted_tool_events_to_structured_progress():
tools.get_definitions.return_value = []
progress_events: list[dict] = []
progress_text: list[str] = []
streamed_text: list[str] = []
async def progress_cb(content, *, tool_events=None, **kwargs):
progress_text.append(content)
if tool_events:
progress_events.extend(tool_events)
hook = CompositeHook([AgentProgressHook(on_progress=progress_cb)])
async def stream_cb(content: str) -> None:
streamed_text.append(content)
hook = AgentProgressHook(on_progress=progress_cb, on_stream=stream_cb)
result = await AgentRunner().run(make_run_spec(
provider,
initial_messages=[{"role": "user", "content": "search X"}],
@@ -102,7 +69,6 @@ async def test_runner_routes_hosted_tool_events_to_structured_progress():
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
hook=hook,
))
@@ -133,14 +99,14 @@ async def test_runner_routes_hosted_tool_events_to_structured_progress():
"embeds": [],
},
]
assert progress_text == ['search X "nanobot oauth"', "", "done"]
assert progress_text == ['search X "nanobot oauth"', ""]
assert streamed_text == ["done"]
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_fails_pending_hosted_tool_when_model_request_fails():
provider = MagicMock()
provider.supports_progress_deltas = True
async def chat_stream_with_retry(*, on_tool_call_delta, **kwargs):
await on_tool_call_delta({
@@ -166,7 +132,10 @@ async def test_runner_fails_pending_hosted_tool_when_model_request_fails():
if tool_events:
progress_events.extend(tool_events)
hook = CompositeHook([AgentProgressHook(on_progress=progress_cb)])
async def stream_cb(_content: str) -> None:
pass
hook = AgentProgressHook(on_progress=progress_cb, on_stream=stream_cb)
result = await AgentRunner().run(make_run_spec(
provider,
initial_messages=[{"role": "user", "content": "search X"}],
@@ -174,7 +143,6 @@ async def test_runner_fails_pending_hosted_tool_when_model_request_fails():
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
hook=hook,
))
@@ -200,7 +168,6 @@ async def test_runner_fails_pending_hosted_tool_when_model_request_fails():
@pytest.mark.asyncio
async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_path):
provider = MagicMock()
provider.supports_progress_deltas = True
call_count = 0
progress_events: list[dict] = []
(tmp_path / "big.txt").write_text("old\n", encoding="utf-8")
@@ -218,7 +185,7 @@ async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_pa
def prepare_call(self, name, params):
return tool, params, None
async def chat_stream_with_retry(**kwargs):
async def chat_with_retry(**kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
@@ -235,8 +202,7 @@ async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_pa
)
return LLMResponse(content="done", tool_calls=[], usage=None)
provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
provider.chat_with_retry = chat_with_retry
tools = Tools()
runner = AgentRunner()
@@ -246,7 +212,6 @@ async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_pa
model="test-model",
max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
workspace=tmp_path,
hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path),
))
@@ -263,13 +228,11 @@ async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_pa
and event["diff"]["format"] == "unified"
for event in progress_events
)
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_path):
provider = MagicMock()
provider.supports_progress_deltas = True
call_count = 0
progress_events: list[dict] = []
target = tmp_path / "notes.txt"
@@ -288,7 +251,7 @@ async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_pat
def prepare_call(self, name, params):
return tool, params, None
async def chat_stream_with_retry(**kwargs):
async def chat_with_retry(**kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
@@ -309,8 +272,7 @@ async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_pat
)
return LLMResponse(content="done", tool_calls=[], usage=None)
provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
provider.chat_with_retry = chat_with_retry
tools = Tools()
runner = AgentRunner()
@@ -320,7 +282,6 @@ async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_pat
model="test-model",
max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
workspace=tmp_path,
hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path),
))
@@ -335,13 +296,11 @@ async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_pat
and event["diff"]["format"] == "unified"
for event in progress_events
)
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path):
provider = MagicMock()
provider.supports_progress_deltas = True
call_count = 0
progress_events: list[dict] = []
@@ -358,7 +317,7 @@ async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path)
def prepare_call(self, name, params):
return tool, params, None
async def chat_stream_with_retry(**kwargs):
async def chat_with_retry(**kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
@@ -375,8 +334,7 @@ async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path)
)
return LLMResponse(content="done", tool_calls=[], usage=None)
provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
provider.chat_with_retry = chat_with_retry
tools = Tools()
runner = AgentRunner()
@@ -386,7 +344,6 @@ async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path)
model="test-model",
max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
workspace=tmp_path,
hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path),
))
@@ -395,13 +352,11 @@ async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path)
assert progress_events[-1]["path"] == "aborted.txt"
assert progress_events[-1]["phase"] == "error"
assert progress_events[-1]["status"] == "error"
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path):
provider = MagicMock()
provider.supports_progress_deltas = True
progress_events: list[dict] = []
executing = asyncio.Event()
target = tmp_path / "cancelled.txt"
@@ -426,7 +381,7 @@ async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path):
def prepare_call(self, name, params):
return tool, params, None
async def chat_stream_with_retry(**kwargs):
async def chat_with_retry(**kwargs):
return LLMResponse(
content=None,
tool_calls=[
@@ -439,8 +394,7 @@ async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path):
usage=None,
)
provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
provider.chat_with_retry = chat_with_retry
tools = Tools()
runner = AgentRunner()
@@ -450,7 +404,6 @@ async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path):
model="test-model",
max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
workspace=tmp_path,
hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path),
)))
@@ -464,4 +417,3 @@ async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path):
assert progress_events[-1]["path"] == "cancelled.txt"
assert progress_events[-1]["status"] == "error"
assert progress_events[-1]["error"] == "Task interrupted before this tool finished."
provider.chat_with_retry.assert_not_awaited()
+24 -23
View File
@@ -15,6 +15,7 @@ import pytest
from agent.runner_helpers import make_run_spec
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.progress_hook import AgentProgressHook
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, LLMUsage, ToolCallRequest
@@ -35,6 +36,18 @@ class _RecordingHook(AgentHook):
self.end_calls += 1
class _StreamRecordingHook(_RecordingHook):
def __init__(self) -> None:
super().__init__()
self.streamed: list[str] = []
def wants_streaming(self) -> bool:
return True
async def on_stream(self, _ctx: AgentHookContext, delta: str) -> None:
self.streamed.append(delta)
@pytest.mark.asyncio
async def test_runner_preserves_reasoning_fields_in_assistant_history():
"""Reasoning fields ride along on the persisted assistant message so
@@ -201,7 +214,6 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed():
from nanobot.agent.runner import AgentRunner
provider = MagicMock()
provider.supports_progress_deltas = True
async def chat_stream_with_retry(*, on_content_delta=None, **kwargs):
if on_content_delta:
@@ -218,12 +230,7 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed():
tools = MagicMock()
tools.get_definitions.return_value = []
progress_calls: list[str] = []
async def _progress(content: str, **_kwargs):
progress_calls.append(content)
hook = _RecordingHook()
hook = _StreamRecordingHook()
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "question"}],
@@ -232,11 +239,10 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed():
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=hook,
progress_callback=_progress,
))
assert result.final_content == "The answer."
assert progress_calls, "answer should have streamed via progress callback"
assert hook.streamed == ["The ", "answer."]
assert hook.emitted == ["step-by-step deduction"]
@@ -247,7 +253,6 @@ async def test_runner_does_not_double_emit_when_inline_think_already_streamed():
from nanobot.agent.runner import AgentRunner
provider = MagicMock()
provider.supports_progress_deltas = True
async def chat_stream_with_retry(*, on_content_delta=None, **kwargs):
if on_content_delta:
@@ -263,10 +268,16 @@ async def test_runner_does_not_double_emit_when_inline_think_already_streamed():
tools = MagicMock()
tools.get_definitions.return_value = []
async def _progress(content: str, **_kwargs):
reasoning_events: list[str] = []
async def _progress(content: str, *, reasoning: bool = False, **_kwargs):
if reasoning:
reasoning_events.append(content)
async def _stream(_content: str) -> None:
pass
hook = _RecordingHook()
hook = AgentProgressHook(on_progress=_progress, on_stream=_stream)
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "question"}],
@@ -275,12 +286,10 @@ async def test_runner_does_not_double_emit_when_inline_think_already_streamed():
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=hook,
progress_callback=_progress,
))
assert result.final_content == "The answer."
assert hook.emitted == ["working..."]
assert hook.end_calls >= 1, "reasoning stream must be closed once the answer starts"
assert reasoning_events == ["working..."]
@pytest.mark.asyncio
@@ -320,14 +329,6 @@ async def test_runner_closes_reasoning_stream_after_one_shot_response():
assert hook.end_calls == 1
class _StreamRecordingHook(_RecordingHook):
def wants_streaming(self) -> bool:
return True
async def on_stream(self, _ctx: AgentHookContext, delta: str) -> None:
pass
@pytest.mark.asyncio
async def test_runner_streams_native_thinking_deltas_without_post_hoc_dup():
"""Anthropic-style ``on_thinking_delta`` should fan out to ``emit_reasoning``;
+89 -4
View File
@@ -51,6 +51,14 @@ def _release_archive(
return payload, checksum
def _tui_source(tmp_path: Path) -> Path:
source_dir = tmp_path / "tui"
source_dir.mkdir()
(source_dir / "package.json").write_text('{"dependencies": {}}\n', encoding="utf-8")
(source_dir / "bun.lock").write_text('lockfileVersion = 1\n', encoding="utf-8")
return source_dir
@pytest.mark.parametrize(
("session_id", "expected"),
[
@@ -142,6 +150,7 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
assert captured["NANOBOT_TUI_BOOTSTRAP_URL"] == (
"http://127.0.0.1:8765/webui/bootstrap"
)
assert captured["NANOBOT_TUI_HEALTH_URL"] == "http://127.0.0.1:18790/health"
assert captured["NANOBOT_TUI_BOOTSTRAP_SECRET"] == "bootstrap-secret"
assert "NANOBOT_TUI_WS_URL" not in captured
assert "NANOBOT_TUI_API_TOKEN" not in captured
@@ -524,18 +533,18 @@ def test_classic_options_require_an_explicit_classic_prompt(
)
def test_source_checkout_refreshes_locked_tui_dependencies(
def test_source_checkout_installs_missing_locked_tui_dependencies(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
source_dir = tmp_path / "tui"
source_dir.mkdir()
(source_dir / "node_modules" / "@opentui" / "core").mkdir(parents=True)
source_dir = _tui_source(tmp_path)
dependency = source_dir / "node_modules" / "@opentui" / "core"
bun = str(tmp_path / "bun")
def install(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
assert command == [bun, "install", "--frozen-lockfile"]
assert kwargs["cwd"] == source_dir
dependency.mkdir(parents=True)
return subprocess.CompletedProcess(command, 0, "", "")
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install)
@@ -550,6 +559,82 @@ def test_source_checkout_refreshes_locked_tui_dependencies(
]
def test_source_checkout_skips_install_when_locked_dependencies_are_current(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
source_dir = _tui_source(tmp_path)
dependency = source_dir / "node_modules" / "@opentui" / "core"
installs: list[list[str]] = []
def install(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
installs.append(command)
dependency.mkdir(parents=True)
return subprocess.CompletedProcess(command, 0, "", "")
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install)
_resolve_source_tui_command(source_dir, "bun")
_resolve_source_tui_command(source_dir, "bun")
assert installs == [["bun", "install", "--frozen-lockfile"]]
@pytest.mark.parametrize("metadata_name", ["package.json", "bun.lock"])
def test_source_checkout_refreshes_dependencies_when_metadata_changes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
metadata_name: str,
) -> None:
source_dir = _tui_source(tmp_path)
dependency = source_dir / "node_modules" / "@opentui" / "core"
installs: list[list[str]] = []
def install(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
installs.append(command)
dependency.mkdir(parents=True, exist_ok=True)
return subprocess.CompletedProcess(command, 0, "", "")
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install)
_resolve_source_tui_command(source_dir, "bun")
with (source_dir / metadata_name).open("a", encoding="utf-8") as metadata:
metadata.write("changed\n")
_resolve_source_tui_command(source_dir, "bun")
assert installs == [
["bun", "install", "--frozen-lockfile"],
["bun", "install", "--frozen-lockfile"],
]
def test_failed_source_dependency_install_does_not_leave_a_valid_cache(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
source_dir = _tui_source(tmp_path)
dependency = source_dir / "node_modules" / "@opentui" / "core"
outcomes = iter((0, 1, 0))
installs = 0
def install(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
nonlocal installs
installs += 1
dependency.mkdir(parents=True, exist_ok=True)
returncode = next(outcomes)
return subprocess.CompletedProcess(command, returncode, "", "partial install")
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install)
_resolve_source_tui_command(source_dir, "bun")
dependency.rmdir()
with pytest.raises(TuiUnavailableError, match="partial install"):
_resolve_source_tui_command(source_dir, "bun")
_resolve_source_tui_command(source_dir, "bun")
assert installs == 3
def test_source_checkout_fails_when_locked_dependencies_cannot_be_refreshed(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
-6
View File
@@ -63,9 +63,3 @@ def test_explicit_provider_import_still_works(monkeypatch) -> None:
finally:
monkeypatch.undo()
setattr(sys.modules["nanobot"], "providers", original_package)
def test_openai_codex_supports_progress_deltas() -> None:
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
assert OpenAICodexProvider.supports_progress_deltas is True
+106 -6
View File
@@ -182,7 +182,7 @@ describe("NanobotTui layout", () => {
expect(setup.renderer.height).toBe(height)
expect(occurrences(frame, "Ask nanobot anything")).toBe(1)
expect(occurrences(frame, "Ready")).toBe(0)
expect(occurrences(frame, "Connecting…")).toBe(1)
expect(occurrences(frame, "Getting ready…")).toBe(1)
expect(occurrences(frame, "nanobot · test/model")).toBe(1)
}
@@ -2384,6 +2384,85 @@ describe("NanobotTui layout", () => {
expect(state()).toBe(false)
})
test("shows actionable connection states without implementation details", async () => {
setup = await createRenderer({ width: 100, height: 20, screenMode: "alternate-screen" })
const app = mount(setup)
const ui = app as unknown as {
status: TextRenderable
handleStatus(
status: "starting" | "connecting" | "connected" | "reconnecting" | "unavailable" | "error",
detail?: string,
info?: {
endpoint: string
attempt: number
elapsedMs: number
health?: "ready" | "degraded" | "unreachable"
},
): void
}
ui.handleStatus("starting", undefined, {
endpoint: "127.0.0.1:8769",
attempt: 1,
elapsedMs: 0,
})
expect(ui.status.plainText).toBe("Getting ready…")
ui.handleStatus("connecting")
expect(ui.status.plainText).toBe("Getting ready…")
ui.handleStatus("connected")
expect(ui.status.plainText).toBe("Getting ready…")
ui.handleStatus("error", "gateway sent an invalid event")
expect(ui.status.plainText).toBe("Getting ready…")
expect(ui.status.plainText).not.toContain("Unable")
ui.handleStatus("reconnecting", "connection closed", {
endpoint: "127.0.0.1:8769",
attempt: 2,
elapsedMs: 800,
})
expect(ui.status.plainText).toBe("Resuming…")
ui.handleStatus("reconnecting", "connection closed", {
endpoint: "127.0.0.1:8769",
attempt: 2,
elapsedMs: 900,
health: "degraded",
})
expect(ui.status.plainText).toBe("Resuming…")
ui.handleStatus("unavailable", "connection refused", {
endpoint: "127.0.0.1:8769",
attempt: 7,
elapsedMs: 3_200,
health: "degraded",
})
expect(ui.status.plainText).toBe("Still getting ready…")
expect(ui.status.plainText).not.toContain("Unable")
ui.handleStatus("unavailable", "connection refused", {
endpoint: "127.0.0.1:8769",
attempt: 8,
elapsedMs: 3_500,
health: "unreachable",
})
expect(ui.status.plainText).toBe("Nanobot is taking longer to respond…")
expect(ui.status.plainText).not.toContain("Unable")
ui.handleStatus("error", "gateway bootstrap failed: HTTP 401", {
endpoint: "127.0.0.1:8769",
attempt: 9,
elapsedMs: 3_800,
})
expect(ui.status.plainText).toBe("Nanobot unavailable · restart nanobot")
expect(ui.status.plainText).not.toContain("gateway")
expect(ui.status.plainText).not.toContain("127.0.0.1")
expect(ui.status.plainText).not.toContain("HTTP")
expect(ui.status.plainText).not.toContain("attempt")
})
test("replays events after asynchronous history hydration", async () => {
setup = await createRenderer({ width: 80, height: 22, screenMode: "alternate-screen" })
const original = globalThis.fetch
@@ -2444,7 +2523,12 @@ describe("NanobotTui layout", () => {
client(sent),
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
const composer = (app as unknown as { composer: TextareaRenderable }).composer
const ui = app as unknown as {
composer: TextareaRenderable
ready: boolean
status: TextRenderable
}
const composer = ui.composer
try {
app.accept({ event: "attached", chat_id: "chat" })
@@ -2452,16 +2536,18 @@ describe("NanobotTui layout", () => {
app.accept({ event: "attached", chat_id: "chat" })
composer.setText("sent during reconnect")
composer.submit()
await Bun.sleep(5)
await waitUntil(() => ui.status.plainText.includes("Not sent"))
expect(sent).toEqual([])
expect(composer.plainText).toBe("sent during reconnect")
expect(ui.status.plainText).toContain("Not sent · press Enter to retry when ready")
resolveReconnect(new Response(JSON.stringify({
messages: [{ role: "assistant", content: "restored history" }],
page: { has_more_before: false },
})))
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
await waitUntil(() => ui.ready)
expect(ui.status.plainText).toBe("Not sent · press Enter to retry")
composer.submit()
await waitUntil(() => sent.length === 1)
await setup.flush()
@@ -2480,24 +2566,38 @@ describe("NanobotTui layout", () => {
const app = mount(setup, sent)
const composer = (app as unknown as { composer: TextareaRenderable }).composer
const connection = app as unknown as {
handleStatus(status: "connecting" | "connected", detail?: string): void
handleStatus(
status: "reconnecting" | "connected",
detail?: string,
info?: { endpoint: string; attempt: number; elapsedMs: number },
): void
}
app.accept({ event: "attached", chat_id: "chat" })
await Bun.sleep(1)
connection.handleStatus("connecting", "reconnecting")
connection.handleStatus("reconnecting", "connection closed", {
endpoint: "127.0.0.1:8769",
attempt: 1,
elapsedMs: 0,
})
connection.handleStatus("connected")
composer.setText("draft before attach")
composer.submit()
await Bun.sleep(5)
composer.submit()
await Bun.sleep(5)
expect(sent).toEqual([])
expect(composer.plainText).toBe("draft before attach")
app.accept({ event: "attached", chat_id: "chat" })
app.accept({ event: "attached", chat_id: "chat" })
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
expect(sent).toEqual([])
composer.submit()
await waitUntil(() => sent.length === 1)
app.accept({ event: "attached", chat_id: "chat" })
await Bun.sleep(5)
expect(sent).toEqual(["draft before attach"])
})
+82 -23
View File
@@ -20,7 +20,9 @@ import {
import {
NanobotClient,
connectionEndpoint,
fetchAvailableSkills,
fetchGatewayHealth,
fetchHistory,
fetchGatewayConnection,
fetchMentionCandidates,
@@ -29,6 +31,7 @@ import {
fetchSlashCommands,
type ApiReauthenticator,
type ConnectionStatus,
type ConnectionStatusInfo,
type FileEditEvent,
type GatewayApiConnection,
type HistoryMessage,
@@ -93,6 +96,7 @@ interface AppOptions {
wsUrl?: string
bootstrapUrl?: string
bootstrapSecret?: string
healthUrl?: string
apiUrl: string
apiToken: string
chatId?: string
@@ -374,6 +378,21 @@ function formatElapsed(milliseconds: number): string {
return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`
}
function connectionStatusText(
status: ConnectionStatus,
info?: ConnectionStatusInfo,
): string {
if (["starting", "connecting", "connected"].includes(status)) return "Getting ready…"
if (status === "reconnecting") return "Resuming…"
if (status === "unavailable") {
return info?.health === "degraded"
? "Still getting ready…"
: "Nanobot is taking longer to respond…"
}
if (status === "error") return "Nanobot unavailable · restart nanobot"
return "Session ended"
}
function singleLine(value: string, limit = 120): string {
return value.replace(/\s+/gu, " ").trim().slice(0, limit)
}
@@ -449,6 +468,8 @@ export class NanobotTui {
private shimmerTimer: ReturnType<typeof setInterval> | null = null
private submitPending = false
private submitGeneration = 0
private unsentSubmit = false
private connectionMessage = "Getting ready…"
private readonly promptHistory: string[] = []
private historyCursor = 0
private historyDraft = ""
@@ -556,11 +577,14 @@ export class NanobotTui {
options.apiUrl,
`tui-${process.pid}`,
),
...(options.healthUrl
? { checkHealth: () => fetchGatewayHealth(options.healthUrl || "") }
: {}),
onConnection: (connection) => this.useGatewayConnection(
connection.apiUrl,
connection.apiToken,
),
connectionRetryLabel: "Starting local gateway",
targetEndpoint: connectionEndpoint(options.bootstrapUrl),
reconnectDelayMs: 100,
startupRetryMaxDelayMs: 250,
}
@@ -571,7 +595,7 @@ export class NanobotTui {
access_mode: options.access.toLocaleLowerCase().includes("full") ? "full" : "restricted",
},
onEvent: (event) => this.accept(event),
onStatus: (status, detail) => this.handleStatus(status, detail),
onStatus: (status, detail, info) => this.handleStatus(status, detail, info),
})
// The terminal owns its canvas. Keeping the default-background intent is
@@ -723,6 +747,8 @@ export class NanobotTui {
},
onContentChange: () => {
this.draft.prune(this.composer.plainText)
const clearedUnsent = this.unsentSubmit && !this.composer.plainText.trim()
if (clearedUnsent) this.unsentSubmit = false
this.runtimeControls.hide()
if (this.contextPanel.visible && this.composer.plainText) this.contextPanel.hide()
this.syncComposerPlaceholder()
@@ -730,6 +756,9 @@ export class NanobotTui {
else if (this.branchMenu.visible) this.syncBranchMenu()
else this.syncComposerMenus()
this.resizeComposer()
if (clearedUnsent && !this.activeTurn) {
this.status.content = this.ready ? this.readyStatus() : this.connectionMessage
}
},
// IMEs may commit their final composed glyph after Enter. Matching the
// OpenCode/OpenTUI integration, defer twice before reading plainText.
@@ -738,7 +767,7 @@ export class NanobotTui {
})
this.status = new TextRenderable(renderer, {
id: "nanobot-tui-status",
content: "Connecting…",
content: "Getting ready…",
fg: this.palette.muted,
height: 1,
width: "auto",
@@ -824,7 +853,7 @@ export class NanobotTui {
// Network setup and small menu payloads do not depend on terminal colors.
// Start them while OSC theme detection is in flight instead of serializing
// up to one second of otherwise independent startup work.
this.host.reportState("unknown", "Connecting")
this.host.reportState("unknown", "Getting ready")
this.client.connect()
void this.loadCommands()
void this.loadMentions()
@@ -899,6 +928,10 @@ export class NanobotTui {
return
}
if (["/continue", "/dismiss"].includes(visibleContent.toLowerCase())) {
if (!this.ready) {
this.markSubmitUnsent()
return
}
this.clearComposer()
this.commandMenu.hide()
void this.updateRecovery(visibleContent.toLowerCase() === "/continue" ? "continue" : "dismiss")
@@ -932,7 +965,7 @@ export class NanobotTui {
return
}
if (!this.ready) {
this.status.content = "Preparing chat…"
this.markSubmitUnsent()
return
}
const prompt = { content, options: mentionOptions(content, this.availableMentions()) }
@@ -947,10 +980,11 @@ export class NanobotTui {
let turnId: string
try {
turnId = this.client.send(prompt.content, prompt.options)
} catch (error) {
this.status.content = error instanceof Error ? error.message : String(error)
} catch {
this.markSubmitUnsent(true)
return false
}
this.unsentSubmit = false
this.clearComposer()
this.commandMenu.hide()
this.mentionMenu.hide()
@@ -1392,36 +1426,58 @@ export class NanobotTui {
}
}
private handleStatus(status: ConnectionStatus, detail?: string): void {
private handleStatus(
status: ConnectionStatus,
_detail?: string,
info?: ConnectionStatusInfo,
): void {
// Invalid frames do not mean the transport is unavailable. Keep the last
// accurate user-facing state unless the protocol supplied connection diagnostics.
if (status === "error" && !info) return
this.connectionMessage = connectionStatusText(status, info)
if (status === "connected") {
this.ready = false
this.host.reportState("unknown", "Connecting")
this.status.content = "Connected · preparing chat…"
this.host.reportState("unknown", "Getting ready")
this.renderConnectionMessage()
return
}
if (status === "connecting") {
if (["starting", "connecting", "reconnecting", "unavailable"].includes(status)) {
this.ready = false
const label = detail === "Starting local gateway"
? detail
: detail ? "Reconnecting" : "Connecting"
this.host.reportState("unknown", label)
if (detail) this.setActive(false)
this.status.content = `${label}`
this.host.reportState("unknown", this.connectionMessage)
if (status === "reconnecting" || status === "unavailable") this.setActive(false)
this.renderConnectionMessage()
return
}
if (status === "error") {
if (info) this.ready = false
this.setActive(false)
this.host.reportState("unknown", detail || "Connection error")
this.status.content = detail || "Connection error"
this.host.reportState("unknown", this.connectionMessage)
this.renderConnectionMessage()
return
}
if (!this.quitting) {
this.ready = false
this.setActive(false)
this.host.reportState("unknown", "Disconnected")
this.status.content = "Disconnected"
this.renderConnectionMessage()
}
}
private renderConnectionMessage(): void {
this.status.content = this.unsentSubmit
? `Not sent · press Enter to retry when ready · ${this.connectionMessage}`
: this.connectionMessage
}
private markSubmitUnsent(sendFailed = false): void {
this.unsentSubmit = true
if (sendFailed) {
this.status.content = "Not sent · send failed; press Enter to retry when ready"
return
}
this.renderConnectionMessage()
}
private setActive(active: boolean, startedAt?: number): void {
if (this.activeTurn === active) {
if (active && startedAt !== undefined) this.activeStartedAt = startedAt
@@ -1460,6 +1516,7 @@ export class NanobotTui {
}
private readyStatus(detail = this.readyDetail): string {
if (this.unsentSubmit) return "Not sent · press Enter to retry"
if (this.transcriptNavigation.awayFromBottom) {
return this.transcriptNavigation.unseenOutput
? "New output · Ctrl+End latest"
@@ -2083,6 +2140,7 @@ export class NanobotTui {
}
private clearComposer(): void {
this.unsentSubmit = false
this.draft.clear()
this.composer.setText("")
}
@@ -2383,7 +2441,7 @@ export class NanobotTui {
options: MessageOptions = {},
): void {
if (!this.ready) {
this.status.content = "Preparing chat…"
this.markSubmitUnsent()
return
}
if (this.activeTurn && lifecycle === "agent_turn") {
@@ -2393,10 +2451,11 @@ export class NanobotTui {
let turnId: string
try {
turnId = this.client.send(content, options)
} catch (error) {
this.status.content = error instanceof Error ? error.message : String(error)
} catch {
this.markSubmitUnsent(true)
return
}
this.unsentSubmit = false
this.commandTurns.set(turnId, lifecycle)
if (silent) this.silentCommandTurns.add(turnId)
if (/^\/model(?:\s|$)/iu.test(content)) this.modelCommandTurns.add(turnId)
+2
View File
@@ -14,6 +14,7 @@ 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() || ""
const healthUrl = process.env.NANOBOT_TUI_HEALTH_URL?.trim() || ""
const gatewayStopCommand = process.env.NANOBOT_TUI_GATEWAY_STOP_COMMAND?.trim()
|| "nanobot gateway stop"
if (!bootstrapUrl && !wsUrl) {
@@ -24,6 +25,7 @@ const options: AppOptions = {
? {
bootstrapUrl,
bootstrapSecret: process.env.NANOBOT_TUI_BOOTSTRAP_SECRET?.trim() || "",
healthUrl: healthUrl || undefined,
}
: { wsUrl }),
apiUrl: process.env.NANOBOT_TUI_API_URL?.trim() || "",
+196 -1
View File
@@ -3,14 +3,19 @@ import { describe, expect, test } from "bun:test"
import {
NanobotClient,
GatewayConnectionError,
connectionEndpoint,
fetchAvailableSkills,
fetchGatewayConnection,
fetchGatewayHealth,
fetchHistory,
fetchMentionCandidates,
fetchRuntimeControls,
fetchSessionContext,
fetchSessions,
fetchSlashCommands,
sanitizeConnectionFailure,
type ConnectionStatus,
type ConnectionStatusInfo,
type InboundEvent,
} from "./protocol"
@@ -39,6 +44,12 @@ class FakeSocket {
}
}
async function waitUntil(predicate: () => boolean, timeout = 1_000): Promise<void> {
const deadline = Date.now() + timeout
while (!predicate() && Date.now() < deadline) await Bun.sleep(2)
if (!predicate()) throw new Error(`condition was not met within ${timeout}ms`)
}
describe("gateway protocol", () => {
test("bootstraps fresh websocket and API credentials", async () => {
const original = globalThis.fetch
@@ -70,6 +81,41 @@ describe("gateway protocol", () => {
}
})
test("classifies gateway health without sending credentials", async () => {
const original = globalThis.fetch
const requests: Array<{ url: string; headers: Headers }> = []
const responses = [
new Response(JSON.stringify({
status: "degraded",
process: "alive",
ready: false,
websocket: "unavailable",
}), { status: 503 }),
new Response(JSON.stringify({
status: "ok",
process: "alive",
ready: true,
websocket: "running",
})),
new Response("not json"),
]
globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
requests.push({ url: String(input), headers: new Headers(init?.headers) })
return Promise.resolve(responses.shift() || new Response("missing", { status: 500 }))
}) as typeof fetch
try {
const healthUrl = "http://127.0.0.1:18790/health"
expect(await fetchGatewayHealth(healthUrl)).toBe("degraded")
expect(await fetchGatewayHealth(healthUrl)).toBe("ready")
expect(await fetchGatewayHealth(healthUrl)).toBe("unreachable")
expect(requests.map(({ url }) => url)).toEqual([healthUrl, healthUrl, healthUrl])
expect(requests.every(({ headers }) => [...headers].length === 0)).toBe(true)
} 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
@@ -138,8 +184,13 @@ describe("gateway protocol", () => {
try {
const connections: string[] = []
let healthChecks = 0
const client = new NanobotClient({
resolveConnection: () => new Promise((resolve) => { resolveConnection = resolve }),
checkHealth: async () => {
healthChecks += 1
return "ready"
},
onConnection: (connection) => connections.push(connection.apiToken),
onEvent: () => undefined,
onStatus: () => undefined,
@@ -155,6 +206,7 @@ describe("gateway protocol", () => {
await Bun.sleep(1)
expect(requestedUrl).toBe("ws://nanobot.test/ws?token=fresh")
expect(connections).toEqual(["fresh-api-token"])
expect(healthChecks).toBe(0)
client.close()
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
@@ -200,6 +252,133 @@ describe("gateway protocol", () => {
}
})
test("escalates a refused bootstrap with safe endpoint and retry diagnostics", async () => {
const original = globalThis.fetch
const bootstrapUrl = "http://bootstrap-user:bootstrap-pass@127.0.0.1:8769"
+ "/webui/bootstrap?token=socket-secret"
const bootstrapSecret = "bootstrap-secret"
const statuses: Array<{
status: ConnectionStatus
detail?: string
info?: ConnectionStatusInfo
}> = []
const refused = new TypeError(
`fetch failed for ${bootstrapUrl}&api_token=api-secret`,
{
cause: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:8769"), {
code: "ECONNREFUSED",
}),
},
)
globalThis.fetch = (() => Promise.reject(refused)) as unknown as typeof fetch
const client = new NanobotClient({
resolveConnection: () => fetchGatewayConnection(
bootstrapUrl,
bootstrapSecret,
"http://127.0.0.1:8769",
"tui-42",
),
targetEndpoint: connectionEndpoint(bootstrapUrl),
checkHealth: async () => "degraded",
startupFailureDelayMs: 8,
reconnectDelayMs: 100,
onEvent: () => undefined,
onStatus: (status, detail, info) => statuses.push({ status, detail, info }),
})
try {
client.connect()
await waitUntil(() => statuses.some(({ status }) => status === "unavailable"))
const failure = [...statuses].reverse().find(({ status }) => status === "unavailable")
expect(statuses[0]?.status).toBe("starting")
expect(failure?.detail).toBe("connection refused")
expect(failure?.info).toMatchObject({
endpoint: "127.0.0.1:8769",
attempt: 1,
elapsedMs: expect.any(Number),
health: "degraded",
})
const visible = JSON.stringify(statuses)
expect(visible).not.toContain("bootstrap-user")
expect(visible).not.toContain("bootstrap-pass")
expect(visible).not.toContain(bootstrapSecret)
expect(visible).not.toContain("socket-secret")
expect(visible).not.toContain("api-secret")
expect(visible).not.toContain("/webui/bootstrap")
} finally {
client.close()
globalThis.fetch = original
}
})
test("recovers after sustained bootstrap failures without hiding the outage", async () => {
const original = globalThis.WebSocket
const sockets: FakeSocket[] = []
let available = false
let attempts = 0
const statuses: ConnectionStatus[] = []
Object.defineProperty(globalThis, "WebSocket", {
configurable: true,
value: class extends FakeSocket {
constructor() {
super()
sockets.push(this)
}
},
})
const client = new NanobotClient({
resolveConnection: async () => {
attempts += 1
if (!available) {
throw Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" })
}
return {
wsUrl: "ws://127.0.0.1:8769/ws?token=fresh",
apiUrl: "http://127.0.0.1:8769",
apiToken: "fresh-api-token",
}
},
targetEndpoint: "127.0.0.1:8769",
checkHealth: async () => available ? "ready" : "degraded",
startupFailureDelayMs: 8,
reconnectDelayMs: 2,
startupRetryMaxDelayMs: 2,
onEvent: () => undefined,
onStatus: (status) => statuses.push(status),
})
try {
client.connect()
await waitUntil(() => statuses.includes("unavailable"))
available = true
await waitUntil(() => sockets.length === 1)
sockets[0]?.emit("open")
await waitUntil(() => statuses.at(-1) === "connected")
expect(attempts).toBeGreaterThan(1)
expect(statuses.indexOf("starting")).toBeLessThan(statuses.indexOf("unavailable"))
expect(statuses.indexOf("unavailable")).toBeLessThan(statuses.lastIndexOf("connected"))
} finally {
client.close()
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
}
})
test("sanitizes arbitrary connection errors and authenticated URLs", () => {
const authenticated = "wss://user:password@127.0.0.1:8769/ws"
+ "?token=socket-secret&api_token=api-secret"
const unknown = new Error(`could not reach ${authenticated}`)
const refused = Object.assign(new Error(`ECONNREFUSED ${authenticated}`), {
code: "ECONNREFUSED",
})
expect(connectionEndpoint(authenticated)).toBe("127.0.0.1:8769")
expect(sanitizeConnectionFailure(unknown)).toBe("connection failed")
expect(sanitizeConnectionFailure(refused)).toBe("connection refused")
expect(sanitizeConnectionFailure(unknown)).not.toContain("socket-secret")
})
test("reports a permanent bootstrap rejection without retrying", async () => {
let attempts = 0
const statuses: string[] = []
@@ -588,13 +767,19 @@ describe("gateway protocol", () => {
})
try {
const statuses: Array<{
status: ConnectionStatus
detail?: string
info?: ConnectionStatusInfo
}> = []
const client = new NanobotClient({
url: "ws://nanobot.test/ws",
reconnectDelayMs: 1,
onEvent: () => undefined,
onStatus: () => undefined,
onStatus: (status, detail, info) => statuses.push({ status, detail, info }),
})
client.connect()
sockets[0]?.emit("open")
sockets[0]?.emit("message", {
data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client" }),
})
@@ -605,11 +790,21 @@ describe("gateway protocol", () => {
await Bun.sleep(5)
expect(sockets).toHaveLength(2)
const reconnecting = [...statuses].reverse().find(
({ status }) => status === "reconnecting",
)
expect(reconnecting).toMatchObject({
status: "reconnecting",
detail: "connection closed",
info: { endpoint: "nanobot.test", attempt: 1 },
})
sockets[1]?.emit("open")
sockets[1]?.emit("message", {
data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client-2" }),
})
const outbound = sockets[1]?.sent.map((value) => JSON.parse(value)) || []
expect(outbound).toEqual([{ type: "attach", chat_id: "generated-chat" }])
expect(statuses.at(-1)?.status).toBe("connected")
client.close()
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
+223 -18
View File
@@ -1,4 +1,21 @@
export type ConnectionStatus = "connecting" | "connected" | "closed" | "error"
export type ConnectionStatus =
| "starting"
| "connecting"
| "connected"
| "reconnecting"
| "unavailable"
| "closed"
| "error"
export interface ConnectionStatusInfo {
endpoint: string
attempt: number
elapsedMs: number
retryInMs?: number
health?: GatewayHealthStatus
}
export type GatewayHealthStatus = "ready" | "degraded" | "unreachable"
export interface ToolProgressEvent {
version?: number
@@ -172,14 +189,16 @@ type OutboundEvent =
export interface ClientOptions {
url?: string
resolveConnection?: () => Promise<GatewayConnection>
checkHealth?: () => Promise<GatewayHealthStatus>
onConnection?: (connection: GatewayConnection) => void
connectionRetryLabel?: string
targetEndpoint?: string
startupFailureDelayMs?: number
startupRetryMaxDelayMs?: number
chatId?: string
initialWorkspaceScope?: WorkspaceScopePayload
reconnectDelayMs?: number
onEvent: (event: InboundEvent) => void
onStatus: (status: ConnectionStatus, detail?: string) => void
onStatus: (status: ConnectionStatus, detail?: string, info?: ConnectionStatusInfo) => void
}
export interface GatewayApiConnection {
@@ -940,6 +959,92 @@ export async function fetchGatewayConnection(
}
}
/** Read gateway readiness without sending bootstrap or API credentials. */
export async function fetchGatewayHealth(
healthUrl: string,
timeoutMs = 400,
): Promise<GatewayHealthStatus> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(healthUrl, { signal: controller.signal })
if (response.status !== 200 && response.status !== 503) return "unreachable"
const payload: unknown = await response.json()
if (!isRecord(payload)) return "unreachable"
if (
response.status === 503
&& payload.status === "degraded"
&& payload.ready === false
&& payload.process === "alive"
) return "degraded"
if (response.status === 200 && payload.status === "ok" && payload.ready !== false) {
return "ready"
}
return "unreachable"
} catch {
return "unreachable"
} finally {
clearTimeout(timer)
}
}
/** Return only the authority users can act on, never credentials or an authenticated path. */
export function connectionEndpoint(value: string | undefined): string {
if (!value) return "local gateway"
try {
return new URL(value).host || "local gateway"
} catch {
return "local gateway"
}
}
/** Reduce arbitrary fetch/WebSocket errors to a small set of credential-safe reasons. */
export function sanitizeConnectionFailure(error: unknown): string {
const signals: string[] = []
const seen = new Set<unknown>()
const collect = (value: unknown): void => {
if (value === null || value === undefined || seen.has(value)) return
if (typeof value === "object") seen.add(value)
if (typeof value === "string") {
signals.push(value)
return
}
if (value instanceof Error) {
signals.push(value.name, value.message)
collect(value.cause)
if (value instanceof AggregateError) {
for (const nested of value.errors) collect(nested)
}
return
}
if (!isRecord(value)) return
if (typeof value.code === "string") signals.push(value.code)
collect(value.cause)
if (Array.isArray(value.errors)) {
for (const nested of value.errors) collect(nested)
}
}
collect(error)
const signal = signals.join(" ")
if (/ECONNREFUSED|connection refused/iu.test(signal)) return "connection refused"
if (/ETIMEDOUT|timed? out|timeout/iu.test(signal)) return "connection timed out"
if (/ENOTFOUND|EAI_AGAIN|name not resolved|host not found/iu.test(signal)) {
return "host not found"
}
if (/certificate|TLS|SSL/iu.test(signal)) return "secure connection failed"
const bootstrapStatus = signal.match(/gateway bootstrap failed:\s*HTTP\s*(\d{3})/iu)
if (bootstrapStatus?.[1]) return `gateway bootstrap failed: HTTP ${bootstrapStatus[1]}`
if (/bootstrap response is missing ws_url/iu.test(signal)) {
return "gateway bootstrap response is missing ws_url"
}
if (/bootstrap response (?:has an invalid ws_url|is invalid)/iu.test(signal)) {
return "gateway bootstrap response is invalid"
}
if (/gateway is still starting/iu.test(signal)) return "gateway is still starting"
if (/fetch failed|failed to fetch|network error/iu.test(signal)) return "network request failed"
return "connection failed"
}
export class NanobotClient {
private socket: WebSocket | null = null
private chatId = ""
@@ -949,13 +1054,22 @@ export class NanobotClient {
private closedByClient = false
private opening = false
private connectedOnce = false
private connectionAttempt = 0
private retryStartedAt = 0
private nextRetryAt = 0
private lastFailure = ""
private healthStatus: GatewayHealthStatus | undefined
private failureEscalationTimer: ReturnType<typeof setTimeout> | null = null
private readonly endpoint: string
private readonly pendingMutations = new Map<string, {
resolve: (value: unknown) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}>()
constructor(private readonly options: ClientOptions) {}
constructor(private readonly options: ClientOptions) {
this.endpoint = options.targetEndpoint || connectionEndpoint(options.url)
}
get activeChatId(): string {
return this.chatId
@@ -963,13 +1077,21 @@ export class NanobotClient {
connect(): void {
this.closedByClient = false
this.connectionAttempt = 0
this.reconnectAttempt = 0
this.retryStartedAt = Date.now()
this.nextRetryAt = 0
this.lastFailure = ""
this.healthStatus = undefined
void this.open()
}
private async open(): Promise<void> {
if (this.socket || this.opening || this.closedByClient) return
this.opening = true
this.options.onStatus("connecting")
this.nextRetryAt = 0
this.connectionAttempt += 1
this.reportConnectionProgress()
let url = this.options.url
try {
if (this.options.resolveConnection) {
@@ -980,37 +1102,52 @@ export class NanobotClient {
}
} catch (error) {
if (!this.closedByClient) {
this.lastFailure = sanitizeConnectionFailure(error)
if (error instanceof GatewayConnectionError && !error.retryable) {
this.options.onStatus("error", error.message)
this.clearFailureEscalation()
this.options.onStatus("error", this.lastFailure, this.connectionInfo())
return
}
this.options.onStatus(
"connecting",
this.options.connectionRetryLabel || "gateway unavailable",
)
this.scheduleReconnect(false)
await this.checkHealthAndScheduleReconnect()
}
return
} finally {
this.opening = false
}
if (!url) {
this.options.onStatus("error", "gateway URL is not configured")
this.options.onStatus("error", "gateway URL is not configured", this.connectionInfo())
return
}
const socket = new WebSocket(url)
let socket: WebSocket
try {
socket = new WebSocket(url)
} catch (error) {
this.lastFailure = sanitizeConnectionFailure(error)
await this.checkHealthAndScheduleReconnect()
return
}
let opened = false
this.socket = socket
socket.addEventListener("open", () => {
if (this.socket !== socket) return
opened = true
this.connectedOnce = true
this.connectionAttempt = 0
this.reconnectAttempt = 0
this.options.onStatus("connected")
this.retryStartedAt = 0
this.nextRetryAt = 0
this.lastFailure = ""
this.healthStatus = "ready"
this.clearFailureEscalation()
this.options.onStatus("connected", undefined, this.connectionInfo())
})
socket.addEventListener("message", (message) => {
if (this.socket === socket) this.handleMessage(String(message.data))
})
socket.addEventListener("error", () => {
if (this.socket === socket) this.options.onStatus("error", "connection failed")
if (this.socket !== socket) return
this.lastFailure = "connection failed"
this.reportRetryState()
})
socket.addEventListener("close", () => {
if (this.socket !== socket) return
@@ -1020,7 +1157,14 @@ export class NanobotClient {
this.options.onStatus("closed")
return
}
this.scheduleReconnect()
if (opened) {
this.connectionAttempt = 0
this.reconnectAttempt = 0
this.retryStartedAt = Date.now()
}
if (!this.lastFailure) this.lastFailure = "connection closed"
this.reportRetryState()
void this.checkHealthAndScheduleReconnect()
})
}
@@ -1028,6 +1172,7 @@ export class NanobotClient {
this.closedByClient = true
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
this.clearFailureEscalation()
const socket = this.socket
this.socket = null
socket?.close()
@@ -1182,20 +1327,80 @@ export class NanobotClient {
this.options.onEvent(event)
}
private scheduleReconnect(announce = true): void {
private scheduleReconnect(): void {
if (this.reconnectTimer || this.closedByClient) return
if (!this.retryStartedAt) this.retryStartedAt = Date.now()
const base = this.options.reconnectDelayMs ?? 500
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.nextRetryAt = Date.now() + delay
this.reportRetryState()
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
void this.open()
}, delay)
}
private async checkHealthAndScheduleReconnect(): Promise<void> {
if (this.options.checkHealth) {
try {
this.healthStatus = await this.options.checkHealth()
} catch {
this.healthStatus = "unreachable"
}
}
if (!this.closedByClient) this.scheduleReconnect()
}
private connectionInfo(): ConnectionStatusInfo {
return {
endpoint: this.endpoint,
attempt: Math.max(1, this.connectionAttempt),
elapsedMs: this.retryStartedAt ? Math.max(0, Date.now() - this.retryStartedAt) : 0,
...(this.nextRetryAt
? { retryInMs: Math.max(0, this.nextRetryAt - Date.now()) }
: {}),
...(this.healthStatus ? { health: this.healthStatus } : {}),
}
}
private reportConnectionProgress(): void {
if (this.connectedOnce) {
this.options.onStatus("reconnecting", this.lastFailure || undefined, this.connectionInfo())
return
}
const phase = this.options.resolveConnection ? "starting" : "connecting"
this.options.onStatus(phase, undefined, this.connectionInfo())
}
private reportRetryState(): void {
const info = this.connectionInfo()
if (this.connectedOnce) {
this.options.onStatus("reconnecting", this.lastFailure, info)
return
}
const failureDelay = this.options.startupFailureDelayMs ?? 3_000
if (info.elapsedMs >= failureDelay) {
this.clearFailureEscalation()
this.options.onStatus("unavailable", this.lastFailure, info)
return
}
this.reportConnectionProgress()
if (this.failureEscalationTimer) return
this.failureEscalationTimer = setTimeout(() => {
this.failureEscalationTimer = null
if (this.closedByClient || this.connectedOnce || !this.lastFailure) return
this.options.onStatus("unavailable", this.lastFailure, this.connectionInfo())
}, Math.max(0, failureDelay - info.elapsedMs))
}
private clearFailureEscalation(): void {
if (this.failureEscalationTimer) clearTimeout(this.failureEscalationTimer)
this.failureEscalationTimer = null
}
private write(event: OutboundEvent): void {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
throw new Error("gateway connection is not open")