Compare commits

..
17 changed files with 288 additions and 800 deletions
+49 -1
View File
@@ -46,11 +46,13 @@ from nanobot.runtime_context import (
from nanobot.session.history_visibility import is_hidden_history_message from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY
from nanobot.utils.helpers import ( from nanobot.utils.helpers import (
IncrementalThinkExtractor,
build_assistant_message, build_assistant_message,
estimate_message_tokens, estimate_message_tokens,
estimate_prompt_tokens_chain, estimate_prompt_tokens_chain,
extract_reasoning, extract_reasoning,
strip_reasoning_tags, strip_reasoning_tags,
strip_think,
) )
from nanobot.utils.llm_runtime import LLMRuntime from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.utils.prompt_templates import render_template from nanobot.utils.prompt_templates import render_template
@@ -65,6 +67,7 @@ from nanobot.utils.runtime import (
) )
ContinuationCallback = Callable[[], str | None] ContinuationCallback = Callable[[], str | None]
ProgressCallback = Callable[[str], Awaitable[None]]
RetryWaitCallback = Callable[[str], Awaitable[None]] RetryWaitCallback = Callable[[str], Awaitable[None]]
CheckpointCallback = Callable[[dict[str, Any]], Awaitable[None]] CheckpointCallback = Callable[[dict[str, Any]], Awaitable[None]]
InjectionCallback = Callable[..., Awaitable[Iterable[Any] | None]] InjectionCallback = Callable[..., Awaitable[Iterable[Any] | None]]
@@ -109,6 +112,7 @@ class AgentRunSpec:
session_key: str | None = None session_key: str | None = None
context_block_limit: int | None = None context_block_limit: int | None = None
provider_retry_mode: str = "standard" provider_retry_mode: str = "standard"
progress_callback: ProgressCallback | None = None
retry_wait_callback: RetryWaitCallback | None = None retry_wait_callback: RetryWaitCallback | None = None
checkpoint_callback: CheckpointCallback | None = None checkpoint_callback: CheckpointCallback | None = None
injection_callback: InjectionCallback | None = None injection_callback: InjectionCallback | None = None
@@ -947,7 +951,14 @@ class AgentRunner:
tools=spec.tools.get_definitions(), tools=spec.tools.get_definitions(),
) )
wants_streaming = hook.wants_streaming() 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]] = {} active_hosted_tools: dict[str, dict[str, Any]] = {}
request_started_at = 0.0 request_started_at = 0.0
first_output_at: float | None = None first_output_at: float | None = None
@@ -1018,6 +1029,40 @@ class AgentRunner:
on_tool_call_delta=_provider_tool_event, on_tool_call_delta=_provider_tool_event,
on_stream_recover=_stream_recover, 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: else:
coro = spec.runtime.provider.chat_with_retry( coro = spec.runtime.provider.chat_with_retry(
**kwargs, **kwargs,
@@ -1029,9 +1074,10 @@ class AgentRunner:
# very slow deltas can still run forever. Use a more generous wall-clock # 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 # timeout for streaming while preserving NANOBOT_LLM_TIMEOUT_S=0 as an
# opt-out for all LLM wall-clock timeouts. # opt-out for all LLM wall-clock timeouts.
is_streaming_request = wants_streaming or wants_progress_streaming
outer_timeout_s = ( outer_timeout_s = (
max(300.0, timeout_s * 2) max(300.0, timeout_s * 2)
if wants_streaming and timeout_s is not None if is_streaming_request and timeout_s is not None
else timeout_s else timeout_s
) )
request_started_at = time.perf_counter() request_started_at = time.perf_counter()
@@ -1069,6 +1115,8 @@ class AgentRunner:
"error": response.content "error": response.content
or "Model request failed before the provider-hosted tool completed.", 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 = ( dropped, all_dropped, original_finish_reason = (
self._drop_malformed_tool_calls(response) self._drop_malformed_tool_calls(response)
) )
-53
View File
@@ -21,7 +21,6 @@ from nanobot.cli.process_identity import named_executable
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,
_gateway_health_url,
_gateway_instance_command, _gateway_instance_command,
_host_for_local_browser, _host_for_local_browser,
_webui_endpoint_reachable, _webui_endpoint_reachable,
@@ -67,8 +66,6 @@ _TUI_RELEASE_LIMITS = {
_TUI_DETACH_EXIT_CODE = 90 _TUI_DETACH_EXIT_CODE = 90
_GATEWAY_READY_TIMEOUT_S = 20.0 _GATEWAY_READY_TIMEOUT_S = 20.0
_GATEWAY_READY_POLL_S = 0.1 _GATEWAY_READY_POLL_S = 0.1
_TUI_DEPENDENCY_METADATA = ("package.json", "bun.lock")
_TUI_DEPENDENCY_CACHE = ".nanobot-install.sha256"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -99,10 +96,6 @@ def launch_tui(
env.update( env.update(
{ {
"NANOBOT_TUI_BOOTSTRAP_URL": f"{base_url}/webui/bootstrap", "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_API_URL": base_url,
"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",
@@ -226,21 +219,6 @@ def _tui_source_dir(project_root: Path) -> Path | None:
def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]: def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]:
dependency = source_dir / "node_modules" / "@opentui" / "core" 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: try:
install = subprocess.run( install = subprocess.run(
[bun, "install", "--frozen-lockfile"], [bun, "install", "--frozen-lockfile"],
@@ -255,37 +233,6 @@ def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]:
detail = (install.stderr or install.stdout).strip().splitlines() detail = (install.stderr or install.stdout).strip().splitlines()
suffix = f": {detail[-1]}" if detail else "" suffix = f": {detail[-1]}" if detail else ""
raise TuiUnavailableError(f"could not install TUI dependencies{suffix}") 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( executable = named_executable(
bun, bun,
name="nanobot-tui", name="nanobot-tui",
+2
View File
@@ -603,6 +603,8 @@ _SYNTHETIC_USER_CONTENT = "(conversation continued)"
class LLMProvider(ABC): class LLMProvider(ABC):
"""Base class for LLM providers.""" """Base class for LLM providers."""
supports_progress_deltas = False
_CHAT_RETRY_DELAYS = (1, 2, 4) _CHAT_RETRY_DELAYS = (1, 2, 4)
_PERSISTENT_MAX_DELAY = 60 _PERSISTENT_MAX_DELAY = 60
_PERSISTENT_IDENTICAL_ERROR_LIMIT = 10 _PERSISTENT_IDENTICAL_ERROR_LIMIT = 10
+4
View File
@@ -157,6 +157,10 @@ class FallbackProvider(LLMProvider):
super().set_llm_call_observer(observer) super().set_llm_call_observer(observer)
self._primary.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( def can_resume_conversation_state(
self, self,
state: ProviderConversationState, state: ProviderConversationState,
@@ -44,6 +44,8 @@ _COMPACTION_RETAINED_CHAR_BUDGET = 256_000
class OpenAICodexProvider(LLMProvider): class OpenAICodexProvider(LLMProvider):
"""Use Codex OAuth to call the Responses API.""" """Use Codex OAuth to call the Responses API."""
supports_progress_deltas = True
def __init__( def __init__(
self, self,
default_model: str = "openai-codex/gpt-5.6-sol", default_model: str = "openai-codex/gpt-5.6-sol",
+2
View File
@@ -63,6 +63,8 @@ def _is_named_x_search_tool(value: object) -> bool:
class XAIGrokProvider(LLMProvider): class XAIGrokProvider(LLMProvider):
"""Call xAI's subscription proxy and expose supported hosted tools.""" """Call xAI's subscription proxy and expose supported hosted tools."""
supports_progress_deltas = True
def __init__( def __init__(
self, self,
default_model: str = DEFAULT_XAI_GROK_MODEL, default_model: str = DEFAULT_XAI_GROK_MODEL,
+12 -2
View File
@@ -373,6 +373,7 @@ class TestToolEventProgress:
"""The /goal command rewrites the prompt but must not bypass WebUI file-edit progress.""" """The /goal command rewrites the prompt but must not bypass WebUI file-edit progress."""
bus = MessageBus() bus = MessageBus()
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "test-model" provider.get_default_model.return_value = "test-model"
call_count = 0 call_count = 0
@@ -459,6 +460,7 @@ class TestToolEventProgress:
"""Non-streaming channels should get one final reply, not token progress spam.""" """Non-streaming channels should get one final reply, not token progress spam."""
bus = MessageBus() bus = MessageBus()
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "openai-codex/gpt-5.5" 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_with_retry = AsyncMock(return_value=LLMResponse(content="Hello", tool_calls=[]))
provider.chat_stream_with_retry = AsyncMock() provider.chat_stream_with_retry = AsyncMock()
@@ -491,6 +493,7 @@ class TestToolEventProgress:
"""Streaming channels still receive provider deltas through stream events.""" """Streaming channels still receive provider deltas through stream events."""
bus = MessageBus() bus = MessageBus()
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "openai-codex/gpt-5.5" provider.get_default_model.return_value = "openai-codex/gpt-5.5"
async def chat_stream_with_retry(*, on_content_delta, **kwargs): async def chat_stream_with_retry(*, on_content_delta, **kwargs):
@@ -541,6 +544,7 @@ class TestToolEventProgress:
) -> None: ) -> None:
bus = MessageBus() bus = MessageBus()
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "test-model" provider.get_default_model.return_value = "test-model"
responses = iter([ responses = iter([
LLMResponse(content="first-", finish_reason="length"), LLMResponse(content="first-", finish_reason="length"),
@@ -586,6 +590,7 @@ class TestToolEventProgress:
) -> None: ) -> None:
bus = MessageBus() bus = MessageBus()
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "test-model" provider.get_default_model.return_value = "test-model"
call_count = 0 call_count = 0
@@ -632,6 +637,7 @@ class TestToolEventProgress:
) -> None: ) -> None:
bus = MessageBus() bus = MessageBus()
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "test-model" provider.get_default_model.return_value = "test-model"
async def chat_stream_with_retry(*, on_content_delta, **kwargs): async def chat_stream_with_retry(*, on_content_delta, **kwargs):
@@ -722,6 +728,7 @@ class TestToolEventProgress:
"""A no-tools finalization must not be dropped after empty stream retries.""" """A no-tools finalization must not be dropped after empty stream retries."""
bus = MessageBus() bus = MessageBus()
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "openai-codex/gpt-5.5" provider.get_default_model.return_value = "openai-codex/gpt-5.5"
provider.chat_stream_with_retry = AsyncMock(side_effect=[ provider.chat_stream_with_retry = AsyncMock(side_effect=[
LLMResponse(content=None, tool_calls=[]), LLMResponse(content=None, tool_calls=[]),
@@ -769,6 +776,7 @@ class TestToolEventProgress:
) -> None: ) -> None:
bus = MessageBus() bus = MessageBus()
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "openai-codex/gpt-5.5" provider.get_default_model.return_value = "openai-codex/gpt-5.5"
first_request_started = asyncio.Event() first_request_started = asyncio.Event()
release_first_request = asyncio.Event() release_first_request = asyncio.Event()
@@ -927,6 +935,7 @@ class TestToolEventProgress:
"""Recovered streaming output should use a new stream segment.""" """Recovered streaming output should use a new stream segment."""
bus = MessageBus() bus = MessageBus()
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "openai-codex/gpt-5.5" provider.get_default_model.return_value = "openai-codex/gpt-5.5"
async def chat_stream_with_retry(*, on_content_delta, on_stream_recover, **kwargs): async def chat_stream_with_retry(*, on_content_delta, on_stream_recover, **kwargs):
@@ -979,12 +988,13 @@ class TestToolEventProgress:
provider.chat_with_retry.assert_not_awaited() provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_streamed_content_is_not_repeated_before_tool_execution( async def test_streamed_progress_is_not_repeated_before_tool_execution(
self, self,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""If content was already streamed, tool setup should not repeat it.""" """If content was already streamed as progress, tool setup should not repeat it."""
loop = _make_loop(tmp_path) loop = _make_loop(tmp_path)
loop.provider.supports_progress_deltas = True
tool_call = ToolCallRequest(id="call1", name="custom_tool", arguments={"path": "foo.txt"}) tool_call = ToolCallRequest(id="call1", name="custom_tool", arguments={"path": "foo.txt"})
calls = iter([ calls = iter([
LLMResponse(content="I will inspect it.", tool_calls=[tool_call]), LLMResponse(content="I will inspect it.", tool_calls=[tool_call]),
+68 -2
View File
@@ -798,6 +798,64 @@ async def test_runner_times_out_never_ending_streaming_request():
provider.chat_with_retry.assert_not_awaited() 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 @pytest.mark.asyncio
async def test_runner_replaces_empty_tool_result_with_marker(): async def test_runner_replaces_empty_tool_result_with_marker():
from nanobot.agent.runner import AgentRunner from nanobot.agent.runner import AgentRunner
@@ -1227,8 +1285,13 @@ async def test_runner_accumulates_usage_and_preserves_cache_reads():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_binds_on_retry_wait_callback(): async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress():
"""Provider retry heartbeats use the explicitly supplied callback.""" """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.
"""
from nanobot.agent.runner import AgentRunner from nanobot.agent.runner import AgentRunner
captured: dict = {} captured: dict = {}
@@ -1242,6 +1305,7 @@ async def test_runner_binds_on_retry_wait_callback():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
progress_cb = AsyncMock()
retry_wait_cb = AsyncMock() retry_wait_cb = AsyncMock()
runner = AgentRunner() runner = AgentRunner()
@@ -1254,10 +1318,12 @@ async def test_runner_binds_on_retry_wait_callback():
model="test-model", model="test-model",
max_iterations=1, max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
retry_wait_callback=retry_wait_cb, retry_wait_callback=retry_wait_cb,
)) ))
assert captured["on_retry_wait"] is retry_wait_cb assert captured["on_retry_wait"] is retry_wait_cb
assert captured["on_retry_wait"] is not progress_cb
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+68 -20
View File
@@ -1,4 +1,4 @@
"""Tests for runner progress hooks and provider event routing.""" """Tests for provider progress delta routing in the shared runner."""
import asyncio import asyncio
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from agent.runner_helpers import make_run_spec from agent.runner_helpers import make_run_spec
from nanobot.agent.hook import CompositeHook
from nanobot.agent.hooks import FileEditActivityHook from nanobot.agent.hooks import FileEditActivityHook
from nanobot.agent.progress_hook import AgentProgressHook from nanobot.agent.progress_hook import AgentProgressHook
from nanobot.agent.runner import AgentRunner from nanobot.agent.runner import AgentRunner
@@ -16,9 +17,45 @@ from nanobot.providers.base import LLMResponse, ToolCallRequest
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars _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 @pytest.mark.asyncio
async def test_runner_routes_hosted_tool_events_to_structured_progress(): async def test_runner_routes_hosted_tool_events_to_structured_progress():
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True
async def chat_stream_with_retry(*, on_content_delta, on_tool_call_delta, **kwargs): async def chat_stream_with_retry(*, on_content_delta, on_tool_call_delta, **kwargs):
await on_tool_call_delta({ await on_tool_call_delta({
@@ -51,17 +88,13 @@ async def test_runner_routes_hosted_tool_events_to_structured_progress():
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
progress_events: list[dict] = [] progress_events: list[dict] = []
progress_text: list[str] = [] progress_text: list[str] = []
streamed_text: list[str] = []
async def progress_cb(content, *, tool_events=None, **kwargs): async def progress_cb(content, *, tool_events=None, **kwargs):
progress_text.append(content) progress_text.append(content)
if tool_events: if tool_events:
progress_events.extend(tool_events) progress_events.extend(tool_events)
async def stream_cb(content: str) -> None: hook = CompositeHook([AgentProgressHook(on_progress=progress_cb)])
streamed_text.append(content)
hook = AgentProgressHook(on_progress=progress_cb, on_stream=stream_cb)
result = await AgentRunner().run(make_run_spec( result = await AgentRunner().run(make_run_spec(
provider, provider,
initial_messages=[{"role": "user", "content": "search X"}], initial_messages=[{"role": "user", "content": "search X"}],
@@ -69,6 +102,7 @@ async def test_runner_routes_hosted_tool_events_to_structured_progress():
model="test-model", model="test-model",
max_iterations=1, max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
hook=hook, hook=hook,
)) ))
@@ -99,14 +133,14 @@ async def test_runner_routes_hosted_tool_events_to_structured_progress():
"embeds": [], "embeds": [],
}, },
] ]
assert progress_text == ['search X "nanobot oauth"', ""] assert progress_text == ['search X "nanobot oauth"', "", "done"]
assert streamed_text == ["done"]
provider.chat_with_retry.assert_not_awaited() provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_fails_pending_hosted_tool_when_model_request_fails(): async def test_runner_fails_pending_hosted_tool_when_model_request_fails():
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True
async def chat_stream_with_retry(*, on_tool_call_delta, **kwargs): async def chat_stream_with_retry(*, on_tool_call_delta, **kwargs):
await on_tool_call_delta({ await on_tool_call_delta({
@@ -132,10 +166,7 @@ async def test_runner_fails_pending_hosted_tool_when_model_request_fails():
if tool_events: if tool_events:
progress_events.extend(tool_events) progress_events.extend(tool_events)
async def stream_cb(_content: str) -> None: hook = CompositeHook([AgentProgressHook(on_progress=progress_cb)])
pass
hook = AgentProgressHook(on_progress=progress_cb, on_stream=stream_cb)
result = await AgentRunner().run(make_run_spec( result = await AgentRunner().run(make_run_spec(
provider, provider,
initial_messages=[{"role": "user", "content": "search X"}], initial_messages=[{"role": "user", "content": "search X"}],
@@ -143,6 +174,7 @@ async def test_runner_fails_pending_hosted_tool_when_model_request_fails():
model="test-model", model="test-model",
max_iterations=1, max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
hook=hook, hook=hook,
)) ))
@@ -168,6 +200,7 @@ async def test_runner_fails_pending_hosted_tool_when_model_request_fails():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_path): async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_path):
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True
call_count = 0 call_count = 0
progress_events: list[dict] = [] progress_events: list[dict] = []
(tmp_path / "big.txt").write_text("old\n", encoding="utf-8") (tmp_path / "big.txt").write_text("old\n", encoding="utf-8")
@@ -185,7 +218,7 @@ async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_pa
def prepare_call(self, name, params): def prepare_call(self, name, params):
return tool, params, None return tool, params, None
async def chat_with_retry(**kwargs): async def chat_stream_with_retry(**kwargs):
nonlocal call_count nonlocal call_count
call_count += 1 call_count += 1
if call_count == 1: if call_count == 1:
@@ -202,7 +235,8 @@ async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_pa
) )
return LLMResponse(content="done", tool_calls=[], usage=None) return LLMResponse(content="done", tool_calls=[], usage=None)
provider.chat_with_retry = chat_with_retry provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
tools = Tools() tools = Tools()
runner = AgentRunner() runner = AgentRunner()
@@ -212,6 +246,7 @@ async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_pa
model="test-model", model="test-model",
max_iterations=2, max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
workspace=tmp_path, workspace=tmp_path,
hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path), hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path),
)) ))
@@ -228,11 +263,13 @@ async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_pa
and event["diff"]["format"] == "unified" and event["diff"]["format"] == "unified"
for event in progress_events for event in progress_events
) )
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_path): async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_path):
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True
call_count = 0 call_count = 0
progress_events: list[dict] = [] progress_events: list[dict] = []
target = tmp_path / "notes.txt" target = tmp_path / "notes.txt"
@@ -251,7 +288,7 @@ async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_pat
def prepare_call(self, name, params): def prepare_call(self, name, params):
return tool, params, None return tool, params, None
async def chat_with_retry(**kwargs): async def chat_stream_with_retry(**kwargs):
nonlocal call_count nonlocal call_count
call_count += 1 call_count += 1
if call_count == 1: if call_count == 1:
@@ -272,7 +309,8 @@ async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_pat
) )
return LLMResponse(content="done", tool_calls=[], usage=None) return LLMResponse(content="done", tool_calls=[], usage=None)
provider.chat_with_retry = chat_with_retry provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
tools = Tools() tools = Tools()
runner = AgentRunner() runner = AgentRunner()
@@ -282,6 +320,7 @@ async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_pat
model="test-model", model="test-model",
max_iterations=2, max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
workspace=tmp_path, workspace=tmp_path,
hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path), hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path),
)) ))
@@ -296,11 +335,13 @@ async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_pat
and event["diff"]["format"] == "unified" and event["diff"]["format"] == "unified"
for event in progress_events for event in progress_events
) )
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path): async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path):
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True
call_count = 0 call_count = 0
progress_events: list[dict] = [] progress_events: list[dict] = []
@@ -317,7 +358,7 @@ async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path)
def prepare_call(self, name, params): def prepare_call(self, name, params):
return tool, params, None return tool, params, None
async def chat_with_retry(**kwargs): async def chat_stream_with_retry(**kwargs):
nonlocal call_count nonlocal call_count
call_count += 1 call_count += 1
if call_count == 1: if call_count == 1:
@@ -334,7 +375,8 @@ async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path)
) )
return LLMResponse(content="done", tool_calls=[], usage=None) return LLMResponse(content="done", tool_calls=[], usage=None)
provider.chat_with_retry = chat_with_retry provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
tools = Tools() tools = Tools()
runner = AgentRunner() runner = AgentRunner()
@@ -344,6 +386,7 @@ async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path)
model="test-model", model="test-model",
max_iterations=2, max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
workspace=tmp_path, workspace=tmp_path,
hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path), hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path),
)) ))
@@ -352,11 +395,13 @@ 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]["path"] == "aborted.txt"
assert progress_events[-1]["phase"] == "error" assert progress_events[-1]["phase"] == "error"
assert progress_events[-1]["status"] == "error" assert progress_events[-1]["status"] == "error"
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path): async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path):
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True
progress_events: list[dict] = [] progress_events: list[dict] = []
executing = asyncio.Event() executing = asyncio.Event()
target = tmp_path / "cancelled.txt" target = tmp_path / "cancelled.txt"
@@ -381,7 +426,7 @@ async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path):
def prepare_call(self, name, params): def prepare_call(self, name, params):
return tool, params, None return tool, params, None
async def chat_with_retry(**kwargs): async def chat_stream_with_retry(**kwargs):
return LLMResponse( return LLMResponse(
content=None, content=None,
tool_calls=[ tool_calls=[
@@ -394,7 +439,8 @@ async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path):
usage=None, usage=None,
) )
provider.chat_with_retry = chat_with_retry provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
tools = Tools() tools = Tools()
runner = AgentRunner() runner = AgentRunner()
@@ -404,6 +450,7 @@ async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path):
model="test-model", model="test-model",
max_iterations=2, max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
workspace=tmp_path, workspace=tmp_path,
hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path), hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path),
))) )))
@@ -417,3 +464,4 @@ async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path):
assert progress_events[-1]["path"] == "cancelled.txt" assert progress_events[-1]["path"] == "cancelled.txt"
assert progress_events[-1]["status"] == "error" assert progress_events[-1]["status"] == "error"
assert progress_events[-1]["error"] == "Task interrupted before this tool finished." assert progress_events[-1]["error"] == "Task interrupted before this tool finished."
provider.chat_with_retry.assert_not_awaited()
+23 -24
View File
@@ -15,7 +15,6 @@ import pytest
from agent.runner_helpers import make_run_spec from agent.runner_helpers import make_run_spec
from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.progress_hook import AgentProgressHook
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, LLMUsage, ToolCallRequest from nanobot.providers.base import LLMResponse, LLMUsage, ToolCallRequest
@@ -36,18 +35,6 @@ class _RecordingHook(AgentHook):
self.end_calls += 1 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 @pytest.mark.asyncio
async def test_runner_preserves_reasoning_fields_in_assistant_history(): async def test_runner_preserves_reasoning_fields_in_assistant_history():
"""Reasoning fields ride along on the persisted assistant message so """Reasoning fields ride along on the persisted assistant message so
@@ -214,6 +201,7 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed():
from nanobot.agent.runner import AgentRunner from nanobot.agent.runner import AgentRunner
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True
async def chat_stream_with_retry(*, on_content_delta=None, **kwargs): async def chat_stream_with_retry(*, on_content_delta=None, **kwargs):
if on_content_delta: if on_content_delta:
@@ -230,7 +218,12 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
hook = _StreamRecordingHook() progress_calls: list[str] = []
async def _progress(content: str, **_kwargs):
progress_calls.append(content)
hook = _RecordingHook()
runner = AgentRunner() runner = AgentRunner()
result = await runner.run(make_run_spec(provider, result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "question"}], initial_messages=[{"role": "user", "content": "question"}],
@@ -239,10 +232,11 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed():
max_iterations=3, max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=hook, hook=hook,
progress_callback=_progress,
)) ))
assert result.final_content == "The answer." assert result.final_content == "The answer."
assert hook.streamed == ["The ", "answer."] assert progress_calls, "answer should have streamed via progress callback"
assert hook.emitted == ["step-by-step deduction"] assert hook.emitted == ["step-by-step deduction"]
@@ -253,6 +247,7 @@ async def test_runner_does_not_double_emit_when_inline_think_already_streamed():
from nanobot.agent.runner import AgentRunner from nanobot.agent.runner import AgentRunner
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True
async def chat_stream_with_retry(*, on_content_delta=None, **kwargs): async def chat_stream_with_retry(*, on_content_delta=None, **kwargs):
if on_content_delta: if on_content_delta:
@@ -268,16 +263,10 @@ async def test_runner_does_not_double_emit_when_inline_think_already_streamed():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
reasoning_events: list[str] = [] async def _progress(content: str, **_kwargs):
async def _progress(content: str, *, reasoning: bool = False, **_kwargs):
if reasoning:
reasoning_events.append(content)
async def _stream(_content: str) -> None:
pass pass
hook = AgentProgressHook(on_progress=_progress, on_stream=_stream) hook = _RecordingHook()
runner = AgentRunner() runner = AgentRunner()
result = await runner.run(make_run_spec(provider, result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "question"}], initial_messages=[{"role": "user", "content": "question"}],
@@ -286,10 +275,12 @@ async def test_runner_does_not_double_emit_when_inline_think_already_streamed():
max_iterations=3, max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=hook, hook=hook,
progress_callback=_progress,
)) ))
assert result.final_content == "The answer." assert result.final_content == "The answer."
assert reasoning_events == ["working..."] assert hook.emitted == ["working..."]
assert hook.end_calls >= 1, "reasoning stream must be closed once the answer starts"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -329,6 +320,14 @@ async def test_runner_closes_reasoning_stream_after_one_shot_response():
assert hook.end_calls == 1 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 @pytest.mark.asyncio
async def test_runner_streams_native_thinking_deltas_without_post_hoc_dup(): async def test_runner_streams_native_thinking_deltas_without_post_hoc_dup():
"""Anthropic-style ``on_thinking_delta`` should fan out to ``emit_reasoning``; """Anthropic-style ``on_thinking_delta`` should fan out to ``emit_reasoning``;
+4 -89
View File
@@ -51,14 +51,6 @@ def _release_archive(
return payload, checksum 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( @pytest.mark.parametrize(
("session_id", "expected"), ("session_id", "expected"),
[ [
@@ -150,7 +142,6 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
assert captured["NANOBOT_TUI_BOOTSTRAP_URL"] == ( assert captured["NANOBOT_TUI_BOOTSTRAP_URL"] == (
"http://127.0.0.1:8765/webui/bootstrap" "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 captured["NANOBOT_TUI_BOOTSTRAP_SECRET"] == "bootstrap-secret"
assert "NANOBOT_TUI_WS_URL" not in captured assert "NANOBOT_TUI_WS_URL" not in captured
assert "NANOBOT_TUI_API_TOKEN" not in captured assert "NANOBOT_TUI_API_TOKEN" not in captured
@@ -533,18 +524,18 @@ def test_classic_options_require_an_explicit_classic_prompt(
) )
def test_source_checkout_installs_missing_locked_tui_dependencies( def test_source_checkout_refreshes_locked_tui_dependencies(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
source_dir = _tui_source(tmp_path) source_dir = tmp_path / "tui"
dependency = source_dir / "node_modules" / "@opentui" / "core" source_dir.mkdir()
(source_dir / "node_modules" / "@opentui" / "core").mkdir(parents=True)
bun = str(tmp_path / "bun") bun = str(tmp_path / "bun")
def install(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: def install(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
assert command == [bun, "install", "--frozen-lockfile"] assert command == [bun, "install", "--frozen-lockfile"]
assert kwargs["cwd"] == source_dir assert kwargs["cwd"] == source_dir
dependency.mkdir(parents=True)
return subprocess.CompletedProcess(command, 0, "", "") return subprocess.CompletedProcess(command, 0, "", "")
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install) monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install)
@@ -559,82 +550,6 @@ def test_source_checkout_installs_missing_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( def test_source_checkout_fails_when_locked_dependencies_cannot_be_refreshed(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
+6
View File
@@ -63,3 +63,9 @@ def test_explicit_provider_import_still_works(monkeypatch) -> None:
finally: finally:
monkeypatch.undo() monkeypatch.undo()
setattr(sys.modules["nanobot"], "providers", original_package) 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
+6 -106
View File
@@ -182,7 +182,7 @@ describe("NanobotTui layout", () => {
expect(setup.renderer.height).toBe(height) expect(setup.renderer.height).toBe(height)
expect(occurrences(frame, "Ask nanobot anything")).toBe(1) expect(occurrences(frame, "Ask nanobot anything")).toBe(1)
expect(occurrences(frame, "Ready")).toBe(0) expect(occurrences(frame, "Ready")).toBe(0)
expect(occurrences(frame, "Getting ready…")).toBe(1) expect(occurrences(frame, "Connecting…")).toBe(1)
expect(occurrences(frame, "nanobot · test/model")).toBe(1) expect(occurrences(frame, "nanobot · test/model")).toBe(1)
} }
@@ -2384,85 +2384,6 @@ describe("NanobotTui layout", () => {
expect(state()).toBe(false) 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 () => { test("replays events after asynchronous history hydration", async () => {
setup = await createRenderer({ width: 80, height: 22, screenMode: "alternate-screen" }) setup = await createRenderer({ width: 80, height: 22, screenMode: "alternate-screen" })
const original = globalThis.fetch const original = globalThis.fetch
@@ -2523,12 +2444,7 @@ describe("NanobotTui layout", () => {
client(sent), client(sent),
new MockTreeSitterClient({ autoResolveTimeout: 0 }), new MockTreeSitterClient({ autoResolveTimeout: 0 }),
) )
const ui = app as unknown as { const composer = (app as unknown as { composer: TextareaRenderable }).composer
composer: TextareaRenderable
ready: boolean
status: TextRenderable
}
const composer = ui.composer
try { try {
app.accept({ event: "attached", chat_id: "chat" }) app.accept({ event: "attached", chat_id: "chat" })
@@ -2536,18 +2452,16 @@ describe("NanobotTui layout", () => {
app.accept({ event: "attached", chat_id: "chat" }) app.accept({ event: "attached", chat_id: "chat" })
composer.setText("sent during reconnect") composer.setText("sent during reconnect")
composer.submit() composer.submit()
await waitUntil(() => ui.status.plainText.includes("Not sent")) await Bun.sleep(5)
expect(sent).toEqual([]) expect(sent).toEqual([])
expect(composer.plainText).toBe("sent during reconnect") 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({ resolveReconnect(new Response(JSON.stringify({
messages: [{ role: "assistant", content: "restored history" }], messages: [{ role: "assistant", content: "restored history" }],
page: { has_more_before: false }, page: { has_more_before: false },
}))) })))
await waitUntil(() => ui.ready) await waitUntil(() => (app as unknown as { ready: boolean }).ready)
expect(ui.status.plainText).toBe("Not sent · press Enter to retry")
composer.submit() composer.submit()
await waitUntil(() => sent.length === 1) await waitUntil(() => sent.length === 1)
await setup.flush() await setup.flush()
@@ -2566,38 +2480,24 @@ describe("NanobotTui layout", () => {
const app = mount(setup, sent) const app = mount(setup, sent)
const composer = (app as unknown as { composer: TextareaRenderable }).composer const composer = (app as unknown as { composer: TextareaRenderable }).composer
const connection = app as unknown as { const connection = app as unknown as {
handleStatus( handleStatus(status: "connecting" | "connected", detail?: string): void
status: "reconnecting" | "connected",
detail?: string,
info?: { endpoint: string; attempt: number; elapsedMs: number },
): void
} }
app.accept({ event: "attached", chat_id: "chat" }) app.accept({ event: "attached", chat_id: "chat" })
await Bun.sleep(1) await Bun.sleep(1)
connection.handleStatus("reconnecting", "connection closed", { connection.handleStatus("connecting", "reconnecting")
endpoint: "127.0.0.1:8769",
attempt: 1,
elapsedMs: 0,
})
connection.handleStatus("connected") connection.handleStatus("connected")
composer.setText("draft before attach") composer.setText("draft before attach")
composer.submit() composer.submit()
await Bun.sleep(5) await Bun.sleep(5)
composer.submit()
await Bun.sleep(5)
expect(sent).toEqual([]) expect(sent).toEqual([])
expect(composer.plainText).toBe("draft before attach") expect(composer.plainText).toBe("draft before attach")
app.accept({ event: "attached", chat_id: "chat" })
app.accept({ event: "attached", chat_id: "chat" }) app.accept({ event: "attached", chat_id: "chat" })
await waitUntil(() => (app as unknown as { ready: boolean }).ready) await waitUntil(() => (app as unknown as { ready: boolean }).ready)
expect(sent).toEqual([])
composer.submit() composer.submit()
await waitUntil(() => sent.length === 1) await waitUntil(() => sent.length === 1)
app.accept({ event: "attached", chat_id: "chat" })
await Bun.sleep(5)
expect(sent).toEqual(["draft before attach"]) expect(sent).toEqual(["draft before attach"])
}) })
+23 -82
View File
@@ -20,9 +20,7 @@ import {
import { import {
NanobotClient, NanobotClient,
connectionEndpoint,
fetchAvailableSkills, fetchAvailableSkills,
fetchGatewayHealth,
fetchHistory, fetchHistory,
fetchGatewayConnection, fetchGatewayConnection,
fetchMentionCandidates, fetchMentionCandidates,
@@ -31,7 +29,6 @@ import {
fetchSlashCommands, fetchSlashCommands,
type ApiReauthenticator, type ApiReauthenticator,
type ConnectionStatus, type ConnectionStatus,
type ConnectionStatusInfo,
type FileEditEvent, type FileEditEvent,
type GatewayApiConnection, type GatewayApiConnection,
type HistoryMessage, type HistoryMessage,
@@ -96,7 +93,6 @@ interface AppOptions {
wsUrl?: string wsUrl?: string
bootstrapUrl?: string bootstrapUrl?: string
bootstrapSecret?: string bootstrapSecret?: string
healthUrl?: string
apiUrl: string apiUrl: string
apiToken: string apiToken: string
chatId?: string chatId?: string
@@ -378,21 +374,6 @@ function formatElapsed(milliseconds: number): string {
return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s` 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 { function singleLine(value: string, limit = 120): string {
return value.replace(/\s+/gu, " ").trim().slice(0, limit) return value.replace(/\s+/gu, " ").trim().slice(0, limit)
} }
@@ -468,8 +449,6 @@ export class NanobotTui {
private shimmerTimer: ReturnType<typeof setInterval> | null = null private shimmerTimer: ReturnType<typeof setInterval> | null = null
private submitPending = false private submitPending = false
private submitGeneration = 0 private submitGeneration = 0
private unsentSubmit = false
private connectionMessage = "Getting ready…"
private readonly promptHistory: string[] = [] private readonly promptHistory: string[] = []
private historyCursor = 0 private historyCursor = 0
private historyDraft = "" private historyDraft = ""
@@ -577,14 +556,11 @@ export class NanobotTui {
options.apiUrl, options.apiUrl,
`tui-${process.pid}`, `tui-${process.pid}`,
), ),
...(options.healthUrl
? { checkHealth: () => fetchGatewayHealth(options.healthUrl || "") }
: {}),
onConnection: (connection) => this.useGatewayConnection( onConnection: (connection) => this.useGatewayConnection(
connection.apiUrl, connection.apiUrl,
connection.apiToken, connection.apiToken,
), ),
targetEndpoint: connectionEndpoint(options.bootstrapUrl), connectionRetryLabel: "Starting local gateway",
reconnectDelayMs: 100, reconnectDelayMs: 100,
startupRetryMaxDelayMs: 250, startupRetryMaxDelayMs: 250,
} }
@@ -595,7 +571,7 @@ export class NanobotTui {
access_mode: options.access.toLocaleLowerCase().includes("full") ? "full" : "restricted", access_mode: options.access.toLocaleLowerCase().includes("full") ? "full" : "restricted",
}, },
onEvent: (event) => this.accept(event), onEvent: (event) => this.accept(event),
onStatus: (status, detail, info) => this.handleStatus(status, detail, info), onStatus: (status, detail) => this.handleStatus(status, detail),
}) })
// The terminal owns its canvas. Keeping the default-background intent is // The terminal owns its canvas. Keeping the default-background intent is
@@ -747,8 +723,6 @@ export class NanobotTui {
}, },
onContentChange: () => { onContentChange: () => {
this.draft.prune(this.composer.plainText) this.draft.prune(this.composer.plainText)
const clearedUnsent = this.unsentSubmit && !this.composer.plainText.trim()
if (clearedUnsent) this.unsentSubmit = false
this.runtimeControls.hide() this.runtimeControls.hide()
if (this.contextPanel.visible && this.composer.plainText) this.contextPanel.hide() if (this.contextPanel.visible && this.composer.plainText) this.contextPanel.hide()
this.syncComposerPlaceholder() this.syncComposerPlaceholder()
@@ -756,9 +730,6 @@ export class NanobotTui {
else if (this.branchMenu.visible) this.syncBranchMenu() else if (this.branchMenu.visible) this.syncBranchMenu()
else this.syncComposerMenus() else this.syncComposerMenus()
this.resizeComposer() 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 // IMEs may commit their final composed glyph after Enter. Matching the
// OpenCode/OpenTUI integration, defer twice before reading plainText. // OpenCode/OpenTUI integration, defer twice before reading plainText.
@@ -767,7 +738,7 @@ export class NanobotTui {
}) })
this.status = new TextRenderable(renderer, { this.status = new TextRenderable(renderer, {
id: "nanobot-tui-status", id: "nanobot-tui-status",
content: "Getting ready…", content: "Connecting…",
fg: this.palette.muted, fg: this.palette.muted,
height: 1, height: 1,
width: "auto", width: "auto",
@@ -853,7 +824,7 @@ export class NanobotTui {
// Network setup and small menu payloads do not depend on terminal colors. // Network setup and small menu payloads do not depend on terminal colors.
// Start them while OSC theme detection is in flight instead of serializing // Start them while OSC theme detection is in flight instead of serializing
// up to one second of otherwise independent startup work. // up to one second of otherwise independent startup work.
this.host.reportState("unknown", "Getting ready") this.host.reportState("unknown", "Connecting")
this.client.connect() this.client.connect()
void this.loadCommands() void this.loadCommands()
void this.loadMentions() void this.loadMentions()
@@ -928,10 +899,6 @@ export class NanobotTui {
return return
} }
if (["/continue", "/dismiss"].includes(visibleContent.toLowerCase())) { if (["/continue", "/dismiss"].includes(visibleContent.toLowerCase())) {
if (!this.ready) {
this.markSubmitUnsent()
return
}
this.clearComposer() this.clearComposer()
this.commandMenu.hide() this.commandMenu.hide()
void this.updateRecovery(visibleContent.toLowerCase() === "/continue" ? "continue" : "dismiss") void this.updateRecovery(visibleContent.toLowerCase() === "/continue" ? "continue" : "dismiss")
@@ -965,7 +932,7 @@ export class NanobotTui {
return return
} }
if (!this.ready) { if (!this.ready) {
this.markSubmitUnsent() this.status.content = "Preparing chat…"
return return
} }
const prompt = { content, options: mentionOptions(content, this.availableMentions()) } const prompt = { content, options: mentionOptions(content, this.availableMentions()) }
@@ -980,11 +947,10 @@ export class NanobotTui {
let turnId: string let turnId: string
try { try {
turnId = this.client.send(prompt.content, prompt.options) turnId = this.client.send(prompt.content, prompt.options)
} catch { } catch (error) {
this.markSubmitUnsent(true) this.status.content = error instanceof Error ? error.message : String(error)
return false return false
} }
this.unsentSubmit = false
this.clearComposer() this.clearComposer()
this.commandMenu.hide() this.commandMenu.hide()
this.mentionMenu.hide() this.mentionMenu.hide()
@@ -1426,58 +1392,36 @@ export class NanobotTui {
} }
} }
private handleStatus( private handleStatus(status: ConnectionStatus, detail?: string): void {
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") { if (status === "connected") {
this.ready = false this.ready = false
this.host.reportState("unknown", "Getting ready") this.host.reportState("unknown", "Connecting")
this.renderConnectionMessage() this.status.content = "Connected · preparing chat…"
return return
} }
if (["starting", "connecting", "reconnecting", "unavailable"].includes(status)) { if (status === "connecting") {
this.ready = false this.ready = false
this.host.reportState("unknown", this.connectionMessage) const label = detail === "Starting local gateway"
if (status === "reconnecting" || status === "unavailable") this.setActive(false) ? detail
this.renderConnectionMessage() : detail ? "Reconnecting" : "Connecting"
this.host.reportState("unknown", label)
if (detail) this.setActive(false)
this.status.content = `${label}`
return return
} }
if (status === "error") { if (status === "error") {
if (info) this.ready = false
this.setActive(false) this.setActive(false)
this.host.reportState("unknown", this.connectionMessage) this.host.reportState("unknown", detail || "Connection error")
this.renderConnectionMessage() this.status.content = detail || "Connection error"
return return
} }
if (!this.quitting) { if (!this.quitting) {
this.ready = false
this.setActive(false) this.setActive(false)
this.host.reportState("unknown", "Disconnected") this.host.reportState("unknown", "Disconnected")
this.renderConnectionMessage() this.status.content = "Disconnected"
} }
} }
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 { private setActive(active: boolean, startedAt?: number): void {
if (this.activeTurn === active) { if (this.activeTurn === active) {
if (active && startedAt !== undefined) this.activeStartedAt = startedAt if (active && startedAt !== undefined) this.activeStartedAt = startedAt
@@ -1516,7 +1460,6 @@ export class NanobotTui {
} }
private readyStatus(detail = this.readyDetail): string { private readyStatus(detail = this.readyDetail): string {
if (this.unsentSubmit) return "Not sent · press Enter to retry"
if (this.transcriptNavigation.awayFromBottom) { if (this.transcriptNavigation.awayFromBottom) {
return this.transcriptNavigation.unseenOutput return this.transcriptNavigation.unseenOutput
? "New output · Ctrl+End latest" ? "New output · Ctrl+End latest"
@@ -2140,7 +2083,6 @@ export class NanobotTui {
} }
private clearComposer(): void { private clearComposer(): void {
this.unsentSubmit = false
this.draft.clear() this.draft.clear()
this.composer.setText("") this.composer.setText("")
} }
@@ -2441,7 +2383,7 @@ export class NanobotTui {
options: MessageOptions = {}, options: MessageOptions = {},
): void { ): void {
if (!this.ready) { if (!this.ready) {
this.markSubmitUnsent() this.status.content = "Preparing chat…"
return return
} }
if (this.activeTurn && lifecycle === "agent_turn") { if (this.activeTurn && lifecycle === "agent_turn") {
@@ -2451,11 +2393,10 @@ export class NanobotTui {
let turnId: string let turnId: string
try { try {
turnId = this.client.send(content, options) turnId = this.client.send(content, options)
} catch { } catch (error) {
this.markSubmitUnsent(true) this.status.content = error instanceof Error ? error.message : String(error)
return return
} }
this.unsentSubmit = false
this.commandTurns.set(turnId, lifecycle) this.commandTurns.set(turnId, lifecycle)
if (silent) this.silentCommandTurns.add(turnId) if (silent) this.silentCommandTurns.add(turnId)
if (/^\/model(?:\s|$)/iu.test(content)) this.modelCommandTurns.add(turnId) if (/^\/model(?:\s|$)/iu.test(content)) this.modelCommandTurns.add(turnId)
-2
View File
@@ -14,7 +14,6 @@ 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 bootstrapUrl = process.env.NANOBOT_TUI_BOOTSTRAP_URL?.trim() || ""
const wsUrl = process.env.NANOBOT_TUI_WS_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() const gatewayStopCommand = process.env.NANOBOT_TUI_GATEWAY_STOP_COMMAND?.trim()
|| "nanobot gateway stop" || "nanobot gateway stop"
if (!bootstrapUrl && !wsUrl) { if (!bootstrapUrl && !wsUrl) {
@@ -25,7 +24,6 @@ const options: AppOptions = {
? { ? {
bootstrapUrl, bootstrapUrl,
bootstrapSecret: process.env.NANOBOT_TUI_BOOTSTRAP_SECRET?.trim() || "", bootstrapSecret: process.env.NANOBOT_TUI_BOOTSTRAP_SECRET?.trim() || "",
healthUrl: healthUrl || undefined,
} }
: { wsUrl }), : { wsUrl }),
apiUrl: process.env.NANOBOT_TUI_API_URL?.trim() || "", apiUrl: process.env.NANOBOT_TUI_API_URL?.trim() || "",
+1 -196
View File
@@ -3,19 +3,14 @@ import { describe, expect, test } from "bun:test"
import { import {
NanobotClient, NanobotClient,
GatewayConnectionError, GatewayConnectionError,
connectionEndpoint,
fetchAvailableSkills, fetchAvailableSkills,
fetchGatewayConnection, fetchGatewayConnection,
fetchGatewayHealth,
fetchHistory, fetchHistory,
fetchMentionCandidates, fetchMentionCandidates,
fetchRuntimeControls, fetchRuntimeControls,
fetchSessionContext, fetchSessionContext,
fetchSessions, fetchSessions,
fetchSlashCommands, fetchSlashCommands,
sanitizeConnectionFailure,
type ConnectionStatus,
type ConnectionStatusInfo,
type InboundEvent, type InboundEvent,
} from "./protocol" } from "./protocol"
@@ -44,12 +39,6 @@ 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", () => { describe("gateway protocol", () => {
test("bootstraps fresh websocket and API credentials", async () => { test("bootstraps fresh websocket and API credentials", async () => {
const original = globalThis.fetch const original = globalThis.fetch
@@ -81,41 +70,6 @@ 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 () => { test("rejects malformed bootstrap responses without retrying", async () => {
const original = globalThis.fetch const original = globalThis.fetch
globalThis.fetch = (() => Promise.resolve(new Response("not json"))) as unknown as typeof fetch globalThis.fetch = (() => Promise.resolve(new Response("not json"))) as unknown as typeof fetch
@@ -184,13 +138,8 @@ describe("gateway protocol", () => {
try { try {
const connections: string[] = [] const connections: string[] = []
let healthChecks = 0
const client = new NanobotClient({ const client = new NanobotClient({
resolveConnection: () => new Promise((resolve) => { resolveConnection = resolve }), resolveConnection: () => new Promise((resolve) => { resolveConnection = resolve }),
checkHealth: async () => {
healthChecks += 1
return "ready"
},
onConnection: (connection) => connections.push(connection.apiToken), onConnection: (connection) => connections.push(connection.apiToken),
onEvent: () => undefined, onEvent: () => undefined,
onStatus: () => undefined, onStatus: () => undefined,
@@ -206,7 +155,6 @@ describe("gateway protocol", () => {
await Bun.sleep(1) await Bun.sleep(1)
expect(requestedUrl).toBe("ws://nanobot.test/ws?token=fresh") expect(requestedUrl).toBe("ws://nanobot.test/ws?token=fresh")
expect(connections).toEqual(["fresh-api-token"]) expect(connections).toEqual(["fresh-api-token"])
expect(healthChecks).toBe(0)
client.close() client.close()
} finally { } finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original }) Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
@@ -252,133 +200,6 @@ 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 () => { test("reports a permanent bootstrap rejection without retrying", async () => {
let attempts = 0 let attempts = 0
const statuses: string[] = [] const statuses: string[] = []
@@ -767,19 +588,13 @@ describe("gateway protocol", () => {
}) })
try { try {
const statuses: Array<{
status: ConnectionStatus
detail?: string
info?: ConnectionStatusInfo
}> = []
const client = new NanobotClient({ const client = new NanobotClient({
url: "ws://nanobot.test/ws", url: "ws://nanobot.test/ws",
reconnectDelayMs: 1, reconnectDelayMs: 1,
onEvent: () => undefined, onEvent: () => undefined,
onStatus: (status, detail, info) => statuses.push({ status, detail, info }), onStatus: () => undefined,
}) })
client.connect() client.connect()
sockets[0]?.emit("open")
sockets[0]?.emit("message", { sockets[0]?.emit("message", {
data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client" }), data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client" }),
}) })
@@ -790,21 +605,11 @@ describe("gateway protocol", () => {
await Bun.sleep(5) await Bun.sleep(5)
expect(sockets).toHaveLength(2) 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", { sockets[1]?.emit("message", {
data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client-2" }), data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client-2" }),
}) })
const outbound = sockets[1]?.sent.map((value) => JSON.parse(value)) || [] const outbound = sockets[1]?.sent.map((value) => JSON.parse(value)) || []
expect(outbound).toEqual([{ type: "attach", chat_id: "generated-chat" }]) expect(outbound).toEqual([{ type: "attach", chat_id: "generated-chat" }])
expect(statuses.at(-1)?.status).toBe("connected")
client.close() client.close()
} finally { } finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original }) Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
+18 -223
View File
@@ -1,21 +1,4 @@
export type ConnectionStatus = export type ConnectionStatus = "connecting" | "connected" | "closed" | "error"
| "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 { export interface ToolProgressEvent {
version?: number version?: number
@@ -189,16 +172,14 @@ type OutboundEvent =
export interface ClientOptions { export interface ClientOptions {
url?: string url?: string
resolveConnection?: () => Promise<GatewayConnection> resolveConnection?: () => Promise<GatewayConnection>
checkHealth?: () => Promise<GatewayHealthStatus>
onConnection?: (connection: GatewayConnection) => void onConnection?: (connection: GatewayConnection) => void
targetEndpoint?: string connectionRetryLabel?: string
startupFailureDelayMs?: number
startupRetryMaxDelayMs?: number startupRetryMaxDelayMs?: number
chatId?: string chatId?: string
initialWorkspaceScope?: WorkspaceScopePayload initialWorkspaceScope?: WorkspaceScopePayload
reconnectDelayMs?: number reconnectDelayMs?: number
onEvent: (event: InboundEvent) => void onEvent: (event: InboundEvent) => void
onStatus: (status: ConnectionStatus, detail?: string, info?: ConnectionStatusInfo) => void onStatus: (status: ConnectionStatus, detail?: string) => void
} }
export interface GatewayApiConnection { export interface GatewayApiConnection {
@@ -959,92 +940,6 @@ 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 { export class NanobotClient {
private socket: WebSocket | null = null private socket: WebSocket | null = null
private chatId = "" private chatId = ""
@@ -1054,22 +949,13 @@ export class NanobotClient {
private closedByClient = false private closedByClient = false
private opening = false private opening = false
private connectedOnce = 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, { private readonly pendingMutations = new Map<string, {
resolve: (value: unknown) => void resolve: (value: unknown) => void
reject: (error: Error) => void reject: (error: Error) => void
timer: ReturnType<typeof setTimeout> timer: ReturnType<typeof setTimeout>
}>() }>()
constructor(private readonly options: ClientOptions) { constructor(private readonly options: ClientOptions) {}
this.endpoint = options.targetEndpoint || connectionEndpoint(options.url)
}
get activeChatId(): string { get activeChatId(): string {
return this.chatId return this.chatId
@@ -1077,21 +963,13 @@ export class NanobotClient {
connect(): void { connect(): void {
this.closedByClient = false this.closedByClient = false
this.connectionAttempt = 0
this.reconnectAttempt = 0
this.retryStartedAt = Date.now()
this.nextRetryAt = 0
this.lastFailure = ""
this.healthStatus = undefined
void this.open() void this.open()
} }
private async open(): Promise<void> { private async open(): Promise<void> {
if (this.socket || this.opening || this.closedByClient) return if (this.socket || this.opening || this.closedByClient) return
this.opening = true this.opening = true
this.nextRetryAt = 0 this.options.onStatus("connecting")
this.connectionAttempt += 1
this.reportConnectionProgress()
let url = this.options.url let url = this.options.url
try { try {
if (this.options.resolveConnection) { if (this.options.resolveConnection) {
@@ -1102,52 +980,37 @@ export class NanobotClient {
} }
} catch (error) { } catch (error) {
if (!this.closedByClient) { if (!this.closedByClient) {
this.lastFailure = sanitizeConnectionFailure(error)
if (error instanceof GatewayConnectionError && !error.retryable) { if (error instanceof GatewayConnectionError && !error.retryable) {
this.clearFailureEscalation() this.options.onStatus("error", error.message)
this.options.onStatus("error", this.lastFailure, this.connectionInfo())
return return
} }
await this.checkHealthAndScheduleReconnect() this.options.onStatus(
"connecting",
this.options.connectionRetryLabel || "gateway unavailable",
)
this.scheduleReconnect(false)
} }
return return
} finally { } finally {
this.opening = false this.opening = false
} }
if (!url) { if (!url) {
this.options.onStatus("error", "gateway URL is not configured", this.connectionInfo()) this.options.onStatus("error", "gateway URL is not configured")
return return
} }
let socket: WebSocket const socket = new WebSocket(url)
try {
socket = new WebSocket(url)
} catch (error) {
this.lastFailure = sanitizeConnectionFailure(error)
await this.checkHealthAndScheduleReconnect()
return
}
let opened = false
this.socket = socket this.socket = socket
socket.addEventListener("open", () => { socket.addEventListener("open", () => {
if (this.socket !== socket) return if (this.socket !== socket) return
opened = true
this.connectedOnce = true this.connectedOnce = true
this.connectionAttempt = 0
this.reconnectAttempt = 0 this.reconnectAttempt = 0
this.retryStartedAt = 0 this.options.onStatus("connected")
this.nextRetryAt = 0
this.lastFailure = ""
this.healthStatus = "ready"
this.clearFailureEscalation()
this.options.onStatus("connected", undefined, this.connectionInfo())
}) })
socket.addEventListener("message", (message) => { socket.addEventListener("message", (message) => {
if (this.socket === socket) this.handleMessage(String(message.data)) if (this.socket === socket) this.handleMessage(String(message.data))
}) })
socket.addEventListener("error", () => { socket.addEventListener("error", () => {
if (this.socket !== socket) return if (this.socket === socket) this.options.onStatus("error", "connection failed")
this.lastFailure = "connection failed"
this.reportRetryState()
}) })
socket.addEventListener("close", () => { socket.addEventListener("close", () => {
if (this.socket !== socket) return if (this.socket !== socket) return
@@ -1157,14 +1020,7 @@ export class NanobotClient {
this.options.onStatus("closed") this.options.onStatus("closed")
return return
} }
if (opened) { this.scheduleReconnect()
this.connectionAttempt = 0
this.reconnectAttempt = 0
this.retryStartedAt = Date.now()
}
if (!this.lastFailure) this.lastFailure = "connection closed"
this.reportRetryState()
void this.checkHealthAndScheduleReconnect()
}) })
} }
@@ -1172,7 +1028,6 @@ export class NanobotClient {
this.closedByClient = true this.closedByClient = true
if (this.reconnectTimer) clearTimeout(this.reconnectTimer) if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
this.reconnectTimer = null this.reconnectTimer = null
this.clearFailureEscalation()
const socket = this.socket const socket = this.socket
this.socket = null this.socket = null
socket?.close() socket?.close()
@@ -1327,80 +1182,20 @@ 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
if (!this.retryStartedAt) this.retryStartedAt = Date.now()
const base = this.options.reconnectDelayMs ?? 500 const base = this.options.reconnectDelayMs ?? 500
const maxDelay = this.connectedOnce const maxDelay = this.connectedOnce
? 8_000 ? 8_000
: this.options.startupRetryMaxDelayMs ?? 8_000 : this.options.startupRetryMaxDelayMs ?? 8_000
const delay = Math.min(maxDelay, base * 2 ** Math.min(this.reconnectAttempt++, 4)) const delay = Math.min(maxDelay, base * 2 ** Math.min(this.reconnectAttempt++, 4))
this.nextRetryAt = Date.now() + delay if (announce) this.options.onStatus("connecting", `reconnecting in ${delay}ms`)
this.reportRetryState()
this.reconnectTimer = setTimeout(() => { this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null this.reconnectTimer = null
void this.open() void this.open()
}, delay) }, 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 { private write(event: OutboundEvent): void {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
throw new Error("gateway connection is not open") throw new Error("gateway connection is not open")