mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e341bb2661 | ||
|
|
3a62b0b744 | ||
|
|
55f85b3c1f | ||
|
|
2113870e27 | ||
|
|
b632186b5f | ||
|
|
d7d03b25ef | ||
|
|
6a3f53a917 | ||
|
|
25e20a1458 | ||
|
|
5678f83290 | ||
|
|
cb7b640d36 | ||
|
|
701926eba1 | ||
|
|
bbbfacbc64 |
+1
-49
@@ -46,13 +46,11 @@ 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
|
||||||
@@ -67,7 +65,6 @@ 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]]
|
||||||
@@ -112,7 +109,6 @@ 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
|
||||||
@@ -951,14 +947,7 @@ 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
|
||||||
@@ -1029,40 +1018,6 @@ 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,
|
||||||
@@ -1074,10 +1029,9 @@ 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 is_streaming_request and timeout_s is not None
|
if wants_streaming and timeout_s is not None
|
||||||
else timeout_s
|
else timeout_s
|
||||||
)
|
)
|
||||||
request_started_at = time.perf_counter()
|
request_started_at = time.perf_counter()
|
||||||
@@ -1115,8 +1069,6 @@ 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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from nanobot.cli.webui_support import (
|
|||||||
_gateway_health_bind_note,
|
_gateway_health_bind_note,
|
||||||
_gateway_health_url,
|
_gateway_health_url,
|
||||||
_host_for_local_browser,
|
_host_for_local_browser,
|
||||||
|
_launch_browser,
|
||||||
_prepare_webui_bundle_for_gateway,
|
_prepare_webui_bundle_for_gateway,
|
||||||
_print_foreground_port_conflict,
|
_print_foreground_port_conflict,
|
||||||
_tcp_endpoint_reachable,
|
_tcp_endpoint_reachable,
|
||||||
@@ -864,7 +865,6 @@ def _run_gateway(
|
|||||||
"""Wait for the gateway to bind, then point the user's browser at the webui."""
|
"""Wait for the gateway to bind, then point the user's browser at the webui."""
|
||||||
if not open_browser_url:
|
if not open_browser_url:
|
||||||
return
|
return
|
||||||
import webbrowser
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
# Channels start asynchronously. When the caller supplies a backend
|
# Channels start asynchronously. When the caller supplies a backend
|
||||||
@@ -896,8 +896,10 @@ def _run_gateway(
|
|||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
display_url = _webui_display_url(open_browser_url)
|
display_url = _webui_display_url(open_browser_url)
|
||||||
try:
|
try:
|
||||||
webbrowser.open(open_browser_url)
|
if _launch_browser(open_browser_url):
|
||||||
console.print(f"[green]✓[/green] Opened browser at {display_url}")
|
console.print(f"[green]✓[/green] Opened browser at {display_url}")
|
||||||
|
else:
|
||||||
|
console.print(f"[yellow]Could not open browser; visit {display_url}[/yellow]")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"[yellow]Could not open browser ({e}); visit {display_url}[/yellow]")
|
console.print(f"[yellow]Could not open browser ({e}); visit {display_url}[/yellow]")
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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,
|
||||||
@@ -66,6 +67,8 @@ _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)
|
||||||
@@ -96,6 +99,10 @@ 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",
|
||||||
@@ -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]:
|
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"],
|
||||||
@@ -233,6 +255,37 @@ 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",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Shared WebUI setup, URL, health, and browser helpers."""
|
"""Shared WebUI setup, URL, health, and browser helpers."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import webbrowser
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@@ -40,6 +42,7 @@ __all__ = [
|
|||||||
"_gateway_instance_command",
|
"_gateway_instance_command",
|
||||||
"_host_for_local_browser",
|
"_host_for_local_browser",
|
||||||
"_load_webui_setup_config",
|
"_load_webui_setup_config",
|
||||||
|
"_launch_browser",
|
||||||
"_open_webui_browser",
|
"_open_webui_browser",
|
||||||
"_prepare_webui_bundle_for_gateway",
|
"_prepare_webui_bundle_for_gateway",
|
||||||
"_print_foreground_port_conflict",
|
"_print_foreground_port_conflict",
|
||||||
@@ -60,6 +63,20 @@ __all__ = [
|
|||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
def _launch_browser(url: str) -> bool:
|
||||||
|
"""Open *url* and request a foreground browser window."""
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
result = subprocess.run(
|
||||||
|
["open", url],
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
return bool(webbrowser.open(url, new=2, autoraise=True))
|
||||||
|
|
||||||
|
|
||||||
def _confirm_webui_action(message: str, *, yes: bool) -> None:
|
def _confirm_webui_action(message: str, *, yes: bool) -> None:
|
||||||
"""Confirm a WebUI first-run mutation or fail clearly in non-interactive shells."""
|
"""Confirm a WebUI first-run mutation or fail clearly in non-interactive shells."""
|
||||||
if yes:
|
if yes:
|
||||||
@@ -419,14 +436,14 @@ def _print_foreground_port_conflict(
|
|||||||
|
|
||||||
def _open_webui_browser(url: str, *, wait: bool = True) -> None:
|
def _open_webui_browser(url: str, *, wait: bool = True) -> None:
|
||||||
"""Open the WebUI in the user's default browser, with a copyable fallback."""
|
"""Open the WebUI in the user's default browser, with a copyable fallback."""
|
||||||
import webbrowser
|
|
||||||
|
|
||||||
if wait:
|
if wait:
|
||||||
_wait_for_webui(url)
|
_wait_for_webui(url)
|
||||||
display_url = _webui_display_url(url)
|
display_url = _webui_display_url(url)
|
||||||
try:
|
try:
|
||||||
webbrowser.open(url)
|
if _launch_browser(url):
|
||||||
console.print(f"[green]✓[/green] Opened WebUI: [cyan]{display_url}[/cyan]")
|
console.print(f"[green]✓[/green] Opened WebUI: [cyan]{display_url}[/cyan]")
|
||||||
|
else:
|
||||||
|
console.print(f"[yellow]Could not open browser; visit {display_url}[/yellow]")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
console.print(f"[yellow]Could not open browser ({exc}); visit {display_url}[/yellow]")
|
console.print(f"[yellow]Could not open browser ({exc}); visit {display_url}[/yellow]")
|
||||||
|
|
||||||
|
|||||||
@@ -603,8 +603,6 @@ _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
|
||||||
|
|||||||
@@ -157,10 +157,6 @@ 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,8 +44,6 @@ _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",
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ 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,
|
||||||
|
|||||||
@@ -373,7 +373,6 @@ 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
|
||||||
|
|
||||||
@@ -460,7 +459,6 @@ 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()
|
||||||
@@ -493,7 +491,6 @@ 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):
|
||||||
@@ -544,7 +541,6 @@ 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"),
|
||||||
@@ -590,7 +586,6 @@ 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
|
||||||
|
|
||||||
@@ -637,7 +632,6 @@ 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):
|
||||||
@@ -728,7 +722,6 @@ 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=[]),
|
||||||
@@ -776,7 +769,6 @@ 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()
|
||||||
@@ -935,7 +927,6 @@ 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):
|
||||||
@@ -988,13 +979,12 @@ 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_progress_is_not_repeated_before_tool_execution(
|
async def test_streamed_content_is_not_repeated_before_tool_execution(
|
||||||
self,
|
self,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> 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 = _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]),
|
||||||
|
|||||||
@@ -798,64 +798,6 @@ 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
|
||||||
@@ -1285,13 +1227,8 @@ async def test_runner_accumulates_usage_and_preserves_cache_reads():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress():
|
async def test_runner_binds_on_retry_wait_callback():
|
||||||
"""Regression: provider retry heartbeats must route through
|
"""Provider retry heartbeats use the explicitly supplied callback."""
|
||||||
``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 = {}
|
||||||
@@ -1305,7 +1242,6 @@ async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress():
|
|||||||
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()
|
||||||
@@ -1318,12 +1254,10 @@ async def test_runner_binds_on_retry_wait_to_retry_callback_not_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,
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
import asyncio
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
@@ -6,7 +6,6 @@ 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
|
||||||
@@ -17,45 +16,9 @@ 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({
|
||||||
@@ -88,13 +51,17 @@ 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)
|
||||||
|
|
||||||
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(
|
result = await AgentRunner().run(make_run_spec(
|
||||||
provider,
|
provider,
|
||||||
initial_messages=[{"role": "user", "content": "search X"}],
|
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",
|
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,
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -133,14 +99,14 @@ async def test_runner_routes_hosted_tool_events_to_structured_progress():
|
|||||||
"embeds": [],
|
"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()
|
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({
|
||||||
@@ -166,7 +132,10 @@ 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)
|
||||||
|
|
||||||
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(
|
result = await AgentRunner().run(make_run_spec(
|
||||||
provider,
|
provider,
|
||||||
initial_messages=[{"role": "user", "content": "search X"}],
|
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",
|
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,
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -200,7 +168,6 @@ 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")
|
||||||
@@ -218,7 +185,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_stream_with_retry(**kwargs):
|
async def chat_with_retry(**kwargs):
|
||||||
nonlocal call_count
|
nonlocal call_count
|
||||||
call_count += 1
|
call_count += 1
|
||||||
if 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)
|
return LLMResponse(content="done", tool_calls=[], usage=None)
|
||||||
|
|
||||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
provider.chat_with_retry = chat_with_retry
|
||||||
provider.chat_with_retry = AsyncMock()
|
|
||||||
tools = Tools()
|
tools = Tools()
|
||||||
|
|
||||||
runner = AgentRunner()
|
runner = AgentRunner()
|
||||||
@@ -246,7 +212,6 @@ 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),
|
||||||
))
|
))
|
||||||
@@ -263,13 +228,11 @@ 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"
|
||||||
@@ -288,7 +251,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_stream_with_retry(**kwargs):
|
async def chat_with_retry(**kwargs):
|
||||||
nonlocal call_count
|
nonlocal call_count
|
||||||
call_count += 1
|
call_count += 1
|
||||||
if 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)
|
return LLMResponse(content="done", tool_calls=[], usage=None)
|
||||||
|
|
||||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
provider.chat_with_retry = chat_with_retry
|
||||||
provider.chat_with_retry = AsyncMock()
|
|
||||||
tools = Tools()
|
tools = Tools()
|
||||||
|
|
||||||
runner = AgentRunner()
|
runner = AgentRunner()
|
||||||
@@ -320,7 +282,6 @@ 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),
|
||||||
))
|
))
|
||||||
@@ -335,13 +296,11 @@ 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] = []
|
||||||
|
|
||||||
@@ -358,7 +317,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_stream_with_retry(**kwargs):
|
async def chat_with_retry(**kwargs):
|
||||||
nonlocal call_count
|
nonlocal call_count
|
||||||
call_count += 1
|
call_count += 1
|
||||||
if 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)
|
return LLMResponse(content="done", tool_calls=[], usage=None)
|
||||||
|
|
||||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
provider.chat_with_retry = chat_with_retry
|
||||||
provider.chat_with_retry = AsyncMock()
|
|
||||||
tools = Tools()
|
tools = Tools()
|
||||||
|
|
||||||
runner = AgentRunner()
|
runner = AgentRunner()
|
||||||
@@ -386,7 +344,6 @@ 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),
|
||||||
))
|
))
|
||||||
@@ -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]["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"
|
||||||
@@ -426,7 +381,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_stream_with_retry(**kwargs):
|
async def chat_with_retry(**kwargs):
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content=None,
|
content=None,
|
||||||
tool_calls=[
|
tool_calls=[
|
||||||
@@ -439,8 +394,7 @@ async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path):
|
|||||||
usage=None,
|
usage=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
provider.chat_with_retry = chat_with_retry
|
||||||
provider.chat_with_retry = AsyncMock()
|
|
||||||
tools = Tools()
|
tools = Tools()
|
||||||
|
|
||||||
runner = AgentRunner()
|
runner = AgentRunner()
|
||||||
@@ -450,7 +404,6 @@ 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),
|
||||||
)))
|
)))
|
||||||
@@ -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]["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()
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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
|
||||||
|
|
||||||
@@ -35,6 +36,18 @@ 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
|
||||||
@@ -201,7 +214,6 @@ 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:
|
||||||
@@ -218,12 +230,7 @@ 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 = []
|
||||||
|
|
||||||
progress_calls: list[str] = []
|
hook = _StreamRecordingHook()
|
||||||
|
|
||||||
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"}],
|
||||||
@@ -232,11 +239,10 @@ 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 progress_calls, "answer should have streamed via progress callback"
|
assert hook.streamed == ["The ", "answer."]
|
||||||
assert hook.emitted == ["step-by-step deduction"]
|
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
|
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:
|
||||||
@@ -263,10 +268,16 @@ 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 = []
|
||||||
|
|
||||||
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
|
pass
|
||||||
|
|
||||||
hook = _RecordingHook()
|
hook = AgentProgressHook(on_progress=_progress, on_stream=_stream)
|
||||||
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"}],
|
||||||
@@ -275,12 +286,10 @@ 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 hook.emitted == ["working..."]
|
assert reasoning_events == ["working..."]
|
||||||
assert hook.end_calls >= 1, "reasoning stream must be closed once the answer starts"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -320,14 +329,6 @@ 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``;
|
||||||
|
|||||||
@@ -2534,7 +2534,11 @@ def test_webui_yes_still_refuses_invalid_custom_model_setup(
|
|||||||
def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> None:
|
def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> None:
|
||||||
opened: list[str] = []
|
opened: list[str] = []
|
||||||
url = "http://127.0.0.1:8765/#/?bootstrapSecret=super-secret"
|
url = "http://127.0.0.1:8765/#/?bootstrapSecret=super-secret"
|
||||||
monkeypatch.setattr("webbrowser.open", lambda value: opened.append(value))
|
monkeypatch.setattr(
|
||||||
|
cli_webui_support,
|
||||||
|
"_launch_browser",
|
||||||
|
lambda value: opened.append(value) or True,
|
||||||
|
)
|
||||||
|
|
||||||
cli_webui_support._open_webui_browser(url, wait=False)
|
cli_webui_support._open_webui_browser(url, wait=False)
|
||||||
|
|
||||||
@@ -2544,6 +2548,42 @@ def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> Non
|
|||||||
assert "super-secret" not in output
|
assert "super-secret" not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_webui_browser_reports_launch_failure(monkeypatch, capsys) -> None:
|
||||||
|
monkeypatch.setattr(cli_webui_support, "_launch_browser", lambda _value: False)
|
||||||
|
|
||||||
|
cli_webui_support._open_webui_browser("http://127.0.0.1:8765/", wait=False)
|
||||||
|
|
||||||
|
assert "Could not open browser; visit http://127.0.0.1:8765/" in _strip_ansi(
|
||||||
|
capsys.readouterr().out
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_launch_browser_uses_macos_foreground_opener(monkeypatch) -> None:
|
||||||
|
seen: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(cli_webui_support.sys, "platform", "darwin")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_webui_support.subprocess,
|
||||||
|
"run",
|
||||||
|
lambda command, **_kwargs: seen.append(command) or SimpleNamespace(returncode=0),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cli_webui_support._launch_browser("http://127.0.0.1:8765/") is True
|
||||||
|
assert seen == [["open", "http://127.0.0.1:8765/"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_launch_browser_uses_default_browser_off_macos(monkeypatch) -> None:
|
||||||
|
opened: list[tuple[str, int, bool]] = []
|
||||||
|
monkeypatch.setattr(cli_webui_support.sys, "platform", "linux")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_webui_support.webbrowser,
|
||||||
|
"open",
|
||||||
|
lambda url, *, new, autoraise: opened.append((url, new, autoraise)) or True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cli_webui_support._launch_browser("http://127.0.0.1:8765/") is True
|
||||||
|
assert opened == [("http://127.0.0.1:8765/", 2, True)]
|
||||||
|
|
||||||
|
|
||||||
def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_path: Path) -> None:
|
def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_path: Path) -> None:
|
||||||
config_file = tmp_path / "config.json"
|
config_file = tmp_path / "config.json"
|
||||||
config_file.write_text("{}")
|
config_file.write_text("{}")
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ 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"),
|
||||||
[
|
[
|
||||||
@@ -142,6 +150,7 @@ 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
|
||||||
@@ -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,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
source_dir = tmp_path / "tui"
|
source_dir = _tui_source(tmp_path)
|
||||||
source_dir.mkdir()
|
dependency = source_dir / "node_modules" / "@opentui" / "core"
|
||||||
(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)
|
||||||
@@ -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(
|
def test_source_checkout_fails_when_locked_dependencies_cannot_be_refreshed(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|||||||
@@ -63,9 +63,3 @@ 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
|
|
||||||
|
|||||||
+106
-6
@@ -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, "Connecting…")).toBe(1)
|
expect(occurrences(frame, "Getting ready…")).toBe(1)
|
||||||
expect(occurrences(frame, "nanobot · test/model")).toBe(1)
|
expect(occurrences(frame, "nanobot · test/model")).toBe(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2384,6 +2384,85 @@ 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
|
||||||
@@ -2444,7 +2523,12 @@ describe("NanobotTui layout", () => {
|
|||||||
client(sent),
|
client(sent),
|
||||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
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 {
|
try {
|
||||||
app.accept({ event: "attached", chat_id: "chat" })
|
app.accept({ event: "attached", chat_id: "chat" })
|
||||||
@@ -2452,16 +2536,18 @@ 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 Bun.sleep(5)
|
await waitUntil(() => ui.status.plainText.includes("Not sent"))
|
||||||
|
|
||||||
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(() => (app as unknown as { ready: boolean }).ready)
|
await waitUntil(() => ui.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()
|
||||||
@@ -2480,24 +2566,38 @@ 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(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" })
|
app.accept({ event: "attached", chat_id: "chat" })
|
||||||
await Bun.sleep(1)
|
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")
|
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"])
|
||||||
})
|
})
|
||||||
|
|||||||
+82
-23
@@ -20,7 +20,9 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
NanobotClient,
|
NanobotClient,
|
||||||
|
connectionEndpoint,
|
||||||
fetchAvailableSkills,
|
fetchAvailableSkills,
|
||||||
|
fetchGatewayHealth,
|
||||||
fetchHistory,
|
fetchHistory,
|
||||||
fetchGatewayConnection,
|
fetchGatewayConnection,
|
||||||
fetchMentionCandidates,
|
fetchMentionCandidates,
|
||||||
@@ -29,6 +31,7 @@ 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,
|
||||||
@@ -93,6 +96,7 @@ 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
|
||||||
@@ -374,6 +378,21 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -449,6 +468,8 @@ 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 = ""
|
||||||
@@ -556,11 +577,14 @@ 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,
|
||||||
),
|
),
|
||||||
connectionRetryLabel: "Starting local gateway",
|
targetEndpoint: connectionEndpoint(options.bootstrapUrl),
|
||||||
reconnectDelayMs: 100,
|
reconnectDelayMs: 100,
|
||||||
startupRetryMaxDelayMs: 250,
|
startupRetryMaxDelayMs: 250,
|
||||||
}
|
}
|
||||||
@@ -571,7 +595,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) => this.handleStatus(status, detail),
|
onStatus: (status, detail, info) => this.handleStatus(status, detail, info),
|
||||||
})
|
})
|
||||||
|
|
||||||
// The terminal owns its canvas. Keeping the default-background intent is
|
// The terminal owns its canvas. Keeping the default-background intent is
|
||||||
@@ -723,6 +747,8 @@ 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()
|
||||||
@@ -730,6 +756,9 @@ 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.
|
||||||
@@ -738,7 +767,7 @@ export class NanobotTui {
|
|||||||
})
|
})
|
||||||
this.status = new TextRenderable(renderer, {
|
this.status = new TextRenderable(renderer, {
|
||||||
id: "nanobot-tui-status",
|
id: "nanobot-tui-status",
|
||||||
content: "Connecting…",
|
content: "Getting ready…",
|
||||||
fg: this.palette.muted,
|
fg: this.palette.muted,
|
||||||
height: 1,
|
height: 1,
|
||||||
width: "auto",
|
width: "auto",
|
||||||
@@ -824,7 +853,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", "Connecting")
|
this.host.reportState("unknown", "Getting ready")
|
||||||
this.client.connect()
|
this.client.connect()
|
||||||
void this.loadCommands()
|
void this.loadCommands()
|
||||||
void this.loadMentions()
|
void this.loadMentions()
|
||||||
@@ -899,6 +928,10 @@ 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")
|
||||||
@@ -932,7 +965,7 @@ export class NanobotTui {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!this.ready) {
|
if (!this.ready) {
|
||||||
this.status.content = "Preparing chat…"
|
this.markSubmitUnsent()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const prompt = { content, options: mentionOptions(content, this.availableMentions()) }
|
const prompt = { content, options: mentionOptions(content, this.availableMentions()) }
|
||||||
@@ -947,10 +980,11 @@ 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 (error) {
|
} catch {
|
||||||
this.status.content = error instanceof Error ? error.message : String(error)
|
this.markSubmitUnsent(true)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
this.unsentSubmit = false
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.mentionMenu.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") {
|
if (status === "connected") {
|
||||||
this.ready = false
|
this.ready = false
|
||||||
this.host.reportState("unknown", "Connecting")
|
this.host.reportState("unknown", "Getting ready")
|
||||||
this.status.content = "Connected · preparing chat…"
|
this.renderConnectionMessage()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (status === "connecting") {
|
if (["starting", "connecting", "reconnecting", "unavailable"].includes(status)) {
|
||||||
this.ready = false
|
this.ready = false
|
||||||
const label = detail === "Starting local gateway"
|
this.host.reportState("unknown", this.connectionMessage)
|
||||||
? detail
|
if (status === "reconnecting" || status === "unavailable") this.setActive(false)
|
||||||
: detail ? "Reconnecting" : "Connecting"
|
this.renderConnectionMessage()
|
||||||
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", detail || "Connection error")
|
this.host.reportState("unknown", this.connectionMessage)
|
||||||
this.status.content = detail || "Connection error"
|
this.renderConnectionMessage()
|
||||||
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.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 {
|
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
|
||||||
@@ -1460,6 +1516,7 @@ 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"
|
||||||
@@ -2083,6 +2140,7 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private clearComposer(): void {
|
private clearComposer(): void {
|
||||||
|
this.unsentSubmit = false
|
||||||
this.draft.clear()
|
this.draft.clear()
|
||||||
this.composer.setText("")
|
this.composer.setText("")
|
||||||
}
|
}
|
||||||
@@ -2383,7 +2441,7 @@ export class NanobotTui {
|
|||||||
options: MessageOptions = {},
|
options: MessageOptions = {},
|
||||||
): void {
|
): void {
|
||||||
if (!this.ready) {
|
if (!this.ready) {
|
||||||
this.status.content = "Preparing chat…"
|
this.markSubmitUnsent()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.activeTurn && lifecycle === "agent_turn") {
|
if (this.activeTurn && lifecycle === "agent_turn") {
|
||||||
@@ -2393,10 +2451,11 @@ export class NanobotTui {
|
|||||||
let turnId: string
|
let turnId: string
|
||||||
try {
|
try {
|
||||||
turnId = this.client.send(content, options)
|
turnId = this.client.send(content, options)
|
||||||
} catch (error) {
|
} catch {
|
||||||
this.status.content = error instanceof Error ? error.message : String(error)
|
this.markSubmitUnsent(true)
|
||||||
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)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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) {
|
||||||
@@ -24,6 +25,7 @@ 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() || "",
|
||||||
|
|||||||
+196
-1
@@ -3,14 +3,19 @@ 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"
|
||||||
|
|
||||||
@@ -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", () => {
|
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
|
||||||
@@ -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 () => {
|
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
|
||||||
@@ -138,8 +184,13 @@ 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,
|
||||||
@@ -155,6 +206,7 @@ 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 })
|
||||||
@@ -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 () => {
|
test("reports a permanent bootstrap rejection without retrying", async () => {
|
||||||
let attempts = 0
|
let attempts = 0
|
||||||
const statuses: string[] = []
|
const statuses: string[] = []
|
||||||
@@ -588,13 +767,19 @@ 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: () => undefined,
|
onStatus: (status, detail, info) => statuses.push({ status, detail, info }),
|
||||||
})
|
})
|
||||||
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" }),
|
||||||
})
|
})
|
||||||
@@ -605,11 +790,21 @@ 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 })
|
||||||
|
|||||||
+223
-18
@@ -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 {
|
export interface ToolProgressEvent {
|
||||||
version?: number
|
version?: number
|
||||||
@@ -172,14 +189,16 @@ 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
|
||||||
connectionRetryLabel?: string
|
targetEndpoint?: 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) => void
|
onStatus: (status: ConnectionStatus, detail?: string, info?: ConnectionStatusInfo) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GatewayApiConnection {
|
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 {
|
export class NanobotClient {
|
||||||
private socket: WebSocket | null = null
|
private socket: WebSocket | null = null
|
||||||
private chatId = ""
|
private chatId = ""
|
||||||
@@ -949,13 +1054,22 @@ 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
|
||||||
@@ -963,13 +1077,21 @@ 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.options.onStatus("connecting")
|
this.nextRetryAt = 0
|
||||||
|
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) {
|
||||||
@@ -980,37 +1102,52 @@ 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.options.onStatus("error", error.message)
|
this.clearFailureEscalation()
|
||||||
|
this.options.onStatus("error", this.lastFailure, this.connectionInfo())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.options.onStatus(
|
await this.checkHealthAndScheduleReconnect()
|
||||||
"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.options.onStatus("error", "gateway URL is not configured", this.connectionInfo())
|
||||||
return
|
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
|
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.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) => {
|
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) this.options.onStatus("error", "connection failed")
|
if (this.socket !== socket) return
|
||||||
|
this.lastFailure = "connection failed"
|
||||||
|
this.reportRetryState()
|
||||||
})
|
})
|
||||||
socket.addEventListener("close", () => {
|
socket.addEventListener("close", () => {
|
||||||
if (this.socket !== socket) return
|
if (this.socket !== socket) return
|
||||||
@@ -1020,7 +1157,14 @@ export class NanobotClient {
|
|||||||
this.options.onStatus("closed")
|
this.options.onStatus("closed")
|
||||||
return
|
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
|
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()
|
||||||
@@ -1182,20 +1327,80 @@ export class NanobotClient {
|
|||||||
this.options.onEvent(event)
|
this.options.onEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleReconnect(announce = true): void {
|
private scheduleReconnect(): 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))
|
||||||
if (announce) this.options.onStatus("connecting", `reconnecting in ${delay}ms`)
|
this.nextRetryAt = Date.now() + delay
|
||||||
|
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")
|
||||||
|
|||||||
@@ -2816,6 +2816,7 @@ function Shell({
|
|||||||
hostChromeTitleInset={hostSidebarCollapsed}
|
hostChromeTitleInset={hostSidebarCollapsed}
|
||||||
hideThemeButton={!context.active}
|
hideThemeButton={!context.active}
|
||||||
hideHeaderTitle
|
hideHeaderTitle
|
||||||
|
inlineHandle={workbenchPaneSessions.length > 1}
|
||||||
headerActions={context.headerActions}
|
headerActions={context.headerActions}
|
||||||
headerPortalTarget={context.headerPortalTarget}
|
headerPortalTarget={context.headerPortalTarget}
|
||||||
headerActive={context.active}
|
headerActive={context.active}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import {
|
||||||
|
Fragment,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock3,
|
Clock3,
|
||||||
@@ -43,10 +52,11 @@ import {
|
|||||||
} from "@/lib/activity-timeline";
|
} from "@/lib/activity-timeline";
|
||||||
import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode";
|
import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode";
|
||||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||||
|
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||||
|
import type { FileEditDisplayMode } from "@/lib/local-preferences";
|
||||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||||
import { canonicalToolTrace, formatToolCallTrace } from "@/lib/tool-traces";
|
import { canonicalToolTrace, formatToolCallTrace } from "@/lib/tool-traces";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
|
||||||
import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types";
|
import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
|
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
|
||||||
@@ -156,10 +166,14 @@ export function AgentActivityCluster({
|
|||||||
const fileEditDisplayMode = useFileEditDisplayMode();
|
const fileEditDisplayMode = useFileEditDisplayMode();
|
||||||
const pageVisible = usePageVisibility();
|
const pageVisible = usePageVisibility();
|
||||||
const activityMessages = useMemo(() => coalesceActivityMessages(messages), [messages]);
|
const activityMessages = useMemo(() => coalesceActivityMessages(messages), [messages]);
|
||||||
const fileEdits = useMemo(
|
const fileEditsByMessage = useMemo(
|
||||||
() => summarizeFileEdits(collectFileEdits(activityMessages), isTurnStreaming),
|
() => summarizeFileEditsByMessage(activityMessages, isTurnStreaming),
|
||||||
[activityMessages, isTurnStreaming],
|
[activityMessages, isTurnStreaming],
|
||||||
);
|
);
|
||||||
|
const fileEdits = useMemo(
|
||||||
|
() => [...fileEditsByMessage.values()].flat(),
|
||||||
|
[fileEditsByMessage],
|
||||||
|
);
|
||||||
const cliRuns = useMemo(() => collectCliRuns(activityMessages), [activityMessages]);
|
const cliRuns = useMemo(() => collectCliRuns(activityMessages), [activityMessages]);
|
||||||
const mcpRuns = useMemo(() => collectMcpRuns(activityMessages), [activityMessages]);
|
const mcpRuns = useMemo(() => collectMcpRuns(activityMessages), [activityMessages]);
|
||||||
const cliAppsByName = useMemo(
|
const cliAppsByName = useMemo(
|
||||||
@@ -348,15 +362,10 @@ export function AgentActivityCluster({
|
|||||||
active={isTurnStreaming}
|
active={isTurnStreaming}
|
||||||
cliAppsByName={cliAppsByName}
|
cliAppsByName={cliAppsByName}
|
||||||
mcpPresetsByName={mcpPresetsByName}
|
mcpPresetsByName={mcpPresetsByName}
|
||||||
|
fileEditsByMessage={fileEditsByMessage}
|
||||||
|
fileEditDisplayMode={fileEditDisplayMode}
|
||||||
onOpenFilePreview={onOpenFilePreview}
|
onOpenFilePreview={onOpenFilePreview}
|
||||||
/>
|
/>
|
||||||
{fileEdits.length ? (
|
|
||||||
<FileEditGroup
|
|
||||||
edits={fileEdits}
|
|
||||||
displayMode={fileEditDisplayMode}
|
|
||||||
onOpenFilePreview={onOpenFilePreview}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</ThinkingReasoningShell>
|
</ThinkingReasoningShell>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -414,12 +423,16 @@ function ActivityMessageTimeline({
|
|||||||
active,
|
active,
|
||||||
cliAppsByName,
|
cliAppsByName,
|
||||||
mcpPresetsByName,
|
mcpPresetsByName,
|
||||||
|
fileEditsByMessage,
|
||||||
|
fileEditDisplayMode,
|
||||||
onOpenFilePreview,
|
onOpenFilePreview,
|
||||||
}: {
|
}: {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
active: boolean;
|
active: boolean;
|
||||||
cliAppsByName: Map<string, CliAppInfo>;
|
cliAppsByName: Map<string, CliAppInfo>;
|
||||||
mcpPresetsByName: Map<string, McpPresetInfo>;
|
mcpPresetsByName: Map<string, McpPresetInfo>;
|
||||||
|
fileEditsByMessage: Map<string, FileEditSummary[]>;
|
||||||
|
fileEditDisplayMode: FileEditDisplayMode;
|
||||||
onOpenFilePreview?: (path: string) => void;
|
onOpenFilePreview?: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const items: ReactNode[] = [];
|
const items: ReactNode[] = [];
|
||||||
@@ -447,14 +460,21 @@ function ActivityMessageTimeline({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.kind === "trace") {
|
if (message.kind === "trace") {
|
||||||
|
const fileEdits = fileEditsByMessage.get(message.id) ?? [];
|
||||||
items.push(
|
items.push(
|
||||||
|
<Fragment key={message.id}>
|
||||||
<ActivityTraceTimeline
|
<ActivityTraceTimeline
|
||||||
key={message.id}
|
|
||||||
message={message}
|
message={message}
|
||||||
active={active && index === messages.length - 1}
|
active={active && index === messages.length - 1}
|
||||||
cliAppsByName={cliAppsByName}
|
cliAppsByName={cliAppsByName}
|
||||||
mcpPresetsByName={mcpPresetsByName}
|
mcpPresetsByName={mcpPresetsByName}
|
||||||
/>,
|
/>
|
||||||
|
<FileEditGroup
|
||||||
|
edits={fileEdits}
|
||||||
|
displayMode={fileEditDisplayMode}
|
||||||
|
onOpenFilePreview={onOpenFilePreview}
|
||||||
|
/>
|
||||||
|
</Fragment>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1061,16 +1081,6 @@ function fileEditCallKey(edit: UIFileEdit): string {
|
|||||||
return `${edit.tool}|${edit.path}`;
|
return `${edit.tool}|${edit.path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectFileEdits(messages: UIMessage[]): UIFileEdit[] {
|
|
||||||
const edits: UIFileEdit[] = [];
|
|
||||||
for (const message of messages) {
|
|
||||||
if (message.kind === "trace" && message.fileEdits?.length) {
|
|
||||||
edits.push(...message.fileEdits);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return edits;
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
|
function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
const byKey = new Map<string, UIFileEdit>();
|
const byKey = new Map<string, UIFileEdit>();
|
||||||
@@ -1082,6 +1092,33 @@ function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
|
|||||||
return order.map((key) => byKey.get(key)).filter(Boolean) as UIFileEdit[];
|
return order.map((key) => byKey.get(key)).filter(Boolean) as UIFileEdit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keep each edit at the point where its call first appeared. Later lifecycle
|
||||||
|
* events update that row in place instead of moving completed edits to the end. */
|
||||||
|
function summarizeFileEditsByMessage(
|
||||||
|
messages: UIMessage[],
|
||||||
|
active: boolean,
|
||||||
|
): Map<string, FileEditSummary[]> {
|
||||||
|
const messageByEdit = new Map<string, string>();
|
||||||
|
const edits: UIFileEdit[] = [];
|
||||||
|
for (const message of messages) {
|
||||||
|
for (const edit of message.fileEdits ?? []) {
|
||||||
|
const key = fileEditCallKey(edit);
|
||||||
|
if (!messageByEdit.has(key)) messageByEdit.set(key, message.id);
|
||||||
|
edits.push(edit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = new Map<string, FileEditSummary[]>();
|
||||||
|
for (const edit of summarizeFileEdits(edits, active)) {
|
||||||
|
const messageId = messageByEdit.get(edit.key);
|
||||||
|
if (!messageId) continue;
|
||||||
|
const group = grouped.get(messageId) ?? [];
|
||||||
|
group.push(edit);
|
||||||
|
grouped.set(messageId, group);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] {
|
function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] {
|
||||||
return latestFileEditEvents(edits).flatMap((edit) => {
|
return latestFileEditEvents(edits).flatMap((edit) => {
|
||||||
const editing = active && edit.status === "editing";
|
const editing = active && edit.status === "editing";
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
type PointerEvent,
|
type PointerEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Check, CircleHelp, Sparkles } from "lucide-react";
|
import { Check, CircleHelp, SlidersHorizontal, Sparkles } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -85,6 +85,7 @@ interface ModelPresetBadgeProps {
|
|||||||
modelPreset?: string | null;
|
modelPreset?: string | null;
|
||||||
modelPresets?: ModelPresetOption[];
|
modelPresets?: ModelPresetOption[];
|
||||||
onPresetChange?: (name: string) => void;
|
onPresetChange?: (name: string) => void;
|
||||||
|
onManageModels?: () => void;
|
||||||
onRequestComposerFocus?: () => void;
|
onRequestComposerFocus?: () => void;
|
||||||
provider?: string | null;
|
provider?: string | null;
|
||||||
providerLabel?: string | null;
|
providerLabel?: string | null;
|
||||||
@@ -100,6 +101,7 @@ export function ModelPresetBadge({
|
|||||||
modelPreset,
|
modelPreset,
|
||||||
modelPresets = [],
|
modelPresets = [],
|
||||||
onPresetChange,
|
onPresetChange,
|
||||||
|
onManageModels,
|
||||||
onRequestComposerFocus,
|
onRequestComposerFocus,
|
||||||
provider,
|
provider,
|
||||||
providerLabel,
|
providerLabel,
|
||||||
@@ -156,6 +158,11 @@ export function ModelPresetBadge({
|
|||||||
requestAnimationFrame(() => onRequestComposerFocus?.());
|
requestAnimationFrame(() => onRequestComposerFocus?.());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openModelSettings = () => {
|
||||||
|
setOpen(false);
|
||||||
|
onManageModels?.();
|
||||||
|
};
|
||||||
|
|
||||||
const clearGesture = () => {
|
const clearGesture = () => {
|
||||||
const gesture = gestureRef.current;
|
const gesture = gestureRef.current;
|
||||||
if (gesture?.timer) clearTimeout(gesture.timer);
|
if (gesture?.timer) clearTimeout(gesture.timer);
|
||||||
@@ -418,6 +425,22 @@ export function ModelPresetBadge({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{onManageModels ? (
|
||||||
|
<div className="mt-1 border-t border-border/55 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openModelSettings}
|
||||||
|
className={cn(
|
||||||
|
floatingItemClassName,
|
||||||
|
floatingItemFocusClassName,
|
||||||
|
"flex min-h-9 w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="size-4 shrink-0" strokeWidth={1.75} />
|
||||||
|
<span>{t("thread.composer.manageModels")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ interface ThreadComposerProps {
|
|||||||
modelNeedsSetup?: boolean;
|
modelNeedsSetup?: boolean;
|
||||||
fallbackModelName?: string | null;
|
fallbackModelName?: string | null;
|
||||||
onModelBadgeClick?: () => void;
|
onModelBadgeClick?: () => void;
|
||||||
|
onManageModels?: () => void;
|
||||||
contextUsage?: ComposerContextUsage | null;
|
contextUsage?: ComposerContextUsage | null;
|
||||||
variant?: "thread" | "hero";
|
variant?: "thread" | "hero";
|
||||||
slashCommands?: SlashCommand[];
|
slashCommands?: SlashCommand[];
|
||||||
@@ -998,6 +999,7 @@ export function ThreadComposer({
|
|||||||
modelNeedsSetup = false,
|
modelNeedsSetup = false,
|
||||||
fallbackModelName = null,
|
fallbackModelName = null,
|
||||||
onModelBadgeClick,
|
onModelBadgeClick,
|
||||||
|
onManageModels,
|
||||||
contextUsage = null,
|
contextUsage = null,
|
||||||
variant = "thread",
|
variant = "thread",
|
||||||
slashCommands = [],
|
slashCommands = [],
|
||||||
@@ -2539,6 +2541,7 @@ export function ThreadComposer({
|
|||||||
modelPreset={modelPreset}
|
modelPreset={modelPreset}
|
||||||
modelPresets={modelPresets}
|
modelPresets={modelPresets}
|
||||||
onPresetChange={onModelPresetChange}
|
onPresetChange={onModelPresetChange}
|
||||||
|
onManageModels={onManageModels}
|
||||||
onRequestComposerFocus={() => textareaRef.current?.focus()}
|
onRequestComposerFocus={() => textareaRef.current?.focus()}
|
||||||
provider={modelProvider}
|
provider={modelProvider}
|
||||||
providerLabel={modelProviderLabel}
|
providerLabel={modelProviderLabel}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function ThreadHeader({
|
|||||||
<div
|
<div
|
||||||
data-testid="thread-header"
|
data-testid="thread-header"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-30 flex items-center justify-between gap-3 px-3 py-2",
|
"relative z-30 flex items-center justify-between gap-3 px-3 py-1",
|
||||||
minimal && "h-11",
|
minimal && "h-11",
|
||||||
!minimal && hostChromeTitleInset && "lg:pl-[128px]",
|
!minimal && hostChromeTitleInset && "lg:pl-[128px]",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export function ThreadMessages({
|
|||||||
return (
|
return (
|
||||||
<ThreadDisplayUnit
|
<ThreadDisplayUnit
|
||||||
key={unitKeys[index]}
|
key={unitKeys[index]}
|
||||||
|
unitKey={unitKeys[index]}
|
||||||
unit={unit}
|
unit={unit}
|
||||||
marginTop={marginTop}
|
marginTop={marginTop}
|
||||||
userPromptId={userPromptId}
|
userPromptId={userPromptId}
|
||||||
@@ -225,6 +226,7 @@ function pendingTurnProjection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ThreadDisplayUnitProps {
|
interface ThreadDisplayUnitProps {
|
||||||
|
unitKey: string;
|
||||||
unit: DisplayUnit;
|
unit: DisplayUnit;
|
||||||
marginTop: string;
|
marginTop: string;
|
||||||
userPromptId?: string;
|
userPromptId?: string;
|
||||||
@@ -243,6 +245,7 @@ interface ThreadDisplayUnitProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
||||||
|
unitKey,
|
||||||
unit,
|
unit,
|
||||||
marginTop,
|
marginTop,
|
||||||
userPromptId,
|
userPromptId,
|
||||||
@@ -273,6 +276,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`${marginTop}${stableDeferOffscreenRender ? " thread-render-unit" : ""}`}
|
className={`${marginTop}${stableDeferOffscreenRender ? " thread-render-unit" : ""}`}
|
||||||
|
data-thread-display-unit={unitKey}
|
||||||
data-user-prompt-id={userPromptId}
|
data-user-prompt-id={userPromptId}
|
||||||
>
|
>
|
||||||
{unit.type === "activity" ? (
|
{unit.type === "activity" ? (
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ interface ThreadShellProps {
|
|||||||
hostChromeTitleInset?: boolean;
|
hostChromeTitleInset?: boolean;
|
||||||
hideThemeButton?: boolean;
|
hideThemeButton?: boolean;
|
||||||
hideHeaderTitle?: boolean;
|
hideHeaderTitle?: boolean;
|
||||||
|
inlineHandle?: boolean;
|
||||||
hideHeader?: boolean;
|
hideHeader?: boolean;
|
||||||
headerActions?: ReactNode;
|
headerActions?: ReactNode;
|
||||||
headerPortalTarget?: HTMLElement | null;
|
headerPortalTarget?: HTMLElement | null;
|
||||||
@@ -645,6 +646,7 @@ export function ThreadShell({
|
|||||||
hostChromeTitleInset = false,
|
hostChromeTitleInset = false,
|
||||||
hideThemeButton = false,
|
hideThemeButton = false,
|
||||||
hideHeaderTitle = false,
|
hideHeaderTitle = false,
|
||||||
|
inlineHandle = false,
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
headerActions,
|
headerActions,
|
||||||
headerPortalTarget,
|
headerPortalTarget,
|
||||||
@@ -1517,6 +1519,7 @@ export function ThreadShell({
|
|||||||
modelNeedsSetup={modelBadge.needsSetup}
|
modelNeedsSetup={modelBadge.needsSetup}
|
||||||
fallbackModelName={fallbackModelName}
|
fallbackModelName={fallbackModelName}
|
||||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||||
|
onManageModels={onOpenModelSettings}
|
||||||
contextUsage={composerContextUsage}
|
contextUsage={composerContextUsage}
|
||||||
variant={composerVariant}
|
variant={composerVariant}
|
||||||
slashCommands={availableSlashCommands}
|
slashCommands={availableSlashCommands}
|
||||||
@@ -1565,6 +1568,7 @@ export function ThreadShell({
|
|||||||
modelNeedsSetup={modelBadge.needsSetup}
|
modelNeedsSetup={modelBadge.needsSetup}
|
||||||
fallbackModelName={fallbackModelName}
|
fallbackModelName={fallbackModelName}
|
||||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||||
|
onManageModels={onOpenModelSettings}
|
||||||
contextUsage={composerContextUsage}
|
contextUsage={composerContextUsage}
|
||||||
variant="hero"
|
variant="hero"
|
||||||
slashCommands={availableSlashCommands}
|
slashCommands={availableSlashCommands}
|
||||||
@@ -1614,7 +1618,7 @@ export function ThreadShell({
|
|||||||
const threadHeader = !hideHeader ? (
|
const threadHeader = !hideHeader ? (
|
||||||
<ThreadHeader
|
<ThreadHeader
|
||||||
title={title}
|
title={title}
|
||||||
handle={temporary || hideHeaderTitle ? null : session?.handle}
|
handle={temporary || (hideHeaderTitle && inlineHandle) ? null : session?.handle}
|
||||||
onToggleSidebar={onToggleSidebar}
|
onToggleSidebar={onToggleSidebar}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={onToggleTheme}
|
onToggleTheme={onToggleTheme}
|
||||||
@@ -1638,7 +1642,7 @@ export function ThreadShell({
|
|||||||
return (
|
return (
|
||||||
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||||
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
{hideHeaderTitle && !temporary && session?.handle ? (
|
{hideHeaderTitle && inlineHandle && !temporary && session?.handle ? (
|
||||||
<div
|
<div
|
||||||
aria-label={`Session @${session.handle.name}`}
|
aria-label={`Session @${session.handle.name}`}
|
||||||
className="flex h-8 shrink-0 items-center px-3 text-[12px]"
|
className="flex h-8 shrink-0 items-center px-3 text-[12px]"
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ interface ThreadViewportProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NEAR_BOTTOM_PX = 48;
|
const NEAR_BOTTOM_PX = 48;
|
||||||
const NEAR_TOP_PX = 96;
|
const HISTORY_PREFETCH_MIN_PX = 160;
|
||||||
|
const HISTORY_PREFETCH_MAX_PX = 480;
|
||||||
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
||||||
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
|
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
|
||||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||||
@@ -72,6 +73,52 @@ const SESSION_HANDOFF_OPACITY = 0.82;
|
|||||||
export const INITIAL_HISTORY_WINDOW = 160;
|
export const INITIAL_HISTORY_WINDOW = 160;
|
||||||
export const HISTORY_WINDOW_INCREMENT = 120;
|
export const HISTORY_WINDOW_INCREMENT = 120;
|
||||||
|
|
||||||
|
interface HistoryScrollAnchor {
|
||||||
|
key: string;
|
||||||
|
offsetTop: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THREAD_DISPLAY_UNIT_SELECTOR = "[data-thread-display-unit]";
|
||||||
|
|
||||||
|
function historyPrefetchDistance(scroller: HTMLElement): number {
|
||||||
|
return Math.min(
|
||||||
|
HISTORY_PREFETCH_MAX_PX,
|
||||||
|
Math.max(HISTORY_PREFETCH_MIN_PX, scroller.clientHeight / 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleHistoryUnit(
|
||||||
|
content: HTMLElement,
|
||||||
|
viewport: DOMRect,
|
||||||
|
): HTMLElement | null {
|
||||||
|
// Scroll is a hot path. Hit-testing keeps the common case O(1) instead of
|
||||||
|
// forcing layout for every mounted message while the trackpad is moving.
|
||||||
|
if (typeof document.elementsFromPoint === "function" && viewport.height > 0) {
|
||||||
|
const contentBounds = content.getBoundingClientRect();
|
||||||
|
const left = Math.max(viewport.left, contentBounds.left);
|
||||||
|
const right = Math.min(viewport.right, contentBounds.right);
|
||||||
|
const x = left + Math.max(0, right - left) / 2;
|
||||||
|
const offsets = [1, Math.min(32, viewport.height / 3), viewport.height / 2];
|
||||||
|
for (const offset of offsets) {
|
||||||
|
for (const target of document.elementsFromPoint(x, viewport.top + offset)) {
|
||||||
|
const unit = target instanceof Element
|
||||||
|
? target.closest<HTMLElement>(THREAD_DISPLAY_UNIT_SELECTOR)
|
||||||
|
: null;
|
||||||
|
if (unit && content.contains(unit)) return unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic fallback for pre-layout states, tests, and older browsers.
|
||||||
|
const units = Array.from(
|
||||||
|
content.querySelectorAll<HTMLElement>(THREAD_DISPLAY_UNIT_SELECTOR),
|
||||||
|
);
|
||||||
|
return units.find((unit) => {
|
||||||
|
const bounds = unit.getBoundingClientRect();
|
||||||
|
return bounds.bottom > viewport.top && bounds.top < viewport.bottom;
|
||||||
|
}) ?? units[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] {
|
export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] {
|
||||||
if (messages.length <= visibleCount) return messages;
|
if (messages.length <= visibleCount) return messages;
|
||||||
let start = Math.max(0, messages.length - visibleCount);
|
let start = Math.max(0, messages.length - visibleCount);
|
||||||
@@ -210,6 +257,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const pendingPromptJumpRef = useRef<string | null>(null);
|
const pendingPromptJumpRef = useRef<string | null>(null);
|
||||||
const restoreScrollAfterPrependRef =
|
const restoreScrollAfterPrependRef =
|
||||||
useRef<{ height: number; top: number } | null>(null);
|
useRef<{ height: number; top: number } | null>(null);
|
||||||
|
const historyScrollAnchorRef = useRef<HistoryScrollAnchor | null>(null);
|
||||||
const composerInputScrollTopRef = useRef<number | null>(null);
|
const composerInputScrollTopRef = useRef<number | null>(null);
|
||||||
const composerDockHeightRef = useRef(0);
|
const composerDockHeightRef = useRef(0);
|
||||||
const [atBottom, setAtBottom] = useState(true);
|
const [atBottom, setAtBottom] = useState(true);
|
||||||
@@ -298,7 +346,53 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
threadMotionRef.current?.takeUserControl();
|
threadMotionRef.current?.takeUserControl();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const captureHistoryScrollAnchor = useCallback(() => {
|
||||||
|
const scroller = scrollRef.current;
|
||||||
|
const content = messageContentRef.current;
|
||||||
|
if (!scroller || !content) {
|
||||||
|
historyScrollAnchorRef.current = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const viewport = scroller.getBoundingClientRect();
|
||||||
|
const element = visibleHistoryUnit(content, viewport);
|
||||||
|
const key = element?.dataset.threadDisplayUnit;
|
||||||
|
if (!element || !key) {
|
||||||
|
historyScrollAnchorRef.current = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
historyScrollAnchorRef.current = {
|
||||||
|
key,
|
||||||
|
offsetTop: element.getBoundingClientRect().top - viewport.top,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reconcileHistoryScrollAnchor = useCallback(() => {
|
||||||
|
const scroller = scrollRef.current;
|
||||||
|
const content = messageContentRef.current;
|
||||||
|
const anchor = historyScrollAnchorRef.current;
|
||||||
|
if (!scroller || !content || !anchor) return false;
|
||||||
|
const element = Array.from(
|
||||||
|
content.querySelectorAll<HTMLElement>("[data-thread-display-unit]"),
|
||||||
|
).find((candidate) => candidate.dataset.threadDisplayUnit === anchor.key);
|
||||||
|
if (!element) {
|
||||||
|
historyScrollAnchorRef.current = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextOffset =
|
||||||
|
element.getBoundingClientRect().top
|
||||||
|
- scroller.getBoundingClientRect().top;
|
||||||
|
const delta = nextOffset - anchor.offsetTop;
|
||||||
|
if (Math.abs(delta) < 0.5) return true;
|
||||||
|
const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
|
||||||
|
const nextTop = Math.min(maxScrollTop, Math.max(0, scroller.scrollTop + delta));
|
||||||
|
threadMotionRef.current?.jumpTo(nextTop);
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const scrollToBottomNow = useCallback((smooth = false) => {
|
const scrollToBottomNow = useCallback((smooth = false) => {
|
||||||
|
historyScrollAnchorRef.current = null;
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
const marker = bottomRef.current;
|
const marker = bottomRef.current;
|
||||||
const behavior: ScrollBehavior = smooth ? "smooth" : "auto";
|
const behavior: ScrollBehavior = smooth ? "smooth" : "auto";
|
||||||
@@ -328,11 +422,15 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const loadEarlierMessages = useCallback(() => {
|
const loadEarlierMessages = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (el) {
|
if (el) {
|
||||||
|
if (captureHistoryScrollAnchor()) {
|
||||||
|
restoreScrollAfterPrependRef.current = null;
|
||||||
|
} else {
|
||||||
restoreScrollAfterPrependRef.current = {
|
restoreScrollAfterPrependRef.current = {
|
||||||
height: el.scrollHeight,
|
height: el.scrollHeight,
|
||||||
top: el.scrollTop,
|
top: el.scrollTop,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
threadMotionRef.current?.takeUserControl();
|
threadMotionRef.current?.takeUserControl();
|
||||||
setAtBottom(false);
|
setAtBottom(false);
|
||||||
if (hiddenMessageCount > 0) {
|
if (hiddenMessageCount > 0) {
|
||||||
@@ -345,13 +443,20 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
setVisibleMessageCount((count) => count + HISTORY_WINDOW_INCREMENT);
|
setVisibleMessageCount((count) => count + HISTORY_WINDOW_INCREMENT);
|
||||||
void onLoadOlder();
|
void onLoadOlder();
|
||||||
}
|
}
|
||||||
}, [hasMoreBefore, hiddenMessageCount, loadingOlder, messages.length, onLoadOlder]);
|
}, [
|
||||||
|
captureHistoryScrollAnchor,
|
||||||
|
hasMoreBefore,
|
||||||
|
hiddenMessageCount,
|
||||||
|
loadingOlder,
|
||||||
|
messages.length,
|
||||||
|
onLoadOlder,
|
||||||
|
]);
|
||||||
|
|
||||||
const maybeLoadEarlierFromScroll = useCallback(() => {
|
const maybeLoadEarlierFromScroll = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el || !hasMessages || pendingConversationScrollRef.current) return;
|
if (!el || !hasMessages || pendingConversationScrollRef.current) return;
|
||||||
if (!threadMotionRef.current?.isBrowsingHistory()) return;
|
if (!threadMotionRef.current?.isBrowsingHistory()) return;
|
||||||
if (el.scrollTop > NEAR_TOP_PX) return;
|
if (el.scrollTop > historyPrefetchDistance(el)) return;
|
||||||
if (hiddenMessageCount <= 0 && !hasMoreBefore) return;
|
if (hiddenMessageCount <= 0 && !hasMoreBefore) return;
|
||||||
loadEarlierMessages();
|
loadEarlierMessages();
|
||||||
}, [hasMessages, hasMoreBefore, hiddenMessageCount, loadEarlierMessages]);
|
}, [hasMessages, hasMoreBefore, hiddenMessageCount, loadEarlierMessages]);
|
||||||
@@ -360,6 +465,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
const prompt = scrollEl ? findPromptElement(scrollEl, promptId) : null;
|
const prompt = scrollEl ? findPromptElement(scrollEl, promptId) : null;
|
||||||
if (!scrollEl || !prompt) return false;
|
if (!scrollEl || !prompt) return false;
|
||||||
|
historyScrollAnchorRef.current = null;
|
||||||
setAtBottom(false);
|
setAtBottom(false);
|
||||||
const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
|
const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
|
||||||
threadMotionRef.current?.navigateHistoryTo(
|
threadMotionRef.current?.navigateHistoryTo(
|
||||||
@@ -442,6 +548,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
conversationHandoffAnimationRef.current = null;
|
conversationHandoffAnimationRef.current = null;
|
||||||
conversationHandoffPendingRef.current = true;
|
conversationHandoffPendingRef.current = true;
|
||||||
pendingConversationScrollRef.current = true;
|
pendingConversationScrollRef.current = true;
|
||||||
|
historyScrollAnchorRef.current = null;
|
||||||
|
restoreScrollAfterPrependRef.current = null;
|
||||||
threadMotionRef.current?.reset();
|
threadMotionRef.current?.reset();
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
||||||
@@ -505,17 +613,18 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const pending = restoreScrollAfterPrependRef.current;
|
const pending = restoreScrollAfterPrependRef.current;
|
||||||
if (!pending) return;
|
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
restoreScrollAfterPrependRef.current = null;
|
restoreScrollAfterPrependRef.current = null;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
if (reconcileHistoryScrollAnchor()) return;
|
||||||
|
if (!pending) return;
|
||||||
const delta = el.scrollHeight - pending.height;
|
const delta = el.scrollHeight - pending.height;
|
||||||
const nextTop = Math.min(
|
const nextTop = Math.min(
|
||||||
Math.max(0, el.scrollHeight - el.clientHeight),
|
Math.max(0, el.scrollHeight - el.clientHeight),
|
||||||
Math.max(0, pending.top + delta),
|
Math.max(0, pending.top + delta),
|
||||||
);
|
);
|
||||||
threadMotionRef.current?.jumpTo(nextTop);
|
threadMotionRef.current?.jumpTo(nextTop);
|
||||||
}, [visibleMessages.length, messages.length]);
|
}, [reconcileHistoryScrollAnchor, visibleMessages.length, messages.length]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const promptId = pendingPromptJumpRef.current;
|
const promptId = pendingPromptJumpRef.current;
|
||||||
@@ -593,6 +702,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
threadMotionRef.current?.invalidateGeometry();
|
threadMotionRef.current?.invalidateGeometry();
|
||||||
};
|
};
|
||||||
const reconcileObservedGeometry = () => {
|
const reconcileObservedGeometry = () => {
|
||||||
|
reconcileHistoryScrollAnchor();
|
||||||
threadMotionRef.current?.reconcileObservedGeometry();
|
threadMotionRef.current?.reconcileObservedGeometry();
|
||||||
};
|
};
|
||||||
reconcileObservedGeometry();
|
reconcileObservedGeometry();
|
||||||
@@ -609,7 +719,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
observer?.disconnect();
|
observer?.disconnect();
|
||||||
window.removeEventListener("resize", invalidateGeometry);
|
window.removeEventListener("resize", invalidateGeometry);
|
||||||
};
|
};
|
||||||
}, [hasMessages]);
|
}, [hasMessages, reconcileHistoryScrollAnchor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
@@ -623,7 +733,12 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
setAtBottom((current) =>
|
setAtBottom((current) =>
|
||||||
current === logicallyAtBottom ? current : logicallyAtBottom,
|
current === logicallyAtBottom ? current : logicallyAtBottom,
|
||||||
);
|
);
|
||||||
if (allowHistoryLoad && owner === "user") maybeLoadEarlierFromScroll();
|
if (owner === "user") {
|
||||||
|
captureHistoryScrollAnchor();
|
||||||
|
if (allowHistoryLoad) maybeLoadEarlierFromScroll();
|
||||||
|
} else if (near) {
|
||||||
|
historyScrollAnchorRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onScroll(false);
|
onScroll(false);
|
||||||
@@ -709,7 +824,12 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
el.removeEventListener("pointerdown", handlePointerDown);
|
el.removeEventListener("pointerdown", handlePointerDown);
|
||||||
el.removeEventListener("keydown", handleKeyDown);
|
el.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [hasMessages, maybeLoadEarlierFromScroll, yieldCameraToUser]);
|
}, [
|
||||||
|
captureHistoryScrollAnchor,
|
||||||
|
hasMessages,
|
||||||
|
maybeLoadEarlierFromScroll,
|
||||||
|
yieldCameraToUser,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="thread-viewport relative flex min-h-0 flex-1 overflow-hidden">
|
<div className="thread-viewport relative flex min-h-0 flex-1 overflow-hidden">
|
||||||
@@ -744,8 +864,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
ref={messageRegionRef}
|
ref={messageRegionRef}
|
||||||
data-testid="thread-message-region"
|
data-testid="thread-message-region"
|
||||||
className={cn(
|
className={cn(
|
||||||
"thread-viewport-scrollbar row-start-1 flex min-h-0 min-w-0 flex-col",
|
"thread-message-viewport thread-viewport-scrollbar row-start-1 flex min-h-0 min-w-0 flex-col",
|
||||||
"scroll-auto justify-start overflow-x-hidden px-3 pb-4 pt-4 sm:px-4",
|
"scroll-auto justify-start overflow-x-hidden px-3 pb-0 pt-3 sm:px-4",
|
||||||
"[overflow-anchor:none] [scrollbar-width:none]",
|
"[overflow-anchor:none] [scrollbar-width:none]",
|
||||||
"[&::-webkit-scrollbar]:hidden",
|
"[&::-webkit-scrollbar]:hidden",
|
||||||
hasVerticalOverflow ? "overflow-y-auto" : "overflow-hidden",
|
hasVerticalOverflow ? "overflow-y-auto" : "overflow-hidden",
|
||||||
@@ -768,6 +888,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
onQuoteSelection={onQuoteSelection}
|
onQuoteSelection={onQuoteSelection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div aria-hidden className="thread-message-end-gap shrink-0" />
|
||||||
<div ref={bottomRef} aria-hidden className="h-px shrink-0" />
|
<div ref={bottomRef} aria-hidden className="h-px shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -808,7 +929,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"row-start-2 z-10 w-full",
|
"row-start-2 z-10 w-full",
|
||||||
hasMessages ? "relative bg-background" : "relative self-center",
|
hasMessages ? "thread-composer-dock relative" : "relative self-center",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -840,7 +961,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
|
className="pointer-events-none absolute inset-x-0 top-0 h-3 bg-gradient-to-b from-background to-transparent"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hasMessages ? (
|
{hasMessages ? (
|
||||||
|
|||||||
@@ -377,6 +377,7 @@
|
|||||||
* bottom without a remount or a transform clone.
|
* bottom without a remount or a transform clone.
|
||||||
*/
|
*/
|
||||||
.thread-layout {
|
.thread-layout {
|
||||||
|
--thread-composer-fade-height: 2.25rem;
|
||||||
grid-template-rows: minmax(min-content, 1fr) auto 0fr;
|
grid-template-rows: minmax(min-content, 1fr) auto 0fr;
|
||||||
transition: grid-template-rows 220ms ease-out;
|
transition: grid-template-rows 220ms ease-out;
|
||||||
}
|
}
|
||||||
@@ -392,6 +393,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the transcript and composer visually continuous. The scrollport owns
|
||||||
|
* the fade because overflow clips its contents before an adjacent overlay
|
||||||
|
* can soften the edge. The matching in-flow gap keeps the last line fully
|
||||||
|
* visible once the user reaches the bottom.
|
||||||
|
*/
|
||||||
|
.thread-message-viewport {
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
#000 0,
|
||||||
|
#000 calc(100% - var(--thread-composer-fade-height)),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
#000 0,
|
||||||
|
#000 calc(100% - var(--thread-composer-fade-height)),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.thread-message-end-gap {
|
||||||
|
height: var(--thread-composer-fade-height);
|
||||||
|
}
|
||||||
|
.thread-composer-dock {
|
||||||
|
background: hsl(var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes composer-status-strip-enter {
|
@keyframes composer-status-strip-enter {
|
||||||
0% {
|
0% {
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
|
|||||||
@@ -1196,6 +1196,7 @@
|
|||||||
"modelNotConfigured": "Model not configured",
|
"modelNotConfigured": "Model not configured",
|
||||||
"configureModel": "Configure model",
|
"configureModel": "Configure model",
|
||||||
"switchModel": "Switch model for this chat",
|
"switchModel": "Switch model for this chat",
|
||||||
|
"manageModels": "Manage models",
|
||||||
"context": {
|
"context": {
|
||||||
"tooltip": "Context · {{tokens}}{{capacity}}",
|
"tooltip": "Context · {{tokens}}{{capacity}}",
|
||||||
"meterDescription": "{{context}}. {{percent}}% used."
|
"meterDescription": "{{context}}. {{percent}}% used."
|
||||||
|
|||||||
@@ -1183,6 +1183,7 @@
|
|||||||
"modelNotConfigured": "Modelo no configurado",
|
"modelNotConfigured": "Modelo no configurado",
|
||||||
"configureModel": "Configurar modelo",
|
"configureModel": "Configurar modelo",
|
||||||
"switchModel": "Cambiar el modelo de este chat",
|
"switchModel": "Cambiar el modelo de este chat",
|
||||||
|
"manageModels": "Gestionar modelos",
|
||||||
"context": {
|
"context": {
|
||||||
"tooltip": "Contexto · {{tokens}}{{capacity}}",
|
"tooltip": "Contexto · {{tokens}}{{capacity}}",
|
||||||
"meterDescription": "{{context}}. {{percent}} % usado."
|
"meterDescription": "{{context}}. {{percent}} % usado."
|
||||||
|
|||||||
@@ -1182,6 +1182,7 @@
|
|||||||
"modelNotConfigured": "Modèle non configuré",
|
"modelNotConfigured": "Modèle non configuré",
|
||||||
"configureModel": "Configurer le modèle",
|
"configureModel": "Configurer le modèle",
|
||||||
"switchModel": "Changer le modèle de cette conversation",
|
"switchModel": "Changer le modèle de cette conversation",
|
||||||
|
"manageModels": "Gérer les modèles",
|
||||||
"context": {
|
"context": {
|
||||||
"tooltip": "Contexte · {{tokens}}{{capacity}}",
|
"tooltip": "Contexte · {{tokens}}{{capacity}}",
|
||||||
"meterDescription": "{{context}}. {{percent}} % utilisé."
|
"meterDescription": "{{context}}. {{percent}} % utilisé."
|
||||||
|
|||||||
@@ -1182,6 +1182,7 @@
|
|||||||
"modelNotConfigured": "Model belum dikonfigurasi",
|
"modelNotConfigured": "Model belum dikonfigurasi",
|
||||||
"configureModel": "Konfigurasi model",
|
"configureModel": "Konfigurasi model",
|
||||||
"switchModel": "Ganti model untuk percakapan ini",
|
"switchModel": "Ganti model untuk percakapan ini",
|
||||||
|
"manageModels": "Kelola model",
|
||||||
"context": {
|
"context": {
|
||||||
"tooltip": "Konteks · {{tokens}}{{capacity}}",
|
"tooltip": "Konteks · {{tokens}}{{capacity}}",
|
||||||
"meterDescription": "{{context}}. {{percent}}% digunakan."
|
"meterDescription": "{{context}}. {{percent}}% digunakan."
|
||||||
|
|||||||
@@ -1182,6 +1182,7 @@
|
|||||||
"modelNotConfigured": "モデルが未設定です",
|
"modelNotConfigured": "モデルが未設定です",
|
||||||
"configureModel": "モデルを設定",
|
"configureModel": "モデルを設定",
|
||||||
"switchModel": "この会話で使うモデルを切り替える",
|
"switchModel": "この会話で使うモデルを切り替える",
|
||||||
|
"manageModels": "モデルを管理",
|
||||||
"context": {
|
"context": {
|
||||||
"tooltip": "コンテキスト · {{tokens}}{{capacity}}",
|
"tooltip": "コンテキスト · {{tokens}}{{capacity}}",
|
||||||
"meterDescription": "{{context}}。{{percent}}% 使用中"
|
"meterDescription": "{{context}}。{{percent}}% 使用中"
|
||||||
|
|||||||
@@ -1182,6 +1182,7 @@
|
|||||||
"modelNotConfigured": "모델이 설정되지 않음",
|
"modelNotConfigured": "모델이 설정되지 않음",
|
||||||
"configureModel": "모델 설정",
|
"configureModel": "모델 설정",
|
||||||
"switchModel": "이 대화에서 사용할 모델 전환",
|
"switchModel": "이 대화에서 사용할 모델 전환",
|
||||||
|
"manageModels": "모델 관리",
|
||||||
"context": {
|
"context": {
|
||||||
"tooltip": "컨텍스트 · {{tokens}}{{capacity}}",
|
"tooltip": "컨텍스트 · {{tokens}}{{capacity}}",
|
||||||
"meterDescription": "{{context}}. {{percent}}% 사용 중."
|
"meterDescription": "{{context}}. {{percent}}% 사용 중."
|
||||||
|
|||||||
@@ -1196,6 +1196,7 @@
|
|||||||
"modelNotConfigured": "Modelo não configurado",
|
"modelNotConfigured": "Modelo não configurado",
|
||||||
"configureModel": "Configurar modelo",
|
"configureModel": "Configurar modelo",
|
||||||
"switchModel": "Alternar o modelo desta conversa",
|
"switchModel": "Alternar o modelo desta conversa",
|
||||||
|
"manageModels": "Gerenciar modelos",
|
||||||
"context": {
|
"context": {
|
||||||
"tooltip": "Contexto · {{tokens}}{{capacity}}",
|
"tooltip": "Contexto · {{tokens}}{{capacity}}",
|
||||||
"meterDescription": "{{context}}. {{percent}}% usado."
|
"meterDescription": "{{context}}. {{percent}}% usado."
|
||||||
|
|||||||
@@ -1182,6 +1182,7 @@
|
|||||||
"modelNotConfigured": "Chưa cấu hình mô hình",
|
"modelNotConfigured": "Chưa cấu hình mô hình",
|
||||||
"configureModel": "Cấu hình mô hình",
|
"configureModel": "Cấu hình mô hình",
|
||||||
"switchModel": "Chuyển mô hình cho cuộc trò chuyện này",
|
"switchModel": "Chuyển mô hình cho cuộc trò chuyện này",
|
||||||
|
"manageModels": "Quản lý mô hình",
|
||||||
"context": {
|
"context": {
|
||||||
"tooltip": "Ngữ cảnh · {{tokens}}{{capacity}}",
|
"tooltip": "Ngữ cảnh · {{tokens}}{{capacity}}",
|
||||||
"meterDescription": "{{context}}. Đã dùng {{percent}}%."
|
"meterDescription": "{{context}}. Đã dùng {{percent}}%."
|
||||||
|
|||||||
@@ -1195,6 +1195,7 @@
|
|||||||
"modelNotConfigured": "模型未配置",
|
"modelNotConfigured": "模型未配置",
|
||||||
"configureModel": "配置模型",
|
"configureModel": "配置模型",
|
||||||
"switchModel": "切换本次对话所用模型",
|
"switchModel": "切换本次对话所用模型",
|
||||||
|
"manageModels": "管理模型预设",
|
||||||
"context": {
|
"context": {
|
||||||
"tooltip": "上下文 · {{tokens}}{{capacity}}",
|
"tooltip": "上下文 · {{tokens}}{{capacity}}",
|
||||||
"meterDescription": "{{context}}。已使用 {{percent}}%。"
|
"meterDescription": "{{context}}。已使用 {{percent}}%。"
|
||||||
|
|||||||
@@ -1182,6 +1182,7 @@
|
|||||||
"modelNotConfigured": "尚未設定模型",
|
"modelNotConfigured": "尚未設定模型",
|
||||||
"configureModel": "設定模型",
|
"configureModel": "設定模型",
|
||||||
"switchModel": "切換此對話使用的模型",
|
"switchModel": "切換此對話使用的模型",
|
||||||
|
"manageModels": "管理模型預設",
|
||||||
"context": {
|
"context": {
|
||||||
"tooltip": "上下文 · {{tokens}}{{capacity}}",
|
"tooltip": "上下文 · {{tokens}}{{capacity}}",
|
||||||
"meterDescription": "{{context}}。已使用 {{percent}}%。"
|
"meterDescription": "{{context}}。已使用 {{percent}}%。"
|
||||||
|
|||||||
@@ -561,6 +561,69 @@ describe("AgentActivityCluster", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a completed file edit at its original position in the turn", () => {
|
||||||
|
const before: UIMessage = {
|
||||||
|
id: "model-before-edit",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Before the edit",
|
||||||
|
activityKind: "model",
|
||||||
|
createdAt: 1,
|
||||||
|
};
|
||||||
|
const after: UIMessage = {
|
||||||
|
id: "model-after-edit",
|
||||||
|
role: "assistant",
|
||||||
|
content: "After the edit",
|
||||||
|
activityKind: "model",
|
||||||
|
createdAt: 3,
|
||||||
|
};
|
||||||
|
const fileEdit = (status: "editing" | "done"): UIMessage => ({
|
||||||
|
id: "file-edit-in-place",
|
||||||
|
role: "tool",
|
||||||
|
kind: "trace",
|
||||||
|
content: "edit_file()",
|
||||||
|
traces: ["edit_file()"],
|
||||||
|
fileEdits: [{
|
||||||
|
call_id: "call-edit-in-place",
|
||||||
|
tool: "edit_file",
|
||||||
|
path: "src/app.tsx",
|
||||||
|
phase: status === "editing" ? "start" : "end",
|
||||||
|
added: status === "editing" ? 0 : 2,
|
||||||
|
deleted: 0,
|
||||||
|
approximate: false,
|
||||||
|
status,
|
||||||
|
}],
|
||||||
|
createdAt: 2,
|
||||||
|
});
|
||||||
|
const assertBetween = (middle: HTMLElement) => {
|
||||||
|
const beforeElement = screen.getByText("Before the edit");
|
||||||
|
const afterElement = screen.getByText("After the edit");
|
||||||
|
expect(beforeElement.compareDocumentPosition(middle) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
|
.toBeTruthy();
|
||||||
|
expect(middle.compareDocumentPosition(afterElement) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
|
.toBeTruthy();
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<AgentActivityCluster
|
||||||
|
messages={[before, fileEdit("editing"), after]}
|
||||||
|
isTurnStreaming
|
||||||
|
hasBodyBelow={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertBetween(screen.getByText("Editing"));
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AgentActivityCluster
|
||||||
|
messages={[before, fileEdit("done"), after]}
|
||||||
|
isTurnStreaming
|
||||||
|
hasBodyBelow={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertBetween(screen.getByText("Edited"));
|
||||||
|
});
|
||||||
|
|
||||||
it("renders file edit diffs and responds to preference changes", () => {
|
it("renders file edit diffs and responds to preference changes", () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"nanobot-webui.settings-preferences",
|
"nanobot-webui.settings-preferences",
|
||||||
|
|||||||
@@ -322,7 +322,10 @@ const MODEL_PRESETS = [
|
|||||||
{ name: "dspro", model: "deepseek/deepseek-v4-pro", provider: "deepseek" },
|
{ name: "dspro", model: "deepseek/deepseek-v4-pro", provider: "deepseek" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function renderPresetComposer(variant: "thread" | "hero" = "thread") {
|
function renderPresetComposer(
|
||||||
|
variant: "thread" | "hero" = "thread",
|
||||||
|
onManageModels?: () => void,
|
||||||
|
) {
|
||||||
const onPresetChange = vi.fn();
|
const onPresetChange = vi.fn();
|
||||||
render(
|
render(
|
||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
@@ -332,6 +335,7 @@ function renderPresetComposer(variant: "thread" | "hero" = "thread") {
|
|||||||
modelProvider="moonshot"
|
modelProvider="moonshot"
|
||||||
modelPresets={MODEL_PRESETS}
|
modelPresets={MODEL_PRESETS}
|
||||||
onModelPresetChange={onPresetChange}
|
onModelPresetChange={onPresetChange}
|
||||||
|
onManageModels={onManageModels}
|
||||||
placeholder={variant === "hero" ? "Ask anything..." : "Type your message..."}
|
placeholder={variant === "hero" ? "Ask anything..." : "Type your message..."}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
/>,
|
/>,
|
||||||
@@ -610,6 +614,18 @@ describe("ThreadComposer", () => {
|
|||||||
expect(badge).toHaveClass("w-fit");
|
expect(badge).toHaveClass("w-fit");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens model settings from the picker footer", async () => {
|
||||||
|
const onManageModels = vi.fn();
|
||||||
|
const { badge } = renderPresetComposer("thread", onManageModels);
|
||||||
|
|
||||||
|
fireEvent.click(badge);
|
||||||
|
const picker = screen.getByRole("dialog", { name: "Switch model for this chat" });
|
||||||
|
fireEvent.click(within(picker).getByRole("button", { name: "Manage models" }));
|
||||||
|
|
||||||
|
expect(onManageModels).toHaveBeenCalledTimes(1);
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps long-press drag switching alongside the click picker", () => {
|
it("keeps long-press drag switching alongside the click picker", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const { badge, onPresetChange } = renderPresetComposer();
|
const { badge, onPresetChange } = renderPresetComposer();
|
||||||
|
|||||||
@@ -417,6 +417,52 @@ describe("ThreadShell", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("moves the session handle into the pane only when the workbench is split", () => {
|
||||||
|
const client = makeClient();
|
||||||
|
const portal = document.createElement("div");
|
||||||
|
document.body.append(portal);
|
||||||
|
const activeSession = {
|
||||||
|
...session("pane-handle"),
|
||||||
|
handle: {
|
||||||
|
id: "handle_11111111111111111111111111111111",
|
||||||
|
name: "soro",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { unmount } = render(wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={activeSession}
|
||||||
|
title="Single pane"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
hideHeaderTitle
|
||||||
|
headerPortalTarget={portal}
|
||||||
|
/>,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(within(portal).getByText("@soro")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("Session @soro")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
const splitView = render(wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={activeSession}
|
||||||
|
title="Split pane"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
hideHeaderTitle
|
||||||
|
inlineHandle
|
||||||
|
headerPortalTarget={portal}
|
||||||
|
/>,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Session @soro")).toHaveTextContent("@soro");
|
||||||
|
expect(within(portal).queryByText("@soro")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
splitView.unmount();
|
||||||
|
portal.remove();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps inferred file paths non-interactive when the availability probe fails", async () => {
|
it("keeps inferred file paths non-interactive when the availability probe fails", async () => {
|
||||||
await preloadMarkdownText();
|
await preloadMarkdownText();
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
|
|||||||
@@ -123,6 +123,25 @@ function stubResizeObserver() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stubElementsFromPoint(resolve: () => Element[]) {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(document, "elementsFromPoint");
|
||||||
|
const mock = vi.fn(resolve);
|
||||||
|
Object.defineProperty(document, "elementsFromPoint", {
|
||||||
|
configurable: true,
|
||||||
|
value: mock,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
mock,
|
||||||
|
restore: () => {
|
||||||
|
if (descriptor) {
|
||||||
|
Object.defineProperty(document, "elementsFromPoint", descriptor);
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(document, "elementsFromPoint");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function makeLongMessages(count: number): UIMessage[] {
|
function makeLongMessages(count: number): UIMessage[] {
|
||||||
return Array.from({ length: count }, (_, index) => ({
|
return Array.from({ length: count }, (_, index) => ({
|
||||||
id: `m${index}`,
|
id: `m${index}`,
|
||||||
@@ -259,7 +278,9 @@ describe("ThreadViewport", () => {
|
|||||||
const messageRegion = screen.getByTestId("thread-message-region");
|
const messageRegion = screen.getByTestId("thread-message-region");
|
||||||
expect(messageRegion).toHaveClass("justify-start");
|
expect(messageRegion).toHaveClass("justify-start");
|
||||||
expect(messageRegion).not.toHaveClass("justify-end");
|
expect(messageRegion).not.toHaveClass("justify-end");
|
||||||
expect(messageRegion).toHaveClass("pb-4");
|
expect(messageRegion).toHaveClass("thread-message-viewport");
|
||||||
|
expect(messageRegion).toHaveClass("pt-3");
|
||||||
|
expect(messageRegion).toHaveClass("pb-0");
|
||||||
expect(messageRegion.className).not.toContain("5rem");
|
expect(messageRegion.className).not.toContain("5rem");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -316,7 +337,9 @@ describe("ThreadViewport", () => {
|
|||||||
expect(scroller).not.toContainElement(composerDock);
|
expect(scroller).not.toContainElement(composerDock);
|
||||||
expect(scroller.parentElement).toContainElement(composerDock);
|
expect(scroller.parentElement).toContainElement(composerDock);
|
||||||
expect(composerDock).toHaveClass("relative");
|
expect(composerDock).toHaveClass("relative");
|
||||||
|
expect(composerDock).toHaveClass("thread-composer-dock");
|
||||||
expect(composerDock).not.toHaveClass("sticky");
|
expect(composerDock).not.toHaveClass("sticky");
|
||||||
|
expect(scroller.querySelector(".thread-message-end-gap")).toBeInTheDocument();
|
||||||
expect(scroller.lastElementChild).toHaveClass("h-px", "shrink-0");
|
expect(scroller.lastElementChild).toHaveClass("h-px", "shrink-0");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1477,6 +1500,108 @@ describe("ThreadViewport", () => {
|
|||||||
expect(screen.getAllByText("message 299").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("message 299").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefetches earlier history within half a viewport of the top", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ThreadViewport
|
||||||
|
messages={makeLongMessages(300)}
|
||||||
|
isStreaming={false}
|
||||||
|
composer={<div />}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const scroller = getScroller(container);
|
||||||
|
Object.defineProperties(scroller, {
|
||||||
|
scrollHeight: { configurable: true, value: 2400 },
|
||||||
|
clientHeight: { configurable: true, value: 600 },
|
||||||
|
scrollTop: { configurable: true, writable: true, value: 301 },
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchUserScroll(scroller);
|
||||||
|
});
|
||||||
|
expect(screen.queryByText("message 139")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
scroller.scrollTop = 250;
|
||||||
|
act(() => {
|
||||||
|
dispatchUserScroll(scroller);
|
||||||
|
});
|
||||||
|
expect(screen.getByText("message 20")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("message 19")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the first visible history item fixed while deferred rows materialize", () => {
|
||||||
|
const resizeObserver = stubResizeObserver();
|
||||||
|
let hitTarget: Element | null = null;
|
||||||
|
const hitTest = stubElementsFromPoint(() => hitTarget ? [hitTarget] : []);
|
||||||
|
try {
|
||||||
|
const { container } = render(
|
||||||
|
<ThreadViewport
|
||||||
|
messages={makeLongMessages(300)}
|
||||||
|
isStreaming={false}
|
||||||
|
composer={<div />}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const scroller = getScroller(container);
|
||||||
|
let scrollHeight = 2_400;
|
||||||
|
Object.defineProperties(scroller, {
|
||||||
|
scrollHeight: { configurable: true, get: () => scrollHeight },
|
||||||
|
clientHeight: { configurable: true, value: 600 },
|
||||||
|
scrollTop: { configurable: true, writable: true, value: 80 },
|
||||||
|
getBoundingClientRect: {
|
||||||
|
configurable: true,
|
||||||
|
value: () => DOMRect.fromRect({ y: 0, width: 800, height: 600 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const anchor = screen.getByText("message 140")
|
||||||
|
.closest<HTMLElement>("[data-thread-display-unit]");
|
||||||
|
expect(anchor).not.toBeNull();
|
||||||
|
hitTarget = anchor;
|
||||||
|
let anchorDocumentTop = 200;
|
||||||
|
Object.defineProperty(anchor, "getBoundingClientRect", {
|
||||||
|
configurable: true,
|
||||||
|
value: () => DOMRect.fromRect({
|
||||||
|
y: anchorDocumentTop - scroller.scrollTop,
|
||||||
|
width: 800,
|
||||||
|
height: 40,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchUserScroll(scroller);
|
||||||
|
});
|
||||||
|
expect(hitTest.mock).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const replacement = anchor.cloneNode(true) as HTMLElement;
|
||||||
|
anchor.replaceWith(replacement);
|
||||||
|
anchorDocumentTop += 180;
|
||||||
|
scrollHeight += 180;
|
||||||
|
Object.defineProperty(replacement, "getBoundingClientRect", {
|
||||||
|
configurable: true,
|
||||||
|
value: () => DOMRect.fromRect({
|
||||||
|
y: anchorDocumentTop - scroller.scrollTop,
|
||||||
|
width: 800,
|
||||||
|
height: 40,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const content = screen.getByTestId("thread-message-region").firstElementChild;
|
||||||
|
const observer = resizeObserver.observers.find((candidate) =>
|
||||||
|
content ? candidate.elements.includes(content) : false,
|
||||||
|
);
|
||||||
|
expect(observer).toBeDefined();
|
||||||
|
act(() => {
|
||||||
|
observer?.callback([], observer as unknown as ResizeObserver);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scroller.scrollTop).toBe(260);
|
||||||
|
expect(replacement.getBoundingClientRect().top).toBe(120);
|
||||||
|
} finally {
|
||||||
|
hitTest.restore();
|
||||||
|
resizeObserver.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("automatically requests older transcript pages near the top", () => {
|
it("automatically requests older transcript pages near the top", () => {
|
||||||
const onLoadOlder = vi.fn();
|
const onLoadOlder = vi.fn();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user