mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abb905e4b7 | ||
|
|
9fd2ada894 |
@@ -146,7 +146,7 @@ Activate it with `source .venv/bin/activate` on macOS/Linux or
|
||||
python -m pip install -e .
|
||||
```
|
||||
|
||||
After that, the normal commands are identical to a stable install. `nanobot` runs the TUI
|
||||
After that, the normal commands are identical to a stable install. `nanobot agent` runs the TUI
|
||||
from this checkout, and `nanobot webui` rebuilds stale frontend assets automatically. A later
|
||||
`git pull --ff-only` updates the Python, TUI, and WebUI source together; rerun
|
||||
`python -m pip install -e .` when Python dependencies change. Contributors should also read
|
||||
@@ -206,10 +206,10 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
|
||||
**Prefer to work entirely in the terminal?**
|
||||
|
||||
```bash
|
||||
nanobot
|
||||
nanobot agent
|
||||
```
|
||||
|
||||
This opens the native terminal client with the launch directory as its workspace. It shares saved conversations and the local gateway with the WebUI. The explicit `nanobot agent` form remains available for compatibility.
|
||||
This opens the native terminal client with the launch directory as its workspace. It shares saved conversations and the local gateway with the WebUI.
|
||||
|
||||
- Type `/` to discover commands, `/sessions` to switch conversations, or `@` to mention an app, MCP server, or saved session.
|
||||
- Press `Enter` to send. While nanobot is working, `Enter` sends now and `Tab` sends after the current response. Press `Shift+Enter` to add a newline (`Ctrl+J` works in terminals that cannot distinguish modified Enter keys).
|
||||
@@ -220,7 +220,7 @@ Each launch starts a new session by default. Use `--session` to resume one and `
|
||||
For one request and an immediate exit, use:
|
||||
|
||||
```bash
|
||||
nanobot -m "Hello!"
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
The one-shot form is useful for a quick provider check, shell scripts, and local automation. If you have not configured a model yet, run `nanobot webui` and open **Settings → Models** first.
|
||||
|
||||
+13
-13
@@ -12,8 +12,8 @@ Use this page when you know what you want to run and need the command shape. For
|
||||
| Use guided setup | `nanobot onboard --wizard` | Best when you prefer prompts over hand-editing JSON |
|
||||
| Open the browser workbench | `nanobot webui` | Prepares local WebUI settings, starts the gateway, and opens the browser |
|
||||
| Check readiness without calling a model | `nanobot status` | Summarizes config/workspace and validates the active provider/model configuration |
|
||||
| Send one test message | `nanobot -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
|
||||
| Chat in the terminal | `nanobot` | Interactive local chat; `nanobot agent` remains an explicit alias |
|
||||
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
|
||||
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` |
|
||||
| Run the gateway directly | `nanobot gateway` | Service/ops command for WebUI, chat apps, cron, and heartbeat |
|
||||
| Deliver a local trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` in the target chat/session |
|
||||
| Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` |
|
||||
@@ -86,15 +86,15 @@ follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` r
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `nanobot -m "Hello!"` | Send one message and exit |
|
||||
| `nanobot` | Start interactive terminal chat |
|
||||
| `nanobot --session <id>` | Use a WebSocket session key; add `--classic` for another channel |
|
||||
| `nanobot --workspace <path>` | Override workspace |
|
||||
| `nanobot --config <path>` | Use a specific config file |
|
||||
| `nanobot --classic` | Use the classic Python prompt instead of the native terminal UI |
|
||||
| `nanobot --theme auto\|dark\|light` | Auto-detect the terminal appearance or force a TUI palette |
|
||||
| `nanobot --no-markdown` | Use the classic prompt and print plain text instead of Markdown |
|
||||
| `nanobot --logs` | Use the classic prompt and show runtime logs while chatting |
|
||||
| `nanobot agent -m "Hello!"` | Send one message and exit |
|
||||
| `nanobot agent` | Start interactive terminal chat |
|
||||
| `nanobot agent --session <id>` | Use a WebSocket session key; add `--classic` for another channel |
|
||||
| `nanobot agent --workspace <path>` | Override workspace |
|
||||
| `nanobot agent --config <path>` | Use a specific config file |
|
||||
| `nanobot agent --classic` | Use the classic Python prompt instead of the native terminal UI |
|
||||
| `nanobot agent --theme auto\|dark\|light` | Auto-detect the terminal appearance or force a TUI palette |
|
||||
| `nanobot agent --no-markdown` | Use the classic prompt and print plain text instead of Markdown |
|
||||
| `nanobot agent --logs` | Use the classic prompt and show runtime logs while chatting |
|
||||
|
||||
Inside the native TUI, `/sessions` switches saved conversations, `/new-chat` starts another saved
|
||||
conversation, and `/context` explains the compacted summary and raw session suffix available to
|
||||
@@ -139,7 +139,7 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, open `http://127.0.0.1:8765`, and follow new gateway logs |
|
||||
| `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, and open `http://127.0.0.1:8765` |
|
||||
| `nanobot webui --background` | Deprecated; prints the equivalent explicit `nanobot gateway --background` command and exits |
|
||||
| `nanobot webui --dev` | Start the gateway and Vite together at `http://127.0.0.1:5173`, with live frontend updates |
|
||||
| `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser |
|
||||
@@ -344,7 +344,7 @@ remain accepted as no-op compatibility aliases.
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `nanobot provider login openai-codex --set-main` | Authenticate Codex and select its current default model |
|
||||
| `nanobot provider login xai-grok --set-main` | Authenticate an eligible X Premium / Grok subscription and select Grok 4.6; hosted X Search is enabled for models that advertise support |
|
||||
| `nanobot provider login xai-grok --set-main` | Authenticate an eligible X Premium / Grok subscription and select Grok 4.5; hosted X Search is enabled for models that advertise support |
|
||||
| `nanobot provider login github-copilot --set-main` | Authenticate GitHub Copilot and select its current default model |
|
||||
| `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state |
|
||||
| `nanobot provider logout xai-grok --config <path>` | Remove the selected nanobot instance's xAI OAuth state |
|
||||
|
||||
+5
-17
@@ -729,11 +729,6 @@ Then run:
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
The WebUI model selector loads the models available to the signed-in account
|
||||
from Codex's online catalog. Context-window and reasoning-effort metadata come
|
||||
from that response; if discovery is unavailable, nanobot keeps a small built-in
|
||||
fallback instead of emptying the selector.
|
||||
|
||||
Codex Fast mode can be enabled from the WebUI provider settings, or with:
|
||||
|
||||
```json
|
||||
@@ -769,14 +764,11 @@ nanobot provider login xai-grok --set-main
|
||||
nanobot agent -m "Hello from Grok."
|
||||
```
|
||||
|
||||
The default model is `xai-grok/grok-4.6` with a 500,000-token context window.
|
||||
The provider reads and caches xAI's online model catalog for both WebUI model
|
||||
selection and runtime capabilities. Newly available models appear automatically;
|
||||
when discovery fails, the last successful catalog or built-in fallback remains
|
||||
available. The server-hosted `x_search` tool is included only when the selected
|
||||
model advertises support. Models without that capability continue normally
|
||||
without hosted X Search. When enabled, searches run inside xAI's Responses API
|
||||
and citations arrive as inline links.
|
||||
The default model is `xai-grok/grok-4.5` with a 500,000-token context window.
|
||||
The provider reads xAI's model catalog and includes the server-hosted `x_search`
|
||||
tool only when the selected model advertises `supportsBackendSearch`. Models
|
||||
without that capability continue normally without hosted X Search. When enabled,
|
||||
searches run inside xAI's Responses API and citations arrive as inline links.
|
||||
Hosted X Search is on by default to preserve this behavior. It can be turned off in the
|
||||
WebUI provider settings or with `providers.xaiGrok.extraBody.tools: []`.
|
||||
|
||||
@@ -813,10 +805,6 @@ a nanobot update.
|
||||
|
||||
GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.github_copilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
|
||||
|
||||
After login, the WebUI loads the account-specific Copilot model catalog online.
|
||||
Only models compatible with nanobot's current chat-completions or Responses
|
||||
transport are shown.
|
||||
|
||||
For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login:
|
||||
```bash
|
||||
export NANOBOT_GITHUB_COPILOT_CLIENT_ID="your-enterprise-client-id"
|
||||
|
||||
+3
-15
@@ -572,23 +572,15 @@ For OpenAI Codex:
|
||||
nanobot provider login openai-codex --set-main
|
||||
```
|
||||
|
||||
The WebUI reads the account's Codex model catalog online, including current
|
||||
context-window and reasoning-effort metadata. A small compatible catalog remains
|
||||
available when the service cannot be reached.
|
||||
|
||||
For an eligible X Premium / Grok subscription:
|
||||
|
||||
```bash
|
||||
nanobot provider login xai-grok --set-main
|
||||
```
|
||||
|
||||
This selects `xai-grok/grok-4.6`. The WebUI model selector reads xAI's online
|
||||
model catalog, so newly available subscription models appear without a nanobot
|
||||
release. Online metadata is cached and enriched with nanobot's curated labels;
|
||||
if xAI is temporarily unavailable, nanobot uses the last successful catalog or
|
||||
a small built-in fallback instead of emptying the selector. The same catalog
|
||||
controls whether the provider exposes the hosted `x_search` tool; models that do
|
||||
not advertise support continue without hosted X Search.
|
||||
This selects `xai-grok/grok-4.5`. The provider reads xAI's model catalog and
|
||||
exposes the hosted `x_search` tool only when the selected model advertises
|
||||
`supportsBackendSearch`; otherwise the model runs without hosted X Search.
|
||||
When enabled, Grok can search current X posts and return inline source links
|
||||
without invoking a local nanobot tool. Credentials are stored under the
|
||||
active instance's `auth/xai.json` (normally `~/.nanobot/auth/xai.json`), not in
|
||||
@@ -607,10 +599,6 @@ For GitHub Copilot:
|
||||
nanobot provider login github-copilot --set-main
|
||||
```
|
||||
|
||||
The WebUI reads the models enabled for the signed-in Copilot account. nanobot
|
||||
lists entries that support its current Copilot chat-completions or Responses
|
||||
transport and hides models that it cannot route safely.
|
||||
|
||||
Each command authenticates the selected provider and makes its current default model active. OpenAI Codex and eligible GitHub Copilot models participate in [Responses state retention](./configuration.md#responses-state-and-compaction), while native compaction remains provider-capability-specific. OAuth providers are not valid automatic fallbacks. See [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems) for proxy, headless-login, model-name, and config-key errors.
|
||||
|
||||
## Provider Resolution
|
||||
|
||||
+3
-3
@@ -103,13 +103,13 @@ Use `nanobot gateway logs`, `restart`, and `stop` to manage that background gate
|
||||
If you do not want the browser or need to isolate a WebUI problem, send one message directly:
|
||||
|
||||
```bash
|
||||
nanobot -m "Hello!"
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
Then start an interactive terminal chat with:
|
||||
|
||||
```bash
|
||||
nanobot
|
||||
nanobot agent
|
||||
```
|
||||
|
||||
In interactive mode, `Enter` sends and `Shift+Enter` inserts a newline (`Ctrl+J` is the
|
||||
@@ -173,7 +173,7 @@ nanobot webui
|
||||
```
|
||||
|
||||
The source path follows current `main` and can be newer than the published package. The editable
|
||||
install keeps Python pointed at the checkout; `nanobot` runs `tui/` with Bun, and
|
||||
install keeps Python pointed at the checkout; `nanobot agent` runs `tui/` with Bun, and
|
||||
`nanobot webui` automatically rebuilds `webui/` when its bundled assets are stale. All normal
|
||||
commands remain the same as a stable install. For development details, follow
|
||||
[`../CONTRIBUTING.md`](../CONTRIBUTING.md).
|
||||
|
||||
+1
-3
@@ -23,9 +23,7 @@ one is missing, starts or joins the same on-demand gateway used by the native
|
||||
TUI, and opens the browser. With a fresh config,
|
||||
it can open before a model is configured so you can finish setup in **Settings
|
||||
→ Models**. The first-run path binds the WebUI to `127.0.0.1` by default, so
|
||||
it is not available from other devices on your LAN. While the launcher remains
|
||||
attached, it mirrors new log output from that exact gateway instance in the
|
||||
terminal without replaying older logs.
|
||||
it is not available from other devices on your LAN.
|
||||
|
||||
After model setup, explicitly promote the shared gateway when you do not want to keep a client open:
|
||||
|
||||
|
||||
+20
-71
@@ -75,23 +75,6 @@ class PersistedPromptContextResolver:
|
||||
return channel, scope.project_path
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TranscriptInput:
|
||||
"""Raw turn inputs from which ``ContextBuilder`` assembles a transcript."""
|
||||
|
||||
history: list[dict[str, Any]]
|
||||
current_message: str | None
|
||||
media: Sequence[str] | None = None
|
||||
current_role: str = "user"
|
||||
session_summary: SessionSummary | None = None
|
||||
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None
|
||||
|
||||
@property
|
||||
def message_count(self) -> int:
|
||||
"""Number of boundary-preserving messages in the assembled transcript."""
|
||||
return 1 + len(self.history) + (self.current_message is not None)
|
||||
|
||||
|
||||
class ContextBuilder:
|
||||
"""Builds the context (system prompt + messages) for the agent."""
|
||||
|
||||
@@ -299,58 +282,14 @@ class ContextBuilder:
|
||||
session_key: str | None = None,
|
||||
unified_session: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Compatibility wrapper for callers that need merged adjacent roles."""
|
||||
messages = self.build_transcript(
|
||||
TranscriptInput(
|
||||
history=history,
|
||||
current_message=current_message,
|
||||
media=media,
|
||||
current_role=current_role,
|
||||
session_summary=session_summary,
|
||||
runtime_context_blocks=runtime_context_blocks,
|
||||
),
|
||||
channel=channel,
|
||||
workspace=workspace,
|
||||
include_memory=include_memory,
|
||||
include_memory_recent_history=include_memory_recent_history,
|
||||
session_key=session_key,
|
||||
unified_session=unified_session,
|
||||
)
|
||||
current = messages[-1]
|
||||
if len(messages) < 2 or messages[-2].get("role") != current.get("role"):
|
||||
return messages
|
||||
|
||||
merged = dict(messages[-2])
|
||||
merged["content"] = self._merge_message_content(
|
||||
merged.get("content"),
|
||||
current.get("content"),
|
||||
)
|
||||
current_meta = current.get("_meta")
|
||||
if current.get("role") == "user" and isinstance(current_meta, dict):
|
||||
internal_meta = dict(merged.get("_meta") or {})
|
||||
internal_meta.update(cast(dict[str, Any], current_meta))
|
||||
merged["_meta"] = internal_meta
|
||||
return [*messages[:-2], merged]
|
||||
|
||||
def build_transcript(
|
||||
self,
|
||||
transcript: TranscriptInput,
|
||||
*,
|
||||
channel: str | None = None,
|
||||
workspace: Path | None = None,
|
||||
include_memory: bool = True,
|
||||
include_memory_recent_history: bool = True,
|
||||
session_key: str | None = None,
|
||||
unified_session: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build a model transcript while preserving the fresh-turn boundary."""
|
||||
"""Build the complete message list for an LLM call."""
|
||||
root = workspace or self.workspace
|
||||
messages: list[dict[str, Any]] = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": self.build_system_prompt(
|
||||
channel=channel,
|
||||
session_summary=transcript.session_summary,
|
||||
session_summary=session_summary,
|
||||
workspace=root,
|
||||
include_memory=include_memory,
|
||||
include_memory_recent_history=include_memory_recent_history,
|
||||
@@ -358,17 +297,27 @@ class ContextBuilder:
|
||||
unified_session=unified_session,
|
||||
),
|
||||
},
|
||||
*transcript.history,
|
||||
*history,
|
||||
]
|
||||
if transcript.current_message is None:
|
||||
return messages
|
||||
|
||||
current = self.build_current_message(
|
||||
transcript.current_message,
|
||||
media=list(transcript.media) if transcript.media else None,
|
||||
current_role=transcript.current_role,
|
||||
runtime_context_blocks=transcript.runtime_context_blocks,
|
||||
current_message,
|
||||
media=media,
|
||||
current_role=current_role,
|
||||
runtime_context_blocks=runtime_context_blocks,
|
||||
)
|
||||
if messages[-1].get("role") == current_role:
|
||||
last = dict(messages[-1])
|
||||
last["content"] = self._merge_message_content(
|
||||
last.get("content"),
|
||||
current.get("content"),
|
||||
)
|
||||
current_meta = current.get("_meta")
|
||||
if current_role == "user" and isinstance(current_meta, dict):
|
||||
internal_meta = dict(last.get("_meta") or {})
|
||||
internal_meta.update(cast(dict[str, Any], current_meta))
|
||||
last["_meta"] = internal_meta
|
||||
messages[-1] = last
|
||||
return messages
|
||||
messages.append(current)
|
||||
return messages
|
||||
|
||||
|
||||
+16
-22
@@ -14,7 +14,6 @@ from collections.abc import Coroutine, Iterable, Mapping
|
||||
from contextlib import AbstractContextManager, ExitStack, nullcontext, suppress
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar, cast
|
||||
|
||||
@@ -24,7 +23,7 @@ from nanobot.agent import context as agent_context
|
||||
from nanobot.agent import model_presets as preset_helpers
|
||||
from nanobot.agent.autocompact import AutoCompact
|
||||
from nanobot.agent.automation_turns import publish_next_deferred_turn
|
||||
from nanobot.agent.context import ContextBuilder, PersistedPromptContextResolver, TranscriptInput
|
||||
from nanobot.agent.context import ContextBuilder, PersistedPromptContextResolver
|
||||
from nanobot.agent.cron_turns import CronTurnCoordinator
|
||||
from nanobot.agent.hook import AgentHook, AgentTurnHookFactory
|
||||
from nanobot.agent.memory import Consolidator
|
||||
@@ -136,7 +135,7 @@ class TurnContext:
|
||||
session: Session | None = None
|
||||
|
||||
history: list[dict[str, Any]] = field(default_factory=list)
|
||||
transcript_input: TranscriptInput | None = None
|
||||
initial_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||
provider_state: ProviderConversationState | None = field(default=None, repr=False)
|
||||
request_context: RequestContext | None = None
|
||||
runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list)
|
||||
@@ -724,15 +723,22 @@ class AgentLoop:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _build_transcript_input(self, ctx: TurnContext) -> TranscriptInput:
|
||||
"""Capture the persisted history and fresh input as separate transcript parts."""
|
||||
def _build_initial_messages(self, ctx: TurnContext) -> list[dict[str, Any]]:
|
||||
"""Build the initial message list for the LLM turn."""
|
||||
assert ctx.session is not None
|
||||
return TranscriptInput(
|
||||
scope = self.workspace_scopes.for_message(ctx.msg, ctx.session.metadata)
|
||||
return self.context.build_messages(
|
||||
history=ctx.history,
|
||||
current_message=ctx.msg.content,
|
||||
media=ctx.msg.media if ctx.kind is TurnKind.USER and ctx.msg.media else None,
|
||||
channel=ctx.delivery.route.channel,
|
||||
session_summary=ctx.pending_summary,
|
||||
workspace=scope.project_path,
|
||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||
include_memory=ctx.session.policy.persist,
|
||||
include_memory_recent_history=not ctx.ephemeral,
|
||||
session_key=ctx.session.key,
|
||||
unified_session=self._unified_session,
|
||||
)
|
||||
|
||||
def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext:
|
||||
@@ -923,7 +929,7 @@ class AgentLoop:
|
||||
|
||||
async def _run_agent_loop(
|
||||
self,
|
||||
transcript_input: TranscriptInput,
|
||||
initial_messages: list[dict[str, Any]],
|
||||
on_progress: Callable[..., Awaitable[None]] | None = None,
|
||||
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
||||
@@ -1104,15 +1110,6 @@ class AgentLoop:
|
||||
message_metadata=request_metadata,
|
||||
session_metadata=session.metadata if session is not None else None,
|
||||
)
|
||||
transcript_builder = partial(
|
||||
self.context.build_transcript,
|
||||
channel=request_ctx.channel,
|
||||
workspace=effective_scope.project_path,
|
||||
include_memory=session.policy.persist if session is not None else True,
|
||||
include_memory_recent_history=not ephemeral,
|
||||
session_key=session.key if session is not None else request_ctx.session_key,
|
||||
unified_session=self._unified_session,
|
||||
)
|
||||
if request_context is None:
|
||||
request_ctx = dataclasses.replace(
|
||||
request_ctx,
|
||||
@@ -1159,13 +1156,11 @@ class AgentLoop:
|
||||
run_extra_hooks_for_ephemeral=run_extra_hooks_for_ephemeral,
|
||||
))
|
||||
result = await self.runner.run(AgentRunSpec(
|
||||
initial_messages=None,
|
||||
initial_messages=initial_messages,
|
||||
tools=effective_tools,
|
||||
runtime=runtime,
|
||||
max_iterations=self.max_iterations,
|
||||
max_tool_result_chars=self.max_tool_result_chars,
|
||||
transcript_input=transcript_input,
|
||||
transcript_builder=transcript_builder,
|
||||
hook=hook,
|
||||
concurrent_tools=True,
|
||||
workspace=effective_scope.project_path,
|
||||
@@ -1973,7 +1968,7 @@ class AgentLoop:
|
||||
# Upgrade the replay-safe baseline to the resumable state before
|
||||
# prompt assembly and the first model checkpoint.
|
||||
self.sessions.save(session)
|
||||
ctx.transcript_input = self._build_transcript_input(ctx)
|
||||
ctx.initial_messages = self._build_initial_messages(ctx)
|
||||
|
||||
if ctx.on_progress is None:
|
||||
ctx.on_progress = ctx.delivery.progress_callback()
|
||||
@@ -1985,10 +1980,9 @@ class AgentLoop:
|
||||
if ctx.visible_run_started_at is None:
|
||||
ctx.visible_run_started_at = time.time()
|
||||
await ctx.delivery.running(started_at=ctx.visible_run_started_at)
|
||||
assert ctx.transcript_input is not None
|
||||
with capture_message_deliveries() as message_sends:
|
||||
result = await self._run_agent_loop(
|
||||
ctx.transcript_input,
|
||||
ctx.initial_messages,
|
||||
runtime=runtime,
|
||||
on_progress=ctx.on_progress,
|
||||
on_stream=ctx.on_stream,
|
||||
|
||||
+8
-48
@@ -14,7 +14,6 @@ from typing import Any, cast
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.context_governance import (
|
||||
ContextGovernanceConfig,
|
||||
ContextGovernor,
|
||||
@@ -67,7 +66,6 @@ ContinuationCallback = Callable[[], str | None]
|
||||
RetryWaitCallback = Callable[[str], Awaitable[None]]
|
||||
CheckpointCallback = Callable[[dict[str, Any]], Awaitable[None]]
|
||||
InjectionCallback = Callable[..., Awaitable[Iterable[Any] | None]]
|
||||
TranscriptBuilder = Callable[[TranscriptInput], list[dict[str, Any]]]
|
||||
|
||||
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
|
||||
_ARREARAGE_ERROR_MESSAGE = (
|
||||
@@ -96,13 +94,11 @@ def _restore_outer_whitespace(content: str, original: str | None) -> str:
|
||||
class AgentRunSpec:
|
||||
"""Configuration for a single agent execution."""
|
||||
|
||||
initial_messages: list[dict[str, Any]] | None
|
||||
initial_messages: list[dict[str, Any]]
|
||||
tools: ToolRegistry
|
||||
runtime: LLMRuntime
|
||||
max_iterations: int
|
||||
max_tool_result_chars: int
|
||||
transcript_input: TranscriptInput | None = None
|
||||
transcript_builder: TranscriptBuilder | None = None
|
||||
hook: AgentHook | None = None
|
||||
error_message: str | None = _DEFAULT_ERROR_MESSAGE
|
||||
max_iterations_message: str | None = None
|
||||
@@ -414,7 +410,7 @@ class AgentRunner:
|
||||
|
||||
async def run(self, spec: AgentRunSpec) -> AgentRunResult:
|
||||
hook = spec.hook or AgentHook()
|
||||
messages = self._initial_transcript(spec)
|
||||
messages = list(spec.initial_messages)
|
||||
context = AgentRunHookContext(messages=deepcopy(messages))
|
||||
llm_usage_source_token = bind_llm_usage_source(
|
||||
spec.llm_usage_source or source_from_session_key(spec.session_key)
|
||||
@@ -466,19 +462,6 @@ class AgentRunner:
|
||||
finally:
|
||||
reset_llm_usage_source(llm_usage_source_token)
|
||||
|
||||
@staticmethod
|
||||
def _initial_transcript(spec: AgentRunSpec) -> list[dict[str, Any]]:
|
||||
"""Resolve exactly one supported source for the initial model transcript."""
|
||||
if spec.transcript_input is not None:
|
||||
if spec.initial_messages is not None:
|
||||
raise ValueError("provide either transcript_input or initial_messages, not both")
|
||||
if spec.transcript_builder is None:
|
||||
raise ValueError("transcript_builder is required with transcript_input")
|
||||
return list(spec.transcript_builder(spec.transcript_input))
|
||||
if spec.initial_messages is None:
|
||||
raise ValueError("initial_messages is required without transcript_input")
|
||||
return list(spec.initial_messages)
|
||||
|
||||
async def _run_core(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
@@ -519,7 +502,7 @@ class AgentRunner:
|
||||
context_window_tokens=spec.runtime.context_window_tokens,
|
||||
context_block_limit=spec.context_block_limit,
|
||||
max_tokens=spec.runtime.generation.max_tokens,
|
||||
inflight_start_index=len(messages),
|
||||
inflight_start_index=len(spec.initial_messages),
|
||||
)
|
||||
|
||||
for iteration in range(spec.max_iterations):
|
||||
@@ -966,7 +949,6 @@ class AgentRunner:
|
||||
|
||||
active_hosted_tools: dict[str, dict[str, Any]] = {}
|
||||
native_reasoning_open = False
|
||||
native_reasoning_close_task: asyncio.Task[None] | None = None
|
||||
request_started_at = 0.0
|
||||
first_output_at: float | None = None
|
||||
generation_started_at: float | None = None
|
||||
@@ -990,29 +972,11 @@ class AgentRunner:
|
||||
generation_started_at = None
|
||||
|
||||
async def _close_native_reasoning() -> None:
|
||||
nonlocal native_reasoning_open, native_reasoning_close_task
|
||||
if native_reasoning_close_task is None:
|
||||
if not native_reasoning_open:
|
||||
return
|
||||
native_reasoning_open = False
|
||||
native_reasoning_close_task = asyncio.create_task(
|
||||
hook.emit_reasoning_end()
|
||||
)
|
||||
|
||||
close_task = native_reasoning_close_task
|
||||
cancellation: asyncio.CancelledError | None = None
|
||||
while not close_task.done():
|
||||
try:
|
||||
await asyncio.shield(close_task)
|
||||
except asyncio.CancelledError as exc:
|
||||
cancellation = cancellation or exc
|
||||
try:
|
||||
close_task.result()
|
||||
finally:
|
||||
if native_reasoning_close_task is close_task:
|
||||
native_reasoning_close_task = None
|
||||
if cancellation is not None:
|
||||
raise cancellation
|
||||
nonlocal native_reasoning_open
|
||||
if not native_reasoning_open:
|
||||
return
|
||||
native_reasoning_open = False
|
||||
await hook.emit_reasoning_end()
|
||||
|
||||
async def _provider_tool_event(event: dict[str, Any]) -> None:
|
||||
if event.get("kind") != "hosted_tool":
|
||||
@@ -1087,10 +1051,6 @@ class AgentRunner:
|
||||
await coro if outer_timeout_s is None
|
||||
else await asyncio.wait_for(coro, timeout=outer_timeout_s)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
_pause_generation()
|
||||
await _close_native_reasoning()
|
||||
raise
|
||||
except asyncio.TimeoutError:
|
||||
if outer_timeout_s is None:
|
||||
response = LLMResponse(
|
||||
|
||||
@@ -43,13 +43,6 @@ _WORKSPACE_VIOLATION_MARKERS: tuple[str, ...] = (
|
||||
)
|
||||
|
||||
|
||||
def _with_retry_hint(payload: str) -> str:
|
||||
"""Append the recovery hint exactly once."""
|
||||
if payload.endswith(_RETRY_HINT):
|
||||
return payload
|
||||
return payload + _RETRY_HINT
|
||||
|
||||
|
||||
async def execute_tool_calls(
|
||||
tools: ToolRegistry,
|
||||
tool_calls: list[ToolCallRequest],
|
||||
@@ -112,7 +105,7 @@ async def _execute_tool_call(
|
||||
"status": "error",
|
||||
"detail": "repeated external lookup blocked",
|
||||
}
|
||||
return _with_retry_hint(lookup_error), event
|
||||
return lookup_error + _RETRY_HINT, event
|
||||
|
||||
prepare_call = cast(
|
||||
Callable[[str, Any], object] | None,
|
||||
@@ -126,7 +119,6 @@ async def _execute_tool_call(
|
||||
if len(prepared_tuple) == 3:
|
||||
tool, params, prep_error = cast(tuple[Any, Any, str | None], prepared_tuple)
|
||||
if prep_error:
|
||||
payload = _with_retry_hint(prep_error)
|
||||
event = {
|
||||
"name": tool_call.name,
|
||||
"status": "error",
|
||||
@@ -134,14 +126,14 @@ async def _execute_tool_call(
|
||||
}
|
||||
handled = _classify_violation(
|
||||
raw_text=prep_error,
|
||||
soft_payload=payload,
|
||||
soft_payload=prep_error + _RETRY_HINT,
|
||||
event=event,
|
||||
tool_call=tool_call,
|
||||
workspace_violation_counts=workspace_violation_counts,
|
||||
)
|
||||
if handled is not None:
|
||||
return handled
|
||||
return payload, event
|
||||
return prep_error + _RETRY_HINT, event
|
||||
|
||||
await hook.before_execute_tool(context, tool_call, tool, params)
|
||||
try:
|
||||
@@ -158,9 +150,10 @@ async def _execute_tool_call(
|
||||
"status": "error",
|
||||
"detail": str(exc),
|
||||
}
|
||||
payload = _with_retry_hint(f"Error: {type(exc).__name__}: {exc}")
|
||||
payload = f"Error: {type(exc).__name__}: {exc}"
|
||||
handled = _classify_violation(
|
||||
raw_text=str(exc),
|
||||
# Preserve legacy exception payloads without the retry hint.
|
||||
soft_payload=payload,
|
||||
event=event,
|
||||
tool_call=tool_call,
|
||||
@@ -172,7 +165,6 @@ async def _execute_tool_call(
|
||||
|
||||
if is_tool_error_result(result):
|
||||
await hook.on_execute_tool_error(context, tool_call, tool, params, result)
|
||||
payload = _with_retry_hint(result)
|
||||
event = {
|
||||
"name": tool_call.name,
|
||||
"status": "error",
|
||||
@@ -180,14 +172,14 @@ async def _execute_tool_call(
|
||||
}
|
||||
handled = _classify_violation(
|
||||
raw_text=result,
|
||||
soft_payload=payload,
|
||||
soft_payload=result + _RETRY_HINT,
|
||||
event=event,
|
||||
tool_call=tool_call,
|
||||
workspace_violation_counts=workspace_violation_counts,
|
||||
)
|
||||
if handled is not None:
|
||||
return handled
|
||||
return payload, event
|
||||
return result + _RETRY_HINT, event
|
||||
|
||||
await hook.after_execute_tool(context, tool_call, tool, params, result)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from collections import OrderedDict, deque
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
@@ -127,7 +127,7 @@ class SendSessionMessageTool(Tool):
|
||||
self._max_messages_per_minute = max_messages_per_minute
|
||||
self._schedule_later = schedule_later
|
||||
self._clock = clock or time.monotonic
|
||||
self._sent_at: OrderedDict[str, deque[float]] = OrderedDict()
|
||||
self._sent_at: dict[str, deque[float]] = {}
|
||||
self._pending_replies: dict[tuple[str, str], _PendingReply] = {}
|
||||
self._expiry_tasks: set[asyncio.Task[None]] = set()
|
||||
self._send_lock = asyncio.Lock()
|
||||
@@ -240,11 +240,8 @@ class SendSessionMessageTool(Tool):
|
||||
|
||||
async with self._send_lock:
|
||||
now = self._clock()
|
||||
sent_at = self._sent_at.setdefault(source.session_key, deque())
|
||||
cutoff = now - _RATE_LIMIT_WINDOW_SECONDS
|
||||
self._prune_expired_rate_limits(cutoff)
|
||||
sent_at = self._sent_at.get(source.session_key)
|
||||
if sent_at is None:
|
||||
sent_at = deque[float]()
|
||||
while sent_at and sent_at[0] <= cutoff:
|
||||
sent_at.popleft()
|
||||
if len(sent_at) >= self._max_messages_per_minute:
|
||||
@@ -262,8 +259,6 @@ class SendSessionMessageTool(Tool):
|
||||
input_role="user",
|
||||
))
|
||||
sent_at.append(now)
|
||||
self._sent_at[source.session_key] = sent_at
|
||||
self._sent_at.move_to_end(source.session_key)
|
||||
self._cancel_pending_reply(reverse_wait_key)
|
||||
if timeout_seconds is not None:
|
||||
self._cancel_pending_reply(wait_key)
|
||||
@@ -276,14 +271,6 @@ class SendSessionMessageTool(Tool):
|
||||
|
||||
return f"@{target.name}"
|
||||
|
||||
def _prune_expired_rate_limits(self, cutoff: float) -> None:
|
||||
"""Drop sources ordered by their most recent successful send."""
|
||||
while self._sent_at:
|
||||
_, sent_at = next(iter(self._sent_at.items()))
|
||||
if sent_at[-1] > cutoff:
|
||||
return
|
||||
self._sent_at.popitem(last=False)
|
||||
|
||||
@staticmethod
|
||||
def _validate_reply_timeout(
|
||||
expect_reply: bool,
|
||||
|
||||
@@ -182,12 +182,6 @@ class NanobotDingTalkHandler(_CallbackHandlerBase):
|
||||
)
|
||||
)
|
||||
|
||||
if not self.channel._accepting_inbound_tasks:
|
||||
self.channel.logger.debug(
|
||||
"Skipping DingTalk inbound dispatch during channel shutdown"
|
||||
)
|
||||
return AckMessage.STATUS_OK, "OK"
|
||||
|
||||
self.channel.logger.info("Received message from {} ({}): {}", sender_name, sender_id, content)
|
||||
|
||||
# Forward to Nanobot via _on_message (non-blocking).
|
||||
@@ -202,7 +196,7 @@ class NanobotDingTalkHandler(_CallbackHandlerBase):
|
||||
)
|
||||
)
|
||||
self.channel._background_tasks.add(task)
|
||||
task.add_done_callback(self.channel._on_background_task_done)
|
||||
task.add_done_callback(self.channel._background_tasks.discard)
|
||||
|
||||
return AckMessage.STATUS_OK, "OK"
|
||||
|
||||
@@ -262,17 +256,6 @@ class DingTalkChannel(BaseChannel):
|
||||
|
||||
# Hold references to background tasks to prevent GC
|
||||
self._background_tasks: set[asyncio.Task[None]] = set()
|
||||
self._accepting_inbound_tasks = True
|
||||
|
||||
def _on_background_task_done(self, task: asyncio.Task[None]) -> None:
|
||||
self._background_tasks.discard(task)
|
||||
if task.cancelled():
|
||||
return
|
||||
exception = task.exception()
|
||||
if exception is not None:
|
||||
self.logger.opt(exception=exception).error(
|
||||
"DingTalk inbound message task failed"
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the DingTalk bot with Stream Mode."""
|
||||
@@ -289,7 +272,6 @@ class DingTalkChannel(BaseChannel):
|
||||
self.logger.error("client_id and client_secret not configured")
|
||||
return
|
||||
|
||||
self._accepting_inbound_tasks = True
|
||||
self._running = True
|
||||
self._http = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(10.0, connect=10.0, read=30.0, write=30.0, pool=10.0)
|
||||
@@ -327,7 +309,6 @@ class DingTalkChannel(BaseChannel):
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the DingTalk bot."""
|
||||
self._accepting_inbound_tasks = False
|
||||
self._running = False
|
||||
await self._close_stream_client()
|
||||
start_task = self._start_task
|
||||
@@ -345,11 +326,8 @@ class DingTalkChannel(BaseChannel):
|
||||
await self._http.aclose()
|
||||
self._http = None
|
||||
# Cancel outstanding background tasks
|
||||
background_tasks = tuple(self._background_tasks)
|
||||
for task in background_tasks:
|
||||
for task in self._background_tasks:
|
||||
task.cancel()
|
||||
if background_tasks:
|
||||
await asyncio.gather(*background_tasks, return_exceptions=True)
|
||||
self._background_tasks.clear()
|
||||
|
||||
async def _close_stream_client(self) -> None:
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -402,61 +402,6 @@ async def test_handler_uses_voice_recognition_text_when_text_is_empty(monkeypatc
|
||||
assert msg.chat_id == "group:conv123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_retrieves_background_message_failure(monkeypatch) -> None:
|
||||
bus = MessageBus()
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
|
||||
bus,
|
||||
)
|
||||
handler = NanobotDingTalkHandler(channel)
|
||||
failure = RuntimeError("inbound dispatch failed")
|
||||
mock_logger = MagicMock()
|
||||
channel.logger = mock_logger
|
||||
|
||||
class _FakeChatbotMessage:
|
||||
text = SimpleNamespace(content="hello")
|
||||
extensions = {}
|
||||
sender_staff_id = "user1"
|
||||
sender_id = "fallback-user"
|
||||
sender_nick = "Alice"
|
||||
message_type = "text"
|
||||
|
||||
@staticmethod
|
||||
def from_dict(_data):
|
||||
return _FakeChatbotMessage()
|
||||
|
||||
async def fail(*_args) -> None:
|
||||
raise failure
|
||||
|
||||
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", _FakeChatbotMessage)
|
||||
monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
|
||||
monkeypatch.setattr(channel, "_on_message", fail)
|
||||
event_loop = asyncio.get_running_loop()
|
||||
previous_handler = event_loop.get_exception_handler()
|
||||
loop_errors: list[dict[str, object]] = []
|
||||
event_loop.set_exception_handler(lambda _loop, context: loop_errors.append(context))
|
||||
|
||||
try:
|
||||
status, body = await handler.process(
|
||||
SimpleNamespace(data={"conversationType": "1", "text": {"content": "hello"}})
|
||||
)
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
if not channel._background_tasks:
|
||||
break
|
||||
finally:
|
||||
event_loop.set_exception_handler(previous_handler)
|
||||
|
||||
assert (status, body) == ("OK", "OK")
|
||||
assert not channel._background_tasks
|
||||
assert not loop_errors
|
||||
mock_logger.opt.assert_called_once_with(exception=failure)
|
||||
mock_logger.opt.return_value.error.assert_called_once_with(
|
||||
"DingTalk inbound message task failed"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_processes_file_message(monkeypatch) -> None:
|
||||
"""Test that file messages are handled and forwarded with downloaded path."""
|
||||
@@ -506,72 +451,6 @@ async def test_handler_processes_file_message(monkeypatch) -> None:
|
||||
assert "/tmp/nanobot_dingtalk/user1/report.xlsx" in msg.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_does_not_spawn_message_task_after_stop_during_download(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
|
||||
MessageBus(),
|
||||
)
|
||||
handler = NanobotDingTalkHandler(channel)
|
||||
download_started = asyncio.Event()
|
||||
release_download = asyncio.Event()
|
||||
message_task_started = asyncio.Event()
|
||||
|
||||
class _FakeFileChatbotMessage:
|
||||
text = None
|
||||
extensions = {}
|
||||
image_content = None
|
||||
rich_text_content = None
|
||||
sender_staff_id = "user1"
|
||||
sender_id = "fallback-user"
|
||||
sender_nick = "Alice"
|
||||
message_type = "file"
|
||||
|
||||
@staticmethod
|
||||
def from_dict(_data):
|
||||
return _FakeFileChatbotMessage()
|
||||
|
||||
async def delayed_download(*_args):
|
||||
download_started.set()
|
||||
await release_download.wait()
|
||||
return "/tmp/nanobot_dingtalk/user1/report.xlsx"
|
||||
|
||||
async def block_message(*_args) -> None:
|
||||
message_task_started.set()
|
||||
await asyncio.Future()
|
||||
|
||||
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", _FakeFileChatbotMessage)
|
||||
monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
|
||||
monkeypatch.setattr(channel, "_download_dingtalk_file", delayed_download)
|
||||
monkeypatch.setattr(channel, "_on_message", block_message)
|
||||
|
||||
process_task = asyncio.create_task(handler.process(SimpleNamespace(data={
|
||||
"conversationType": "1",
|
||||
"content": {"downloadCode": "abc123", "fileName": "report.xlsx"},
|
||||
"text": {"content": ""},
|
||||
})))
|
||||
await download_started.wait()
|
||||
|
||||
try:
|
||||
await channel.stop()
|
||||
release_download.set()
|
||||
assert await process_task == ("OK", "OK")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert not message_task_started.is_set()
|
||||
assert not channel._background_tasks
|
||||
finally:
|
||||
release_download.set()
|
||||
if not process_task.done():
|
||||
process_task.cancel()
|
||||
pending = tuple(channel._background_tasks)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
await asyncio.gather(process_task, *pending, return_exceptions=True)
|
||||
|
||||
|
||||
def _rich_text_message(rich_text_list):
|
||||
class _FakeRichTextChatbotMessage:
|
||||
text = None
|
||||
@@ -771,41 +650,6 @@ async def test_stop_cancels_stream_client_after_sdk_swallows_first_cancel(monkey
|
||||
assert start_task.cancelled()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_waits_for_background_message_tasks() -> None:
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
mock_logger = MagicMock()
|
||||
channel.logger = mock_logger
|
||||
started = asyncio.Event()
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def wait_forever() -> None:
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Future()
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
task = asyncio.create_task(wait_forever())
|
||||
channel._background_tasks.add(task)
|
||||
task.add_done_callback(channel._on_background_task_done)
|
||||
await started.wait()
|
||||
|
||||
try:
|
||||
await channel.stop()
|
||||
assert task.done()
|
||||
assert cancelled.is_set()
|
||||
assert not channel._background_tasks
|
||||
mock_logger.opt.assert_not_called()
|
||||
finally:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_dingtalk_file(tmp_path, monkeypatch) -> None:
|
||||
"""Test the two-step file download flow (get URL then download content)."""
|
||||
|
||||
@@ -430,13 +430,7 @@ class EmailChannel(BaseChannel):
|
||||
skipped_uids: set[str],
|
||||
cycle_uids: set[str],
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Fetch messages by arbitrary IMAP search criteria.
|
||||
|
||||
Uses UID SEARCH so already-processed UIDs are recognized before any
|
||||
FETCH at all, then fetches headers only to evaluate every filter — the
|
||||
full body (and any attachments) is downloaded only for messages that
|
||||
pass every check and are actually going to be delivered.
|
||||
"""
|
||||
"""Fetch messages by arbitrary IMAP search criteria."""
|
||||
mailbox = self.config.imap_mailbox or "INBOX"
|
||||
|
||||
client = self._open_imap_client(mailbox=mailbox, missing_mailbox_ok=True)
|
||||
@@ -444,30 +438,29 @@ class EmailChannel(BaseChannel):
|
||||
return messages
|
||||
|
||||
try:
|
||||
status, data = client.uid("SEARCH", None, *search_criteria)
|
||||
if status != "OK" or not data or not data[0]:
|
||||
status, data = client.search(None, *search_criteria)
|
||||
if status != "OK" or not data:
|
||||
return messages
|
||||
|
||||
uids = [raw.decode("ascii", errors="ignore") for raw in data[0].split()]
|
||||
if limit > 0 and len(uids) > limit:
|
||||
uids = uids[-limit:]
|
||||
|
||||
features: _ServerFeatures | None = None
|
||||
|
||||
for uid in uids:
|
||||
if not uid or uid in cycle_uids:
|
||||
continue
|
||||
if dedupe and uid in self._processed_uids:
|
||||
continue
|
||||
|
||||
status, fetched = client.uid("FETCH", uid, "(BODY.PEEK[HEADER])")
|
||||
ids = data[0].split()
|
||||
if limit > 0 and len(ids) > limit:
|
||||
ids = ids[-limit:]
|
||||
for imap_id in ids:
|
||||
status, fetched = client.fetch(imap_id, "(BODY.PEEK[] UID)")
|
||||
if status != "OK" or not fetched:
|
||||
continue
|
||||
header_bytes = self._extract_message_bytes(fetched)
|
||||
if header_bytes is None:
|
||||
|
||||
raw_bytes = self._extract_message_bytes(fetched)
|
||||
if raw_bytes is None:
|
||||
continue
|
||||
|
||||
parsed = BytesParser(policy=policy.default).parsebytes(header_bytes)
|
||||
uid = self._extract_uid(fetched)
|
||||
if uid and uid in cycle_uids:
|
||||
continue
|
||||
if dedupe and uid and uid in self._processed_uids:
|
||||
continue
|
||||
|
||||
parsed = BytesParser(policy=policy.default).parsebytes(raw_bytes)
|
||||
sender = parseaddr(parsed.get("From", ""))[1].strip().lower()
|
||||
if not sender:
|
||||
continue
|
||||
@@ -475,8 +468,9 @@ class EmailChannel(BaseChannel):
|
||||
self.logger.info("From {} ignored: matches bot-owned address", sender)
|
||||
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||
if mark_seen:
|
||||
features = self._mark_seen_uid(client, uid, features)
|
||||
skipped_uids.add(uid)
|
||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||
if uid:
|
||||
skipped_uids.add(uid)
|
||||
continue
|
||||
|
||||
# --- Anti-spoofing: verify Authentication-Results ---
|
||||
@@ -488,7 +482,8 @@ class EmailChannel(BaseChannel):
|
||||
sender,
|
||||
)
|
||||
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||
skipped_uids.add(uid)
|
||||
if uid:
|
||||
skipped_uids.add(uid)
|
||||
continue
|
||||
if self.config.verify_dkim and not dkim_pass:
|
||||
self.logger.warning(
|
||||
@@ -497,26 +492,18 @@ class EmailChannel(BaseChannel):
|
||||
sender,
|
||||
)
|
||||
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||
skipped_uids.add(uid)
|
||||
if uid:
|
||||
skipped_uids.add(uid)
|
||||
continue
|
||||
|
||||
if not self.is_allowed(sender):
|
||||
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||
if mark_seen:
|
||||
features = self._mark_seen_uid(client, uid, features)
|
||||
skipped_uids.add(uid)
|
||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||
if uid:
|
||||
skipped_uids.add(uid)
|
||||
continue
|
||||
|
||||
# Passed every filter — only now fetch the full message body
|
||||
# (and any attachments) for the message we're actually delivering.
|
||||
status, full_fetched = client.uid("FETCH", uid, "(BODY.PEEK[])")
|
||||
if status != "OK" or not full_fetched:
|
||||
continue
|
||||
raw_bytes = self._extract_message_bytes(full_fetched)
|
||||
if raw_bytes is None:
|
||||
continue
|
||||
parsed = BytesParser(policy=policy.default).parsebytes(raw_bytes)
|
||||
|
||||
subject = self._decode_header_value(parsed.get("Subject", ""))
|
||||
date_value = parsed.get("Date", "")
|
||||
message_id = parsed.get("Message-ID", "").strip()
|
||||
@@ -569,19 +556,10 @@ class EmailChannel(BaseChannel):
|
||||
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||
|
||||
if mark_seen:
|
||||
features = self._mark_seen_uid(client, uid, features)
|
||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||
finally:
|
||||
self._close_imap_client(client)
|
||||
|
||||
def _mark_seen_uid(
|
||||
self, client: Any, uid: str, features: _ServerFeatures | None
|
||||
) -> _ServerFeatures:
|
||||
"""Mark a single UID \\Seen, reusing session-learned STORE support."""
|
||||
if features is None:
|
||||
features = self._server_features(client)
|
||||
self._uid_store_flag(client, uid, "\\Seen", features)
|
||||
return features
|
||||
|
||||
def _open_imap_client(self, mailbox: str, *, missing_mailbox_ok: bool = False) -> Any | None:
|
||||
if self.config.imap_use_ssl:
|
||||
client: Any = imaplib.IMAP4_SSL(self.config.imap_host, self.config.imap_port)
|
||||
@@ -736,14 +714,11 @@ class EmailChannel(BaseChannel):
|
||||
return data[0].split()[0]
|
||||
|
||||
def _uid_store_deleted(self, client: Any, uid: str, features: _ServerFeatures) -> bool:
|
||||
return self._uid_store_flag(client, uid, "\\Deleted", features)
|
||||
|
||||
def _uid_store_flag(self, client: Any, uid: str, flag: str, features: _ServerFeatures) -> bool:
|
||||
# Optimistic path: try UID STORE first because UID is stable and avoids
|
||||
# sequence-number lookup. If this fails once for the session, remember it
|
||||
# and use the sequence STORE fallback directly for remaining UIDs.
|
||||
if features.uid_store is not False:
|
||||
status, _ = client.uid("STORE", uid, "+FLAGS", f"({flag})")
|
||||
status, _ = client.uid("STORE", uid, "+FLAGS", "(\\Deleted)")
|
||||
if status == "OK":
|
||||
features.uid_store = True
|
||||
return True
|
||||
@@ -753,12 +728,12 @@ class EmailChannel(BaseChannel):
|
||||
# unreliable: resolve the current sequence number from UID and use STORE.
|
||||
imap_id = self._lookup_imap_id_by_uid(client, uid)
|
||||
if not imap_id:
|
||||
self.logger.warning("Could not locate UID {} to set flag {}", uid, flag)
|
||||
self.logger.warning("Post-action skipped: UID {} not found", uid)
|
||||
return False
|
||||
|
||||
status, _ = client.store(imap_id, "+FLAGS", flag)
|
||||
status, _ = client.store(imap_id, "+FLAGS", "\\Deleted")
|
||||
if status != "OK":
|
||||
self.logger.warning("Failed to set flag {} on UID {}", flag, uid)
|
||||
self.logger.warning("Post-action failed: could not mark UID {} as deleted", uid)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -798,6 +773,16 @@ class EmailChannel(BaseChannel):
|
||||
return bytes(fetched_item[1])
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_uid(fetched: list[Any]) -> str:
|
||||
for item in fetched:
|
||||
if isinstance(item, tuple) and item and isinstance(item[0], (bytes, bytearray)):
|
||||
head = bytes(item[0]).decode("utf-8", errors="ignore")
|
||||
m = re.search(r"UID\s+(\d+)", head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _decode_header_value(value: str) -> str:
|
||||
if not value:
|
||||
|
||||
@@ -53,7 +53,30 @@ def _make_raw_email(
|
||||
def test_fetch_new_messages_parses_unseen_and_marks_seen(monkeypatch) -> None:
|
||||
raw = _make_raw_email(subject="Invoice", body="Please pay")
|
||||
|
||||
fake = _make_fake_imap(raw, uid=b"123")
|
||||
class FakeIMAP:
|
||||
def __init__(self) -> None:
|
||||
self.store_calls: list[tuple[bytes, str, str]] = []
|
||||
|
||||
def login(self, _user: str, _pw: str):
|
||||
return "OK", [b"logged in"]
|
||||
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def search(self, *_args):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def fetch(self, _imap_id: bytes, _parts: str):
|
||||
return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
|
||||
|
||||
def store(self, imap_id: bytes, op: str, flags: str):
|
||||
self.store_calls.append((imap_id, op, flags))
|
||||
return "OK", [b""]
|
||||
|
||||
def logout(self):
|
||||
return "BYE", [b""]
|
||||
|
||||
fake = FakeIMAP()
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
channel = EmailChannel(_make_config(), MessageBus())
|
||||
@@ -63,25 +86,38 @@ def test_fetch_new_messages_parses_unseen_and_marks_seen(monkeypatch) -> None:
|
||||
assert items[0]["sender"] == "alice@example.com"
|
||||
assert items[0]["subject"] == "Invoice"
|
||||
assert "Please pay" in items[0]["content"]
|
||||
assert ("STORE", "123", "+FLAGS", "(\\Seen)") in fake.uid_calls
|
||||
assert [call for call in fake.uid_calls if call[0] == "FETCH"] == [
|
||||
("FETCH", "123", "(BODY.PEEK[HEADER])"),
|
||||
("FETCH", "123", "(BODY.PEEK[])"),
|
||||
]
|
||||
assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")]
|
||||
assert skipped_uids == set()
|
||||
|
||||
# Same UID should be deduped in-process.
|
||||
items_again, skipped_again = channel._fetch_new_messages()
|
||||
assert items_again == []
|
||||
assert skipped_again == set()
|
||||
assert len([call for call in fake.uid_calls if call[0] == "FETCH"]) == 2
|
||||
|
||||
|
||||
def test_fetch_new_messages_returns_accepted_and_skipped_uids(monkeypatch) -> None:
|
||||
raw = _make_raw_email(subject="Invoice", body="Please pay")
|
||||
|
||||
fake = _make_fake_imap(raw, uid=b"123")
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
class FakeIMAP:
|
||||
def login(self, _user: str, _pw: str):
|
||||
return "OK", [b"logged in"]
|
||||
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def search(self, *_args):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def fetch(self, _imap_id: bytes, _parts: str):
|
||||
return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
|
||||
|
||||
def store(self, _imap_id: bytes, _op: str, _flags: str):
|
||||
return "OK", [b""]
|
||||
|
||||
def logout(self):
|
||||
return "BYE", [b""]
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: FakeIMAP())
|
||||
|
||||
channel = EmailChannel(_make_config(post_action="delete"), MessageBus())
|
||||
items, skipped_uids = channel._fetch_new_messages()
|
||||
@@ -94,10 +130,26 @@ def test_fetch_new_messages_returns_accepted_and_skipped_uids(monkeypatch) -> No
|
||||
def test_fetch_new_messages_rejected_returns_skipped_uid(monkeypatch) -> None:
|
||||
raw = _make_raw_email(from_addr="Nanobot <bot@example.com>", subject="Loop test")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.email.runtime.imaplib.IMAP4_SSL",
|
||||
lambda _h, _p: _make_fake_imap(raw, uid=b"123"),
|
||||
)
|
||||
class FakeIMAP:
|
||||
def login(self, _user: str, _pw: str):
|
||||
return "OK", [b"logged in"]
|
||||
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def search(self, *_args):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def fetch(self, _imap_id: bytes, _parts: str):
|
||||
return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
|
||||
|
||||
def store(self, _imap_id: bytes, _op: str, _flags: str):
|
||||
return "OK", [b""]
|
||||
|
||||
def logout(self):
|
||||
return "BYE", [b""]
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: FakeIMAP())
|
||||
|
||||
channel_skip = EmailChannel(
|
||||
_make_config(from_address="bot@example.com", post_action="delete", post_action_ignore_skipped=True),
|
||||
@@ -493,7 +545,30 @@ async def test_start_keeps_post_actions_for_successful_emails_when_later_deliver
|
||||
def test_fetch_new_messages_skips_self_sent_email_and_marks_seen(monkeypatch) -> None:
|
||||
raw = _make_raw_email(from_addr="Nanobot <bot@example.com>", subject="Loop test")
|
||||
|
||||
fake = _make_fake_imap(raw, uid=b"123")
|
||||
class FakeIMAP:
|
||||
def __init__(self) -> None:
|
||||
self.store_calls: list[tuple[bytes, str, str]] = []
|
||||
|
||||
def login(self, _user: str, _pw: str):
|
||||
return "OK", [b"logged in"]
|
||||
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def search(self, *_args):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def fetch(self, _imap_id: bytes, _parts: str):
|
||||
return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
|
||||
|
||||
def store(self, imap_id: bytes, op: str, flags: str):
|
||||
self.store_calls.append((imap_id, op, flags))
|
||||
return "OK", [b""]
|
||||
|
||||
def logout(self):
|
||||
return "BYE", [b""]
|
||||
|
||||
fake = FakeIMAP()
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
channel = EmailChannel(_make_config(from_address="bot@example.com"), MessageBus())
|
||||
@@ -501,7 +576,7 @@ def test_fetch_new_messages_skips_self_sent_email_and_marks_seen(monkeypatch) ->
|
||||
|
||||
assert items == []
|
||||
assert skipped_uids == {"123"}
|
||||
assert ("STORE", "123", "+FLAGS", "(\\Seen)") in fake.uid_calls
|
||||
assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")]
|
||||
|
||||
# Same UID should still be deduped after being ignored.
|
||||
items_again, skipped_again = channel._fetch_new_messages()
|
||||
@@ -539,14 +614,37 @@ def test_fetch_new_messages_skips_self_sent_across_identity_sources(
|
||||
imap_username matches, and must be case-insensitive."""
|
||||
raw = _make_raw_email(from_addr=from_header, subject="Loop test")
|
||||
|
||||
fake = _make_fake_imap(raw, uid=b"123")
|
||||
class FakeIMAP:
|
||||
def __init__(self) -> None:
|
||||
self.store_calls: list[tuple[bytes, str, str]] = []
|
||||
|
||||
def login(self, _user: str, _pw: str):
|
||||
return "OK", [b"logged in"]
|
||||
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def search(self, *_args):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def fetch(self, _imap_id: bytes, _parts: str):
|
||||
return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
|
||||
|
||||
def store(self, imap_id: bytes, op: str, flags: str):
|
||||
self.store_calls.append((imap_id, op, flags))
|
||||
return "OK", [b""]
|
||||
|
||||
def logout(self):
|
||||
return "BYE", [b""]
|
||||
|
||||
fake = FakeIMAP()
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
channel = EmailChannel(_make_config(**config_override), MessageBus())
|
||||
items, _ = channel._fetch_new_messages()
|
||||
|
||||
assert items == []
|
||||
assert ("STORE", "123", "+FLAGS", "(\\Seen)") in fake.uid_calls
|
||||
assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")]
|
||||
|
||||
|
||||
def test_fetch_new_messages_retries_once_when_imap_connection_goes_stale(monkeypatch) -> None:
|
||||
@@ -564,16 +662,15 @@ def test_fetch_new_messages_retries_once_when_imap_connection_goes_stale(monkeyp
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command: str, *args):
|
||||
if command == "SEARCH":
|
||||
self.search_calls += 1
|
||||
if fail_once["pending"]:
|
||||
fail_once["pending"] = False
|
||||
raise imaplib.IMAP4.abort("socket error")
|
||||
return "OK", [b"123"]
|
||||
if command == "FETCH":
|
||||
return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
|
||||
return "OK", [b""]
|
||||
def search(self, *_args):
|
||||
self.search_calls += 1
|
||||
if fail_once["pending"]:
|
||||
fail_once["pending"] = False
|
||||
raise imaplib.IMAP4.abort("socket error")
|
||||
return "OK", [b"1"]
|
||||
|
||||
def fetch(self, _imap_id: bytes, _parts: str):
|
||||
return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
|
||||
|
||||
def store(self, imap_id: bytes, op: str, flags: str):
|
||||
self.store_calls.append((imap_id, op, flags))
|
||||
@@ -603,7 +700,10 @@ def test_fetch_new_messages_retries_once_when_imap_connection_goes_stale(monkeyp
|
||||
def test_fetch_new_messages_keeps_messages_collected_before_stale_retry(monkeypatch) -> None:
|
||||
raw_first = _make_raw_email(subject="First", body="First body")
|
||||
raw_second = _make_raw_email(subject="Second", body="Second body")
|
||||
mailbox_state = {"123": raw_first, "124": raw_second}
|
||||
mailbox_state = {
|
||||
b"1": {"uid": b"123", "raw": raw_first, "seen": False},
|
||||
b"2": {"uid": b"124", "raw": raw_second, "seen": False},
|
||||
}
|
||||
fail_once = {"pending": True}
|
||||
|
||||
class FlakyIMAP:
|
||||
@@ -613,18 +713,20 @@ def test_fetch_new_messages_keeps_messages_collected_before_stale_retry(monkeypa
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"2"]
|
||||
|
||||
def uid(self, command: str, *args):
|
||||
if command == "SEARCH":
|
||||
keys = " ".join(sorted(mailbox_state.keys(), key=int))
|
||||
return "OK", [keys.encode()]
|
||||
if command == "FETCH":
|
||||
uid = args[0]
|
||||
if uid == "124" and fail_once["pending"]:
|
||||
fail_once["pending"] = False
|
||||
raise imaplib.IMAP4.abort("socket error")
|
||||
raw = mailbox_state[uid]
|
||||
header = f"{uid} (UID {uid} BODY[] {{200}})".encode()
|
||||
return "OK", [(header, raw), b")"]
|
||||
def search(self, *_args):
|
||||
unseen_ids = [imap_id for imap_id, item in mailbox_state.items() if not item["seen"]]
|
||||
return "OK", [b" ".join(unseen_ids)]
|
||||
|
||||
def fetch(self, imap_id: bytes, _parts: str):
|
||||
if imap_id == b"2" and fail_once["pending"]:
|
||||
fail_once["pending"] = False
|
||||
raise imaplib.IMAP4.abort("socket error")
|
||||
item = mailbox_state[imap_id]
|
||||
header = b"%s (UID %s BODY[] {200})" % (imap_id, item["uid"])
|
||||
return "OK", [(header, item["raw"]), b")"]
|
||||
|
||||
def store(self, imap_id: bytes, _op: str, _flags: str):
|
||||
mailbox_state[imap_id]["seen"] = True
|
||||
return "OK", [b""]
|
||||
|
||||
def logout(self):
|
||||
@@ -942,13 +1044,12 @@ def test_fetch_messages_between_dates_uses_imap_since_before_without_mark_seen(m
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command: str, *args):
|
||||
if command == "SEARCH":
|
||||
self.search_args = args
|
||||
return "OK", [b"999"]
|
||||
if command == "FETCH":
|
||||
return "OK", [(b"5 (UID 999 BODY[] {200})", raw), b")"]
|
||||
return "OK", [b""]
|
||||
def search(self, *_args):
|
||||
self.search_args = _args
|
||||
return "OK", [b"5"]
|
||||
|
||||
def fetch(self, _imap_id: bytes, _parts: str):
|
||||
return "OK", [(b"5 (UID 999 BODY[] {200})", raw), b")"]
|
||||
|
||||
def store(self, imap_id: bytes, op: str, flags: str):
|
||||
self.store_calls.append((imap_id, op, flags))
|
||||
@@ -969,7 +1070,7 @@ def test_fetch_messages_between_dates_uses_imap_since_before_without_mark_seen(m
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0]["subject"] == "Status"
|
||||
# uid("SEARCH", None, "SINCE", "06-Feb-2026", "BEFORE", "07-Feb-2026")
|
||||
# search(None, "SINCE", "06-Feb-2026", "BEFORE", "07-Feb-2026")
|
||||
assert fake.search_args is not None
|
||||
assert fake.search_args[1:] == ("SINCE", "06-Feb-2026", "BEFORE", "07-Feb-2026")
|
||||
assert fake.store_calls == []
|
||||
@@ -979,12 +1080,11 @@ def test_fetch_messages_between_dates_uses_imap_since_before_without_mark_seen(m
|
||||
# Security: Anti-spoofing tests for Authentication-Results verification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_fake_imap(raw: bytes, uid: bytes = b"500"):
|
||||
def _make_fake_imap(raw: bytes):
|
||||
"""Return a FakeIMAP class pre-loaded with the given raw email."""
|
||||
class FakeIMAP:
|
||||
def __init__(self) -> None:
|
||||
self.store_calls: list[tuple[bytes, str, str]] = []
|
||||
self.uid_calls: list[tuple] = []
|
||||
|
||||
def login(self, _user: str, _pw: str):
|
||||
return "OK", [b"logged in"]
|
||||
@@ -992,16 +1092,11 @@ def _make_fake_imap(raw: bytes, uid: bytes = b"500"):
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def capability(self):
|
||||
return "OK", [b"IMAP4rev1"]
|
||||
def search(self, *_args):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command: str, *args):
|
||||
self.uid_calls.append((command, *args))
|
||||
if command == "SEARCH":
|
||||
return "OK", [uid]
|
||||
if command == "FETCH":
|
||||
return "OK", [(b"1 (UID " + uid + b" BODY[] {200})", raw), b")"]
|
||||
return "OK", [b""]
|
||||
def fetch(self, _imap_id: bytes, _parts: str):
|
||||
return "OK", [(b"1 (UID 500 BODY[] {200})", raw), b")"]
|
||||
|
||||
def store(self, imap_id: bytes, op: str, flags: str):
|
||||
self.store_calls.append((imap_id, op, flags))
|
||||
@@ -1197,10 +1292,7 @@ def test_fetch_new_messages_ignores_unauthorized_sender_before_attachments(monke
|
||||
|
||||
assert channel._fetch_new_messages() == ([], {"500"})
|
||||
assert called["attachments"] is False
|
||||
assert [call for call in fake.uid_calls if call[0] == "FETCH"] == [
|
||||
("FETCH", "500", "(BODY.PEEK[HEADER])")
|
||||
]
|
||||
assert ("STORE", "500", "+FLAGS", "(\\Seen)") in fake.uid_calls
|
||||
assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")]
|
||||
|
||||
|
||||
def test_extract_attachments_saves_pdf(tmp_path, monkeypatch) -> None:
|
||||
|
||||
+3
-12
@@ -87,12 +87,7 @@ app = typer.Typer(
|
||||
name="nanobot",
|
||||
context_settings={"help_option_names": ["-h", "--help"]},
|
||||
help=f"{__logo__} nanobot - Personal AI Assistant",
|
||||
epilog=(
|
||||
"Run `nanobot` without a subcommand to start the terminal agent. "
|
||||
"Use `nanobot agent --help` for agent options."
|
||||
),
|
||||
invoke_without_command=True,
|
||||
no_args_is_help=False,
|
||||
no_args_is_help=True,
|
||||
)
|
||||
|
||||
console = Console()
|
||||
@@ -103,7 +98,7 @@ def version_callback(value: bool):
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
@app.callback()
|
||||
def main(
|
||||
ctx: typer.Context,
|
||||
version: bool = typer.Option(
|
||||
@@ -115,11 +110,7 @@ def main(
|
||||
# imports this Typer app directly instead of ``nanobot.cli.entry``. Keep the
|
||||
# role identity correct until that launcher is regenerated.
|
||||
command = ctx.invoked_subcommand
|
||||
set_cli_process_identity([command] if command else ["agent"])
|
||||
if command is None:
|
||||
from nanobot.cli.entry import _run_agent
|
||||
|
||||
_run_agent([], prog_name="nanobot")
|
||||
set_cli_process_identity([command] if command else sys.argv[1:])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
+10
-48
@@ -8,28 +8,6 @@ from contextlib import suppress
|
||||
|
||||
from nanobot.cli.process_identity import set_cli_process_identity
|
||||
|
||||
_ROOT_OPTIONS = frozenset(
|
||||
{
|
||||
"-h",
|
||||
"--help",
|
||||
"-v",
|
||||
"--version",
|
||||
"--install-completion",
|
||||
"--show-completion",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _agent_invocation_args(args: list[str]) -> list[str] | None:
|
||||
"""Return agent arguments when the root command should act as ``agent``."""
|
||||
if not args:
|
||||
return []
|
||||
if args[0] == "agent":
|
||||
return args[1:]
|
||||
if args[0].startswith("-") and args[0].split("=", 1)[0] not in _ROOT_OPTIONS:
|
||||
return args
|
||||
return None
|
||||
|
||||
|
||||
def _native_tui_candidate(args: list[str]) -> bool:
|
||||
"""Return whether ``agent`` can start without the classic agent stack."""
|
||||
@@ -56,35 +34,19 @@ def _configure_windows_console() -> None:
|
||||
reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def _run_agent(args: list[str], *, prog_name: str) -> None:
|
||||
"""Run the shared agent command without importing the complete CLI graph."""
|
||||
import typer
|
||||
|
||||
from nanobot.cli.agent import agent
|
||||
|
||||
agent_app = typer.Typer(add_completion=False)
|
||||
agent_app.command()(agent)
|
||||
command = typer.main.get_command(agent_app)
|
||||
command.main(args=args, prog_name=prog_name)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Dispatch native TUI startup without importing the complete CLI graph."""
|
||||
raw_args = sys.argv[1:]
|
||||
# Installed completion scripts call ``nanobot`` without positional arguments
|
||||
# and pass the request through this environment variable. Keep those requests
|
||||
# on the root command so subcommands remain discoverable.
|
||||
shell_completion = bool(os.environ.get("_NANOBOT_COMPLETE"))
|
||||
agent_args = None if shell_completion else _agent_invocation_args(raw_args)
|
||||
dispatch_args = ["agent", *agent_args] if agent_args is not None else raw_args
|
||||
set_cli_process_identity(dispatch_args)
|
||||
set_cli_process_identity(sys.argv[1:])
|
||||
_configure_windows_console()
|
||||
root_agent_alias = agent_args is not None and raw_args[:1] != ["agent"]
|
||||
if agent_args is not None and (
|
||||
root_agent_alias or _native_tui_candidate(dispatch_args)
|
||||
):
|
||||
prog_name = "nanobot" if root_agent_alias else "nanobot agent"
|
||||
_run_agent(agent_args, prog_name=prog_name)
|
||||
if _native_tui_candidate(sys.argv[1:]):
|
||||
import typer
|
||||
|
||||
from nanobot.cli.agent import agent
|
||||
|
||||
fast_app = typer.Typer(add_completion=False)
|
||||
fast_app.command()(agent)
|
||||
command = typer.main.get_command(fast_app)
|
||||
command.main(args=sys.argv[2:], prog_name="nanobot agent")
|
||||
return
|
||||
|
||||
from nanobot.cli.commands import app
|
||||
|
||||
@@ -29,7 +29,7 @@ _PROVIDER_DISPLAY: dict[str, str] = {
|
||||
|
||||
_OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
|
||||
"openai_codex": "openai-codex/gpt-5.6-sol",
|
||||
"xai_grok": "xai-grok/grok-4.6",
|
||||
"xai_grok": "xai-grok/grok-4.5",
|
||||
"github_copilot": "github-copilot/gpt-5.4-mini",
|
||||
}
|
||||
|
||||
@@ -134,10 +134,7 @@ def _set_oauth_provider_as_main(
|
||||
config.agents.defaults.model_preset = None
|
||||
config.agents.defaults.provider = provider_name
|
||||
config.agents.defaults.model = selected_model
|
||||
if provider_name == "xai_grok" and selected_model in {
|
||||
"xai-grok/grok-4.5",
|
||||
"xai-grok/grok-4.6",
|
||||
}:
|
||||
if provider_name == "xai_grok" and selected_model == "xai-grok/grok-4.5":
|
||||
config.agents.defaults.context_window_tokens = 500_000
|
||||
save_config(config, resolved_config_path)
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"""Shared WebUI setup, URL, health, and browser helpers."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import webbrowser
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, BinaryIO
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
from pydantic import ValidationError
|
||||
@@ -459,104 +457,27 @@ def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
|
||||
console.print("[green]WebUI is attached to the shared gateway.[/green]")
|
||||
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
|
||||
console.print(
|
||||
"[dim]Following live gateway logs. Press Ctrl+C to detach; the gateway stops "
|
||||
"only when the last local client exits.[/dim]"
|
||||
"[dim]Press Ctrl+C to detach; the gateway stops only when the last local client exits.[/dim]"
|
||||
)
|
||||
|
||||
|
||||
_LOG_ANCHOR_BYTES = 64
|
||||
|
||||
|
||||
@dataclass
|
||||
class _GatewayLogCursor:
|
||||
offset: int = 0
|
||||
identity: tuple[int, int] | None = None
|
||||
anchor: bytes = b""
|
||||
pending: bytes = b""
|
||||
|
||||
|
||||
def _log_anchor(handle: BinaryIO, offset: int) -> bytes:
|
||||
size = min(offset, _LOG_ANCHOR_BYTES)
|
||||
handle.seek(offset - size)
|
||||
return handle.read(size)
|
||||
|
||||
|
||||
def _start_gateway_log_cursor(log_path: Path) -> _GatewayLogCursor:
|
||||
"""Start following at the current end of *log_path*."""
|
||||
try:
|
||||
with log_path.open("rb") as handle:
|
||||
stat = os.fstat(handle.fileno())
|
||||
offset = stat.st_size
|
||||
return _GatewayLogCursor(
|
||||
offset=offset,
|
||||
identity=(stat.st_dev, stat.st_ino),
|
||||
anchor=_log_anchor(handle, offset),
|
||||
)
|
||||
except OSError:
|
||||
return _GatewayLogCursor()
|
||||
|
||||
|
||||
def _read_new_gateway_logs(
|
||||
log_path: Path,
|
||||
cursor: _GatewayLogCursor,
|
||||
*,
|
||||
flush: bool = False,
|
||||
) -> list[str]:
|
||||
"""Read complete gateway log lines appended after *cursor*."""
|
||||
try:
|
||||
with log_path.open("rb") as handle:
|
||||
stat = os.fstat(handle.fileno())
|
||||
identity = (stat.st_dev, stat.st_ino)
|
||||
reset = cursor.identity != identity or stat.st_size < cursor.offset
|
||||
if not reset and cursor.offset:
|
||||
reset = _log_anchor(handle, cursor.offset) != cursor.anchor
|
||||
if reset:
|
||||
cursor.offset = 0
|
||||
cursor.pending = b""
|
||||
|
||||
handle.seek(cursor.offset)
|
||||
chunk = handle.read()
|
||||
cursor.offset = handle.tell()
|
||||
cursor.identity = identity
|
||||
cursor.anchor = _log_anchor(handle, cursor.offset)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
parts = (cursor.pending + chunk).split(b"\n")
|
||||
cursor.pending = parts.pop()
|
||||
if flush and cursor.pending:
|
||||
parts.append(cursor.pending)
|
||||
cursor.pending = b""
|
||||
return [part.removesuffix(b"\r").decode("utf-8", errors="replace") for part in parts]
|
||||
|
||||
|
||||
def _attach_to_background_gateway(
|
||||
runtime: "GatewayRuntime",
|
||||
*,
|
||||
poll_hook: Callable[[], None] | None = None,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
) -> None:
|
||||
"""Keep the launcher attached and mirror this gateway's new log output."""
|
||||
status = runtime.status()
|
||||
log_path = status.log_path
|
||||
cursor = _start_gateway_log_cursor(log_path)
|
||||
"""Keep a WebUI launcher attached without taking ownership of the gateway."""
|
||||
_print_webui_foreground_lifecycle(attached=True)
|
||||
try:
|
||||
while status.running:
|
||||
for line in _read_new_gateway_logs(log_path, cursor):
|
||||
console.print(line, markup=False, highlight=False)
|
||||
while runtime.status().running:
|
||||
if poll_hook is not None:
|
||||
poll_hook()
|
||||
sleep(0.5)
|
||||
status = runtime.status()
|
||||
except KeyboardInterrupt:
|
||||
for line in _read_new_gateway_logs(log_path, cursor, flush=True):
|
||||
console.print(line, markup=False, highlight=False)
|
||||
console.print("\n[yellow]WebUI launcher detached.[/yellow]")
|
||||
return
|
||||
|
||||
for line in _read_new_gateway_logs(log_path, cursor, flush=True):
|
||||
console.print(line, markup=False, highlight=False)
|
||||
console.print("[yellow]Gateway stopped.[/yellow]")
|
||||
|
||||
|
||||
|
||||
+3
-21
@@ -25,7 +25,6 @@ from nanobot.cron.types import (
|
||||
CronSchedule,
|
||||
CronStore,
|
||||
)
|
||||
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META
|
||||
from nanobot.utils.run_records import (
|
||||
write_run_record as write_automation_run_record,
|
||||
)
|
||||
@@ -116,21 +115,8 @@ def _disable_malformed_legacy_job(job: CronJob) -> None:
|
||||
logger.warning("Cron: disabled malformed legacy job '{}' ({}): {}", job.name, job.id, reason)
|
||||
|
||||
|
||||
def _persistable_origin_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a detached JSON-safe routing snapshot for a cron payload."""
|
||||
snapshot: dict[str, Any] = {}
|
||||
for key, value in metadata.items():
|
||||
if key == RUNTIME_CONTEXT_INPUT_META:
|
||||
continue
|
||||
try:
|
||||
snapshot[key] = json.loads(json.dumps(value, ensure_ascii=False, allow_nan=False))
|
||||
except (TypeError, ValueError, RecursionError):
|
||||
continue
|
||||
return snapshot
|
||||
|
||||
|
||||
def _normalize_agent_turn_job(job: CronJob) -> bool:
|
||||
"""Make routing metadata persistable and migrate legacy user cron payloads.
|
||||
"""Migrate legacy user cron payloads into session-bound payloads.
|
||||
|
||||
Pre-bound user cron jobs stored their delivery target in ``channel``/``to``.
|
||||
Normal user-created legacy jobs always have those fields; if they are
|
||||
@@ -138,12 +124,8 @@ def _normalize_agent_turn_job(job: CronJob) -> bool:
|
||||
a runtime legacy execution path.
|
||||
"""
|
||||
payload = job.payload
|
||||
origin_metadata = _persistable_origin_metadata(payload.origin_metadata)
|
||||
changed = origin_metadata != payload.origin_metadata
|
||||
payload.origin_metadata = origin_metadata
|
||||
|
||||
if payload.kind != "agent_turn" or not _has_legacy_delivery_context(payload):
|
||||
return changed
|
||||
return False
|
||||
|
||||
if not payload.channel or not payload.to:
|
||||
_disable_malformed_legacy_job(job)
|
||||
@@ -153,7 +135,7 @@ def _normalize_agent_turn_job(job: CronJob) -> bool:
|
||||
payload.origin_channel = payload.origin_channel or payload.channel
|
||||
payload.origin_chat_id = payload.origin_chat_id or payload.to
|
||||
if not payload.origin_metadata:
|
||||
payload.origin_metadata = _persistable_origin_metadata(payload.channel_meta or {})
|
||||
payload.origin_metadata = dict(payload.channel_meta or {})
|
||||
|
||||
payload.deliver = False
|
||||
payload.channel = None
|
||||
|
||||
@@ -1029,20 +1029,6 @@ class LLMProvider(ABC):
|
||||
# Unknown 429 defaults to WAIT+retry.
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _content_as_blocks(content: Any) -> list[dict[str, Any]]:
|
||||
"""Convert message content to blocks so mixed user content can be merged."""
|
||||
if isinstance(content, list):
|
||||
return [
|
||||
dict(cast(dict[str, Any], item))
|
||||
if isinstance(item, dict)
|
||||
else {"type": "text", "text": str(item)}
|
||||
for item in cast(list[object], content)
|
||||
]
|
||||
if content is None:
|
||||
return []
|
||||
return [{"type": "text", "text": str(content)}]
|
||||
|
||||
@staticmethod
|
||||
def _enforce_role_alternation(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Merge consecutive same-role messages and drop trailing assistant messages.
|
||||
@@ -1077,13 +1063,6 @@ class LLMProvider(ABC):
|
||||
curr_content = msg.get("content") or ""
|
||||
if isinstance(prev_content, str) and isinstance(curr_content, str):
|
||||
prev["content"] = (prev_content + "\n\n" + curr_content).strip()
|
||||
elif role == "user":
|
||||
combined = dict(msg)
|
||||
combined["content"] = [
|
||||
*LLMProvider._content_as_blocks(prev_content),
|
||||
*LLMProvider._content_as_blocks(curr_content),
|
||||
]
|
||||
merged[-1] = combined
|
||||
else:
|
||||
merged[-1] = dict(msg)
|
||||
else:
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
import webbrowser
|
||||
@@ -18,12 +17,7 @@ from oauth_cli_kit.models import OAuthToken
|
||||
from oauth_cli_kit.storage import FileTokenStorage
|
||||
|
||||
from nanobot.providers.base import LLMResponse, ProviderCallContext
|
||||
from nanobot.providers.oauth_model_catalog import (
|
||||
OAuthModelCatalog,
|
||||
OAuthModelCatalogSnapshot,
|
||||
)
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
from nanobot.providers.registry import ProviderModelSpec, find_by_name
|
||||
|
||||
DEFAULT_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"
|
||||
DEFAULT_GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
||||
@@ -102,9 +96,7 @@ def login_github_copilot(
|
||||
|
||||
device_code = str(payload["device_code"])
|
||||
user_code = str(payload["user_code"])
|
||||
verify_url = str(
|
||||
payload.get("verification_uri") or payload.get("verification_uri_complete") or ""
|
||||
)
|
||||
verify_url = str(payload.get("verification_uri") or payload.get("verification_uri_complete") or "")
|
||||
verify_complete = str(payload.get("verification_uri_complete") or verify_url)
|
||||
interval = max(1, int(payload.get("interval") or 5))
|
||||
expires_in = int(payload.get("expires_in") or 900)
|
||||
@@ -188,6 +180,8 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
||||
*,
|
||||
provider_name: str = "github_copilot",
|
||||
):
|
||||
from nanobot.providers.registry import find_by_name
|
||||
|
||||
self._copilot_access_token: str | None = None
|
||||
self._copilot_expires_at: float = 0.0
|
||||
self._copilot_token_lock: asyncio.Lock = asyncio.Lock()
|
||||
@@ -223,9 +217,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
||||
)
|
||||
|
||||
timeout = httpx.Timeout(20.0, connect=20.0)
|
||||
async with httpx.AsyncClient(
|
||||
timeout=timeout, follow_redirects=True, trust_env=True
|
||||
) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, trust_env=True) as client:
|
||||
response = await client.get(
|
||||
_resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL),
|
||||
headers=_copilot_headers(github_token.access),
|
||||
@@ -304,174 +296,3 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
||||
on_tool_call_delta=on_tool_call_delta,
|
||||
provider_context=provider_context,
|
||||
)
|
||||
|
||||
|
||||
def get_github_copilot_model_catalog(
|
||||
proxy: str | None = None,
|
||||
) -> OAuthModelCatalogSnapshot:
|
||||
storage = get_storage()
|
||||
token = storage.load()
|
||||
account_key = _catalog_account_key(getattr(token, "account_id", None))
|
||||
cache_key = (
|
||||
f"{storage.get_token_path()}\0{account_key}\0"
|
||||
f"{_resolve('NANOBOT_COPILOT_BASE_URL', DEFAULT_COPILOT_BASE_URL)}\0{proxy or ''}"
|
||||
)
|
||||
return _GITHUB_COPILOT_MODEL_CATALOG.get(cache_key=cache_key, proxy=proxy)
|
||||
|
||||
|
||||
def invalidate_github_copilot_model_catalog() -> None:
|
||||
_GITHUB_COPILOT_MODEL_CATALOG.invalidate()
|
||||
|
||||
|
||||
def _fetch_github_copilot_models(proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||
github_token = get_storage().load()
|
||||
if not github_token or not github_token.access:
|
||||
raise RuntimeError("GitHub Copilot is not logged in")
|
||||
|
||||
common_headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
"Editor-Version": EDITOR_VERSION,
|
||||
"Editor-Plugin-Version": EDITOR_PLUGIN_VERSION,
|
||||
}
|
||||
client_kwargs: dict[str, Any] = {"timeout": 20.0, "follow_redirects": True}
|
||||
if proxy:
|
||||
client_kwargs.update(proxy=proxy, trust_env=False)
|
||||
with httpx.Client(**client_kwargs) as client:
|
||||
exchange = client.get(
|
||||
_resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL),
|
||||
headers={**common_headers, "Authorization": f"token {github_token.access}"},
|
||||
)
|
||||
exchange.raise_for_status()
|
||||
exchange_mapping = _catalog_mapping(exchange.json())
|
||||
copilot_token = exchange_mapping.get("token")
|
||||
if not isinstance(copilot_token, str) or not copilot_token:
|
||||
raise RuntimeError("GitHub Copilot token exchange returned no token")
|
||||
endpoint_base = _catalog_first_text(
|
||||
_catalog_mapping(exchange_mapping.get("endpoints")),
|
||||
"api",
|
||||
)
|
||||
base_url = endpoint_base or _resolve(
|
||||
"NANOBOT_COPILOT_BASE_URL",
|
||||
DEFAULT_COPILOT_BASE_URL,
|
||||
)
|
||||
models_url = (
|
||||
base_url
|
||||
if base_url.rstrip("/").endswith("/models")
|
||||
else f"{base_url.rstrip('/')}/models"
|
||||
)
|
||||
response = client.get(
|
||||
models_url,
|
||||
headers={**common_headers, "Authorization": f"Bearer {copilot_token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _parse_github_copilot_models(response.json())
|
||||
|
||||
|
||||
def _parse_github_copilot_models(payload: Any) -> tuple[ProviderModelSpec, ...]:
|
||||
rows = cast(dict[str, Any], payload).get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
return ()
|
||||
|
||||
fallback_models = _oauth_fallback_models("github_copilot")
|
||||
fallback_by_id = {model.id.split("/", 1)[-1]: model for model in fallback_models}
|
||||
models: list[ProviderModelSpec] = []
|
||||
seen: set[str] = set()
|
||||
for value in cast(list[object], rows):
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
row = cast(dict[str, Any], value)
|
||||
wire_id = _catalog_first_text(row, "id")
|
||||
policy = _catalog_mapping(row.get("policy"))
|
||||
endpoints = row.get("supported_endpoints")
|
||||
if (
|
||||
not wire_id
|
||||
or wire_id in seen
|
||||
or row.get("model_picker_enabled") is not True
|
||||
or policy.get("state") == "disabled"
|
||||
or not _copilot_transport_supported(wire_id, endpoints)
|
||||
):
|
||||
continue
|
||||
seen.add(wire_id)
|
||||
capabilities = _catalog_mapping(row.get("capabilities"))
|
||||
supports = _catalog_mapping(capabilities.get("supports"))
|
||||
limits = _catalog_mapping(capabilities.get("limits"))
|
||||
fallback = fallback_by_id.get(wire_id)
|
||||
models.append(
|
||||
ProviderModelSpec(
|
||||
id=f"github-copilot/{wire_id}",
|
||||
label=(
|
||||
_catalog_first_text(row, "name")
|
||||
or (fallback.label if fallback is not None else wire_id)
|
||||
),
|
||||
description=(fallback.description if fallback is not None else ""),
|
||||
owned_by="GitHub Copilot",
|
||||
context_window=(
|
||||
_catalog_positive_int(limits, "max_context_window_tokens")
|
||||
or (fallback.context_window if fallback is not None else None)
|
||||
),
|
||||
reasoning_efforts=_catalog_reasoning_efforts(supports.get("reasoning_effort")),
|
||||
)
|
||||
)
|
||||
return tuple(models)
|
||||
|
||||
|
||||
def _copilot_transport_supported(wire_id: str, endpoints: object) -> bool:
|
||||
if not isinstance(endpoints, list):
|
||||
return True
|
||||
supported = cast(list[object], endpoints)
|
||||
if "/chat/completions" in supported:
|
||||
return True
|
||||
model = wire_id.lower()
|
||||
return "/responses" in supported and any(
|
||||
token in model for token in ("gpt-5", "o1", "o3", "o4")
|
||||
)
|
||||
|
||||
|
||||
def _oauth_fallback_models(provider_name: str) -> tuple[ProviderModelSpec, ...]:
|
||||
spec = find_by_name(provider_name)
|
||||
assert spec is not None
|
||||
return spec.builtin_models
|
||||
|
||||
|
||||
def _catalog_account_key(account_id: object) -> str:
|
||||
value = account_id if isinstance(account_id, str) else ""
|
||||
return hashlib.sha256(value.encode()).hexdigest()[:16] if value else "anonymous"
|
||||
|
||||
|
||||
def _catalog_mapping(value: Any) -> dict[str, Any]:
|
||||
return cast(dict[str, Any], value) if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _catalog_first_text(row: dict[str, Any], *keys: str) -> str:
|
||||
for key in keys:
|
||||
value = row.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _catalog_positive_int(row: dict[str, Any], *keys: str) -> int | None:
|
||||
for key in keys:
|
||||
value = row.get(key)
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
|
||||
return int(value)
|
||||
return None
|
||||
|
||||
|
||||
def _catalog_reasoning_efforts(value: Any) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
item.strip()
|
||||
for item in cast(list[object], value)
|
||||
if isinstance(item, str) and item.strip()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
_GITHUB_COPILOT_MODEL_CATALOG = OAuthModelCatalog(
|
||||
fallback_models=_oauth_fallback_models("github_copilot"),
|
||||
fetch=_fetch_github_copilot_models,
|
||||
)
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
"""Shared cache seam for OAuth provider model discovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Literal
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.providers.registry import ProviderModelSpec
|
||||
|
||||
CatalogSource = Literal["remote", "cache", "stale", "fallback"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OAuthModelCatalogSnapshot:
|
||||
"""One usable catalog view, including where it came from."""
|
||||
|
||||
models: tuple[ProviderModelSpec, ...]
|
||||
source: CatalogSource
|
||||
fetched_at: float
|
||||
message: str | None = None
|
||||
|
||||
def find(self, model: str) -> ProviderModelSpec | None:
|
||||
wire_id = model.split("/", 1)[-1]
|
||||
return next(
|
||||
(item for item in self.models if item.id.split("/", 1)[-1] == wire_id),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CacheEntry:
|
||||
snapshot: OAuthModelCatalogSnapshot
|
||||
stored_at: float
|
||||
|
||||
|
||||
class OAuthModelCatalog:
|
||||
"""Cache one provider's discovery behind a small failure-tolerant interface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
fallback_models: Sequence[ProviderModelSpec],
|
||||
fetch: Callable[[str | None], Sequence[ProviderModelSpec]],
|
||||
fresh_ttl_s: float = 5 * 60,
|
||||
stale_ttl_s: float = 24 * 60 * 60,
|
||||
failure_ttl_s: float = 30,
|
||||
max_entries: int = 8,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
wall_clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
if fresh_ttl_s < 0 or stale_ttl_s < fresh_ttl_s or failure_ttl_s < 0:
|
||||
raise ValueError("catalog cache TTLs are invalid")
|
||||
if max_entries < 1:
|
||||
raise ValueError("catalog cache must allow at least one entry")
|
||||
self._fallback_models = tuple(fallback_models)
|
||||
self._fetch = fetch
|
||||
self._fresh_ttl_s = fresh_ttl_s
|
||||
self._stale_ttl_s = stale_ttl_s
|
||||
self._failure_ttl_s = failure_ttl_s
|
||||
self._max_entries = max_entries
|
||||
self._monotonic = monotonic
|
||||
self._wall_clock = wall_clock
|
||||
self._condition = threading.Condition()
|
||||
self._entries: dict[str, _CacheEntry] = {}
|
||||
self._failures: dict[str, float] = {}
|
||||
self._inflight: set[str] = set()
|
||||
self._generation = 0
|
||||
|
||||
def get(self, *, cache_key: str, proxy: str | None = None) -> OAuthModelCatalogSnapshot:
|
||||
"""Return a fresh catalog, sharing concurrent work and retaining a fallback."""
|
||||
with self._condition:
|
||||
generation = self._generation
|
||||
cached = self._cached_result(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
while cache_key in self._inflight:
|
||||
self._condition.wait()
|
||||
if generation != self._generation:
|
||||
return self._stale_or_fallback(None, self._monotonic())
|
||||
cached = self._cached_result(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
self._inflight.add(cache_key)
|
||||
|
||||
try:
|
||||
models = tuple(self._fetch(proxy))
|
||||
if not models:
|
||||
raise ValueError("provider returned an empty model catalog")
|
||||
except Exception as exc:
|
||||
logger.warning("OAuth model catalog refresh failed: type={}", type(exc).__name__)
|
||||
with self._condition:
|
||||
result = (
|
||||
self._stale_or_fallback(None, self._monotonic())
|
||||
if generation != self._generation
|
||||
else self._failure_result(cache_key)
|
||||
)
|
||||
else:
|
||||
now = self._monotonic()
|
||||
result = OAuthModelCatalogSnapshot(
|
||||
models=models,
|
||||
source="remote",
|
||||
fetched_at=self._wall_clock(),
|
||||
)
|
||||
with self._condition:
|
||||
if generation != self._generation:
|
||||
result = self._stale_or_fallback(None, now)
|
||||
else:
|
||||
self._store(cache_key, _CacheEntry(snapshot=result, stored_at=now))
|
||||
self._failures.pop(cache_key, None)
|
||||
finally:
|
||||
with self._condition:
|
||||
self._inflight.discard(cache_key)
|
||||
self._condition.notify_all()
|
||||
|
||||
return result
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Drop cached work and prevent an older identity refresh from being stored."""
|
||||
with self._condition:
|
||||
self._generation += 1
|
||||
self._entries.clear()
|
||||
self._failures.clear()
|
||||
self._condition.notify_all()
|
||||
|
||||
def _cached_result(self, cache_key: str) -> OAuthModelCatalogSnapshot | None:
|
||||
now = self._monotonic()
|
||||
entry = self._entries.get(cache_key)
|
||||
if entry is not None and now - entry.stored_at < self._fresh_ttl_s:
|
||||
return replace(entry.snapshot, source="cache")
|
||||
failure_until = self._failures.get(cache_key)
|
||||
if failure_until is not None and failure_until <= now:
|
||||
self._failures.pop(cache_key, None)
|
||||
elif failure_until is not None:
|
||||
return self._stale_or_fallback(entry, now)
|
||||
return None
|
||||
|
||||
def _failure_result(self, cache_key: str) -> OAuthModelCatalogSnapshot:
|
||||
now = self._monotonic()
|
||||
self._reserve(cache_key)
|
||||
self._failures[cache_key] = now + self._failure_ttl_s
|
||||
return self._stale_or_fallback(self._entries.get(cache_key), now)
|
||||
|
||||
def _stale_or_fallback(
|
||||
self,
|
||||
entry: _CacheEntry | None,
|
||||
now: float,
|
||||
) -> OAuthModelCatalogSnapshot:
|
||||
if entry is not None and now - entry.stored_at < self._stale_ttl_s:
|
||||
return replace(
|
||||
entry.snapshot,
|
||||
source="stale",
|
||||
message="Could not refresh the online model list; showing cached models.",
|
||||
)
|
||||
return OAuthModelCatalogSnapshot(
|
||||
models=self._fallback_models,
|
||||
source="fallback",
|
||||
fetched_at=self._wall_clock(),
|
||||
message="Could not load the online model list; showing built-in fallback models.",
|
||||
)
|
||||
|
||||
def _store(self, cache_key: str, entry: _CacheEntry) -> None:
|
||||
self._reserve(cache_key)
|
||||
self._entries[cache_key] = entry
|
||||
|
||||
def _reserve(self, cache_key: str) -> None:
|
||||
known = set(self._entries) | set(self._failures)
|
||||
if cache_key in known or len(known) < self._max_entries:
|
||||
return
|
||||
oldest = min(
|
||||
known,
|
||||
key=lambda key: (
|
||||
self._entries[key].stored_at
|
||||
if key in self._entries
|
||||
else self._failures[key] - self._failure_ttl_s
|
||||
),
|
||||
)
|
||||
self._entries.pop(oldest, None)
|
||||
self._failures.pop(oldest, None)
|
||||
|
||||
|
||||
def get_oauth_model_catalog(
|
||||
provider_name: str,
|
||||
*,
|
||||
proxy: str | None = None,
|
||||
) -> OAuthModelCatalogSnapshot:
|
||||
"""Discover models through the owning provider module."""
|
||||
if provider_name == "openai_codex":
|
||||
from nanobot.providers.openai_codex_provider import get_openai_codex_model_catalog
|
||||
|
||||
return get_openai_codex_model_catalog(proxy)
|
||||
if provider_name == "xai_grok":
|
||||
from nanobot.providers.xai_grok_provider import get_xai_grok_model_catalog
|
||||
|
||||
return get_xai_grok_model_catalog(proxy)
|
||||
if provider_name == "github_copilot":
|
||||
from nanobot.providers.github_copilot_provider import get_github_copilot_model_catalog
|
||||
|
||||
return get_github_copilot_model_catalog(proxy)
|
||||
raise ValueError(f"OAuth model discovery is not available for {provider_name}")
|
||||
|
||||
|
||||
def invalidate_oauth_model_catalog(provider_name: str) -> None:
|
||||
"""Invalidate provider discovery after its OAuth identity changes."""
|
||||
if provider_name == "openai_codex":
|
||||
from nanobot.providers.openai_codex_provider import (
|
||||
invalidate_openai_codex_model_catalog,
|
||||
)
|
||||
|
||||
invalidate_openai_codex_model_catalog()
|
||||
elif provider_name == "xai_grok":
|
||||
from nanobot.providers.xai_grok_provider import invalidate_xai_grok_model_catalog
|
||||
|
||||
invalidate_xai_grok_model_catalog()
|
||||
elif provider_name == "github_copilot":
|
||||
from nanobot.providers.github_copilot_provider import (
|
||||
invalidate_github_copilot_model_catalog,
|
||||
)
|
||||
|
||||
invalidate_github_copilot_model_catalog()
|
||||
@@ -14,10 +14,7 @@ from typing import Any, cast
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from oauth_cli_kit import get_token as get_codex_token
|
||||
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
|
||||
from oauth_cli_kit.storage import FileTokenStorage
|
||||
|
||||
from nanobot import __version__
|
||||
from nanobot.providers.base import (
|
||||
LLMProvider,
|
||||
LLMResponse,
|
||||
@@ -25,10 +22,6 @@ from nanobot.providers.base import (
|
||||
ProviderConversationState,
|
||||
resolve_stream_idle_timeout_s,
|
||||
)
|
||||
from nanobot.providers.oauth_model_catalog import (
|
||||
OAuthModelCatalog,
|
||||
OAuthModelCatalogSnapshot,
|
||||
)
|
||||
from nanobot.providers.openai_responses import (
|
||||
ResponsesStreamCapture,
|
||||
build_responses_state,
|
||||
@@ -42,11 +35,8 @@ from nanobot.providers.openai_responses import (
|
||||
responses_state_items,
|
||||
responses_state_matches,
|
||||
)
|
||||
from nanobot.providers.registry import ProviderModelSpec, find_by_name
|
||||
|
||||
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
|
||||
DEFAULT_OPENAI_CODEX_MODELS_URL = "https://chatgpt.com/backend-api/codex/models"
|
||||
OPENAI_CODEX_CATALOG_CLIENT_VERSION = "0.144.0"
|
||||
DEFAULT_ORIGINATOR = "nanobot"
|
||||
_COMPACTION_RETAINED_CHAR_BUDGET = 256_000
|
||||
|
||||
@@ -97,7 +87,9 @@ class OpenAICodexProvider(LLMProvider):
|
||||
model = model or self.default_model
|
||||
sanitized_messages = self._sanitize_empty_content(messages)
|
||||
sanitized_state = (
|
||||
provider_context.conversation_state if provider_context is not None else None
|
||||
provider_context.conversation_state
|
||||
if provider_context is not None
|
||||
else None
|
||||
)
|
||||
if sanitized_state is not None:
|
||||
sanitized_state = sanitized_state.with_pending_messages(
|
||||
@@ -176,7 +168,11 @@ class OpenAICodexProvider(LLMProvider):
|
||||
)
|
||||
|
||||
compact_threshold = resolve_compact_threshold(
|
||||
(provider_context.context_window_tokens if provider_context is not None else None),
|
||||
(
|
||||
provider_context.context_window_tokens
|
||||
if provider_context is not None
|
||||
else None
|
||||
),
|
||||
max_tokens,
|
||||
)
|
||||
if (
|
||||
@@ -240,12 +236,8 @@ class OpenAICodexProvider(LLMProvider):
|
||||
return response
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7,
|
||||
reasoning_effort: str | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
provider_context: ProviderCallContext | None = None,
|
||||
@@ -272,12 +264,8 @@ class OpenAICodexProvider(LLMProvider):
|
||||
)
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7,
|
||||
reasoning_effort: str | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
@@ -356,7 +344,11 @@ def _without_response_item_ids(
|
||||
sanitized_input.append(raw_item)
|
||||
continue
|
||||
item = cast(dict[str, Any], raw_item)
|
||||
sanitized_input.append({key: value for key, value in item.items() if key != "id"})
|
||||
sanitized_input.append({
|
||||
key: value
|
||||
for key, value in item.items()
|
||||
if key != "id"
|
||||
})
|
||||
|
||||
body = dict(request_body)
|
||||
body["input"] = sanitized_input
|
||||
@@ -452,12 +444,15 @@ async def _request_codex(
|
||||
raw = text.decode("utf-8", "ignore")
|
||||
retry_after = LLMProvider._extract_retry_after_from_headers(response.headers)
|
||||
error_type, error_code = LLMProvider._extract_error_type_code(raw)
|
||||
compaction_unsupported = response.status_code in {400, 404, 422} and any(
|
||||
marker in raw.lower()
|
||||
for marker in (
|
||||
"context_management",
|
||||
"compact_threshold",
|
||||
"compaction_trigger",
|
||||
compaction_unsupported = (
|
||||
response.status_code in {400, 404, 422}
|
||||
and any(
|
||||
marker in raw.lower()
|
||||
for marker in (
|
||||
"context_management",
|
||||
"compact_threshold",
|
||||
"compaction_trigger",
|
||||
)
|
||||
)
|
||||
)
|
||||
raise _CodexHTTPError(
|
||||
@@ -466,9 +461,7 @@ async def _request_codex(
|
||||
retry_after=retry_after,
|
||||
error_type=error_type,
|
||||
error_code=error_code,
|
||||
should_retry=_should_retry_status(
|
||||
response.status_code, error_type, error_code, raw
|
||||
),
|
||||
should_retry=_should_retry_status(response.status_code, error_type, error_code, raw),
|
||||
compaction_unsupported=compaction_unsupported,
|
||||
)
|
||||
capture = ResponsesStreamCapture()
|
||||
@@ -541,9 +534,7 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
|
||||
default_detail = "HTTP request failed"
|
||||
|
||||
if status_code is not None and should_retry is None:
|
||||
retry_content = (
|
||||
None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail
|
||||
)
|
||||
retry_content = None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail
|
||||
should_retry = _should_retry_status(
|
||||
int(status_code),
|
||||
getattr(exc, "error_type", None),
|
||||
@@ -601,139 +592,3 @@ def _should_retry_status(
|
||||
)
|
||||
)
|
||||
return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500
|
||||
|
||||
|
||||
def get_openai_codex_model_catalog(
|
||||
proxy: str | None = None,
|
||||
) -> OAuthModelCatalogSnapshot:
|
||||
storage = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename)
|
||||
token = storage.load()
|
||||
account_id = getattr(token, "account_id", None)
|
||||
account_key = _catalog_account_key(account_id)
|
||||
cache_key = f"{storage.get_token_path()}\0{account_key}\0{proxy or ''}"
|
||||
return _OPENAI_CODEX_MODEL_CATALOG.get(cache_key=cache_key, proxy=proxy)
|
||||
|
||||
|
||||
def invalidate_openai_codex_model_catalog() -> None:
|
||||
_OPENAI_CODEX_MODEL_CATALOG.invalidate()
|
||||
|
||||
|
||||
def _fetch_openai_codex_models(proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||
token = get_codex_token(proxy=proxy)
|
||||
account_id = getattr(token, "account_id", None)
|
||||
if not isinstance(account_id, str) or not account_id:
|
||||
raise RuntimeError("OpenAI Codex OAuth token has no account ID")
|
||||
client_kwargs: dict[str, Any] = {"timeout": 10.0, "follow_redirects": False}
|
||||
if proxy:
|
||||
client_kwargs.update(proxy=proxy, trust_env=False)
|
||||
with httpx.Client(**client_kwargs) as client:
|
||||
response = client.get(
|
||||
DEFAULT_OPENAI_CODEX_MODELS_URL,
|
||||
params={"client_version": OPENAI_CODEX_CATALOG_CLIENT_VERSION},
|
||||
headers={
|
||||
"Authorization": f"Bearer {token.access}",
|
||||
"chatgpt-account-id": account_id,
|
||||
"originator": DEFAULT_ORIGINATOR,
|
||||
"User-Agent": f"nanobot/{__version__} (python)",
|
||||
"accept": "application/json",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _parse_openai_codex_models(response.json())
|
||||
|
||||
|
||||
def _parse_openai_codex_models(payload: Any) -> tuple[ProviderModelSpec, ...]:
|
||||
rows = cast(dict[str, Any], payload).get("models") if isinstance(payload, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
return ()
|
||||
|
||||
fallback_models = _oauth_fallback_models("openai_codex")
|
||||
fallback_by_id = {model.id.split("/", 1)[-1]: model for model in fallback_models}
|
||||
parsed: list[tuple[int, ProviderModelSpec]] = []
|
||||
seen: set[str] = set()
|
||||
for value in cast(list[object], rows):
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
row = cast(dict[str, Any], value)
|
||||
wire_id = _catalog_first_text(row, "slug", "id")
|
||||
if not wire_id or wire_id in seen or row.get("visibility") in {"hide", "none"}:
|
||||
continue
|
||||
seen.add(wire_id)
|
||||
fallback = fallback_by_id.get(wire_id)
|
||||
priority = row.get("priority")
|
||||
parsed.append(
|
||||
(
|
||||
priority if isinstance(priority, int) and not isinstance(priority, bool) else 2**31,
|
||||
ProviderModelSpec(
|
||||
id=f"openai-codex/{wire_id}",
|
||||
label=(
|
||||
_catalog_first_text(row, "display_name", "name")
|
||||
or (fallback.label if fallback is not None else wire_id)
|
||||
),
|
||||
description=(
|
||||
_catalog_first_text(row, "description")
|
||||
or (fallback.description if fallback is not None else "")
|
||||
),
|
||||
owned_by="OpenAI Codex",
|
||||
context_window=(
|
||||
_catalog_positive_int(row, "context_window")
|
||||
or (fallback.context_window if fallback is not None else None)
|
||||
),
|
||||
reasoning_efforts=(
|
||||
_catalog_reasoning_efforts(row.get("supported_reasoning_levels"))
|
||||
or (fallback.reasoning_efforts if fallback is not None else ())
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
parsed.sort(key=lambda item: item[0])
|
||||
return tuple(model for _, model in parsed)
|
||||
|
||||
|
||||
def _oauth_fallback_models(provider_name: str) -> tuple[ProviderModelSpec, ...]:
|
||||
spec = find_by_name(provider_name)
|
||||
assert spec is not None
|
||||
return spec.builtin_models
|
||||
|
||||
|
||||
def _catalog_account_key(account_id: object) -> str:
|
||||
value = account_id if isinstance(account_id, str) else ""
|
||||
return hashlib.sha256(value.encode()).hexdigest()[:16] if value else "anonymous"
|
||||
|
||||
|
||||
def _catalog_first_text(row: dict[str, Any], *keys: str) -> str:
|
||||
for key in keys:
|
||||
value = row.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _catalog_positive_int(row: dict[str, Any], *keys: str) -> int | None:
|
||||
for key in keys:
|
||||
value = row.get(key)
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
|
||||
return int(value)
|
||||
return None
|
||||
|
||||
|
||||
def _catalog_reasoning_efforts(value: Any) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
efforts: list[str] = []
|
||||
for item in cast(list[object], value):
|
||||
if isinstance(item, str):
|
||||
effort = item.strip()
|
||||
elif isinstance(item, dict):
|
||||
effort = _catalog_first_text(cast(dict[str, Any], item), "effort", "value", "id")
|
||||
else:
|
||||
effort = ""
|
||||
if effort and effort not in efforts:
|
||||
efforts.append(effort)
|
||||
return tuple(efforts)
|
||||
|
||||
|
||||
_OPENAI_CODEX_MODEL_CATALOG = OAuthModelCatalog(
|
||||
fallback_models=_oauth_fallback_models("openai_codex"),
|
||||
fetch=_fetch_openai_codex_models,
|
||||
)
|
||||
|
||||
@@ -20,15 +20,12 @@ from pydantic.alias_generators import to_snake
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderModelSpec:
|
||||
"""Curated model metadata used for fixed catalogs or online fallback."""
|
||||
"""A curated model exposed by providers without a model-list endpoint."""
|
||||
|
||||
id: str
|
||||
label: str = ""
|
||||
description: str = ""
|
||||
owned_by: str = ""
|
||||
context_window: int | None = None
|
||||
reasoning_efforts: tuple[str, ...] = ()
|
||||
supports_backend_search: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -45,7 +42,7 @@ class ProviderSpec:
|
||||
keywords: tuple[str, ...] # model-name keywords for matching (lowercase)
|
||||
env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY"
|
||||
display_name: str = "" # shown in `nanobot status`
|
||||
model_catalog: str = "auto" # WebUI model-list source, including builtin/hybrid
|
||||
model_catalog: str = "auto" # WebUI model-list source
|
||||
builtin_models: tuple[ProviderModelSpec, ...] = ()
|
||||
settings_alias_for: str = "" # compatibility alias grouped under this provider in Settings
|
||||
|
||||
@@ -410,56 +407,45 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
keywords=("openai-codex",),
|
||||
env_key="",
|
||||
display_name="OpenAI Codex",
|
||||
model_catalog="hybrid",
|
||||
model_catalog="builtin",
|
||||
builtin_models=(
|
||||
ProviderModelSpec(
|
||||
id="openai-codex/gpt-5.6-sol",
|
||||
label="GPT-5.6-Sol",
|
||||
description="Latest frontier agentic coding model.",
|
||||
context_window=272_000,
|
||||
reasoning_efforts=("low", "medium", "high", "xhigh", "max", "ultra"),
|
||||
context_window=372000,
|
||||
),
|
||||
ProviderModelSpec(
|
||||
id="openai-codex/gpt-5.6-terra",
|
||||
label="GPT-5.6-Terra",
|
||||
description="Balanced agentic coding model for everyday work.",
|
||||
context_window=272_000,
|
||||
reasoning_efforts=("low", "medium", "high", "xhigh", "max", "ultra"),
|
||||
context_window=372000,
|
||||
),
|
||||
ProviderModelSpec(
|
||||
id="openai-codex/gpt-5.6-luna",
|
||||
label="GPT-5.6-Luna",
|
||||
description="Fast and affordable agentic coding model.",
|
||||
context_window=272_000,
|
||||
reasoning_efforts=("low", "medium", "high", "xhigh", "max"),
|
||||
context_window=372000,
|
||||
),
|
||||
ProviderModelSpec(
|
||||
id="openai-codex/gpt-5.5",
|
||||
label="GPT-5.5",
|
||||
description="Frontier model for complex coding, research, and real-world work.",
|
||||
context_window=272_000,
|
||||
reasoning_efforts=("low", "medium", "high", "xhigh"),
|
||||
),
|
||||
ProviderModelSpec(
|
||||
id="openai-codex/gpt-5.4",
|
||||
label="GPT-5.4",
|
||||
description="Strong model for everyday coding.",
|
||||
context_window=272_000,
|
||||
reasoning_efforts=("low", "medium", "high", "xhigh"),
|
||||
),
|
||||
ProviderModelSpec(
|
||||
id="openai-codex/gpt-5.4-mini",
|
||||
label="GPT-5.4-Mini",
|
||||
description="Small, fast, and cost-efficient model for simpler coding tasks.",
|
||||
context_window=272_000,
|
||||
reasoning_efforts=("low", "medium", "high", "xhigh"),
|
||||
),
|
||||
ProviderModelSpec(
|
||||
id="openai-codex/gpt-5.3-codex-spark",
|
||||
label="GPT-5.3-Codex-Spark",
|
||||
description="Ultra-fast coding model.",
|
||||
context_window=128_000,
|
||||
reasoning_efforts=("low", "medium", "high", "xhigh"),
|
||||
),
|
||||
),
|
||||
backend="openai_codex",
|
||||
@@ -473,19 +459,13 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
keywords=("xai-grok", "xai_grok"),
|
||||
env_key="",
|
||||
display_name="xAI Grok",
|
||||
model_catalog="hybrid",
|
||||
model_catalog="builtin",
|
||||
builtin_models=(
|
||||
ProviderModelSpec(
|
||||
id="xai-grok/grok-4.6",
|
||||
label="Grok 4.6",
|
||||
description="Grok via xAI subscription; X Search is enabled when supported.",
|
||||
context_window=500_000,
|
||||
),
|
||||
ProviderModelSpec(
|
||||
id="xai-grok/grok-4.5",
|
||||
label="Grok 4.5",
|
||||
description="Grok via xAI subscription; X Search is enabled when supported.",
|
||||
context_window=500_000,
|
||||
context_window=500000,
|
||||
),
|
||||
),
|
||||
backend="xai_grok",
|
||||
@@ -498,19 +478,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
keywords=("github_copilot", "copilot"),
|
||||
env_key="",
|
||||
display_name="Github Copilot",
|
||||
model_catalog="hybrid",
|
||||
builtin_models=(
|
||||
ProviderModelSpec(
|
||||
id="github-copilot/gpt-5.4-mini",
|
||||
label="GPT-5.4 Mini",
|
||||
description="GitHub Copilot Responses model.",
|
||||
),
|
||||
ProviderModelSpec(
|
||||
id="github-copilot/gpt-4.1",
|
||||
label="GPT-4.1",
|
||||
description="GitHub Copilot chat model.",
|
||||
),
|
||||
),
|
||||
backend="github_copilot",
|
||||
default_api_base="https://api.githubcopilot.com",
|
||||
strip_model_prefix=True,
|
||||
|
||||
@@ -4,9 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, cast
|
||||
@@ -22,24 +22,21 @@ from nanobot.providers.base import (
|
||||
ToolCallRequest,
|
||||
resolve_stream_idle_timeout_s,
|
||||
)
|
||||
from nanobot.providers.oauth_model_catalog import OAuthModelCatalog, OAuthModelCatalogSnapshot
|
||||
from nanobot.providers.openai_responses import (
|
||||
consume_sse_with_reasoning,
|
||||
convert_messages,
|
||||
convert_tools,
|
||||
)
|
||||
from nanobot.providers.registry import ProviderModelSpec, find_by_name
|
||||
from nanobot.providers.xai_oauth import (
|
||||
XAI_CLIENT_VERSION,
|
||||
get_xai_oauth_login_status,
|
||||
get_xai_oauth_storage_path,
|
||||
XAIToken,
|
||||
get_xai_oauth_token,
|
||||
)
|
||||
|
||||
DEFAULT_XAI_GROK_MODEL = "xai-grok/grok-4.6"
|
||||
DEFAULT_XAI_GROK_URL = "https://cli-chat-proxy.grok.com/v1/responses"
|
||||
DEFAULT_XAI_GROK_MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models"
|
||||
_HOSTED_SEARCH_MAX_TURNS = 5
|
||||
DEFAULT_XAI_GROK_MODEL = "xai-grok/grok-4.5"
|
||||
_MODEL_CAPABILITIES_TTL_S = 5 * 60
|
||||
_MAX_ERROR_BODY_CHARS = 1000
|
||||
_SENSITIVE_ERROR_KEYS = {
|
||||
"accesstoken",
|
||||
@@ -66,10 +63,6 @@ def _is_named_x_search_tool(value: object) -> bool:
|
||||
class XAIGrokProvider(LLMProvider):
|
||||
"""Call xAI's subscription proxy and expose supported hosted tools."""
|
||||
|
||||
# An incomplete hosted-tool stream can already have emitted answer text. Let the
|
||||
# provider close that stream segment before its one bounded recovery attempt.
|
||||
supports_stream_recover_callback = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
default_model: str = DEFAULT_XAI_GROK_MODEL,
|
||||
@@ -82,19 +75,37 @@ class XAIGrokProvider(LLMProvider):
|
||||
self.default_model = default_model
|
||||
self.proxy = proxy or None
|
||||
self._extra_body = dict(extra_body or {})
|
||||
self._model_capabilities: dict[str, bool] | None = None
|
||||
self._model_capabilities_fetched_at = 0.0
|
||||
|
||||
async def _supports_backend_search(self, model: str) -> bool:
|
||||
catalog = await asyncio.to_thread(
|
||||
get_xai_grok_model_catalog,
|
||||
self.proxy,
|
||||
)
|
||||
if catalog.message:
|
||||
logger.warning(
|
||||
"xAI model catalog unavailable; hosted X Search disabled unless cached: {}",
|
||||
catalog.message,
|
||||
)
|
||||
info = catalog.find(model)
|
||||
return bool(info and info.supports_backend_search)
|
||||
async def _supports_backend_search(self, token: XAIToken, model: str) -> bool:
|
||||
now = time.monotonic()
|
||||
capabilities = self._model_capabilities
|
||||
if (
|
||||
capabilities is None
|
||||
or now - self._model_capabilities_fetched_at >= _MODEL_CAPABILITIES_TTL_S
|
||||
):
|
||||
try:
|
||||
capabilities = await _fetch_xai_model_capabilities(
|
||||
DEFAULT_XAI_GROK_MODELS_URL,
|
||||
_build_model_headers(token),
|
||||
proxy=self.proxy,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"xAI model capability lookup failed; hosted X Search disabled for model {}: "
|
||||
"type={} error={}",
|
||||
model,
|
||||
type(exc).__name__,
|
||||
str(exc).strip() or "unexpected error",
|
||||
)
|
||||
capabilities = {}
|
||||
self._model_capabilities = capabilities
|
||||
self._model_capabilities_fetched_at = now
|
||||
else:
|
||||
self._model_capabilities = capabilities
|
||||
self._model_capabilities_fetched_at = now
|
||||
return capabilities.get(model, False)
|
||||
|
||||
async def _call_xai(
|
||||
self,
|
||||
@@ -108,7 +119,6 @@ class XAIGrokProvider(LLMProvider):
|
||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
||||
) -> LLMResponse:
|
||||
wire_model = _strip_model_prefix(model or self.default_model)
|
||||
system_prompt, input_items = convert_messages(messages)
|
||||
@@ -118,13 +128,17 @@ class XAIGrokProvider(LLMProvider):
|
||||
token = await asyncio.to_thread(get_xai_oauth_token, proxy=self.proxy)
|
||||
configured_tools = self._extra_body.get("tools")
|
||||
tools_are_explicit = "tools" in self._extra_body
|
||||
configured_hosted_search = isinstance(configured_tools, list) and any(
|
||||
_is_hosted_x_search_tool(tool) for tool in cast(list[object], configured_tools)
|
||||
configured_hosted_search = (
|
||||
isinstance(configured_tools, list)
|
||||
and any(
|
||||
_is_hosted_x_search_tool(tool)
|
||||
for tool in cast(list[object], configured_tools)
|
||||
)
|
||||
)
|
||||
supports_backend_search = False
|
||||
if not tools_are_explicit:
|
||||
stage = "model_capabilities"
|
||||
supports_backend_search = await self._supports_backend_search(wire_model)
|
||||
supports_backend_search = await self._supports_backend_search(token, wire_model)
|
||||
converted_tools = convert_tools(tools or [])
|
||||
if isinstance(configured_tools, list):
|
||||
converted_tools.extend(cast(list[dict[str, Any]], configured_tools))
|
||||
@@ -135,8 +149,6 @@ class XAIGrokProvider(LLMProvider):
|
||||
if supports_backend_search:
|
||||
converted_tools.append({"type": "x_search"})
|
||||
|
||||
hosted_search_enabled = supports_backend_search or configured_hosted_search
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"model": wire_model,
|
||||
"store": False,
|
||||
@@ -152,65 +164,51 @@ class XAIGrokProvider(LLMProvider):
|
||||
"temperature": temperature,
|
||||
"reasoning": _build_reasoning_options(reasoning_effort),
|
||||
}
|
||||
if hosted_search_enabled:
|
||||
# xAI's global default is intentionally unspecified. Five turns is
|
||||
# their documented balanced setting and prevents a search from
|
||||
# stopping after a single unsuccessful lookup.
|
||||
body["max_turns"] = _HOSTED_SEARCH_MAX_TURNS
|
||||
if self._extra_body:
|
||||
body.update(
|
||||
{key: value for key, value in self._extra_body.items() if key != "tools"}
|
||||
)
|
||||
body.update({
|
||||
key: value
|
||||
for key, value in self._extra_body.items()
|
||||
if key != "tools"
|
||||
})
|
||||
if tools_are_explicit and not isinstance(configured_tools, list):
|
||||
body["tools"] = configured_tools
|
||||
|
||||
headers = _build_headers(token.access, wire_model)
|
||||
stage = "xai_request"
|
||||
auth_retried = False
|
||||
hosted_tool_retried = False
|
||||
retry_usage: LLMUsage | None = None
|
||||
while True:
|
||||
try:
|
||||
result = await _request_xai(
|
||||
DEFAULT_XAI_GROK_URL,
|
||||
headers,
|
||||
body,
|
||||
proxy=self.proxy,
|
||||
on_content_delta=on_content_delta,
|
||||
on_thinking_delta=on_thinking_delta,
|
||||
on_tool_call_delta=on_tool_call_delta,
|
||||
)
|
||||
break
|
||||
except _XAIHTTPError as exc:
|
||||
if exc.status_code != 401 or auth_retried:
|
||||
raise
|
||||
auth_retried = True
|
||||
stage = "oauth_refresh"
|
||||
token = await asyncio.to_thread(
|
||||
get_xai_oauth_token,
|
||||
proxy=self.proxy,
|
||||
force_refresh=True,
|
||||
)
|
||||
headers = _build_headers(token.access, wire_model)
|
||||
stage = "xai_request_after_oauth_refresh"
|
||||
except _XAIIncompleteHostedToolError as exc:
|
||||
retry_usage = _combine_usage(retry_usage, exc.usage)
|
||||
cannot_recover_stream = exc.stream_output_emitted and on_stream_recover is None
|
||||
if hosted_tool_retried or cannot_recover_stream:
|
||||
exc.usage = retry_usage
|
||||
raise
|
||||
hosted_tool_retried = True
|
||||
stage = "hosted_tool_recovery"
|
||||
logger.warning(
|
||||
"xAI response ended with unfinished hosted tool(s): {}; retrying once",
|
||||
", ".join(exc.tool_names),
|
||||
)
|
||||
if on_stream_recover is not None:
|
||||
await on_stream_recover()
|
||||
headers = _build_headers(token.access, wire_model)
|
||||
try:
|
||||
result = await _request_xai(
|
||||
DEFAULT_XAI_GROK_URL,
|
||||
headers,
|
||||
body,
|
||||
proxy=self.proxy,
|
||||
on_content_delta=on_content_delta,
|
||||
on_thinking_delta=on_thinking_delta,
|
||||
on_tool_call_delta=on_tool_call_delta,
|
||||
)
|
||||
except _XAIHTTPError as exc:
|
||||
if exc.status_code != 401:
|
||||
raise
|
||||
stage = "oauth_refresh"
|
||||
token = await asyncio.to_thread(
|
||||
get_xai_oauth_token,
|
||||
proxy=self.proxy,
|
||||
force_refresh=True,
|
||||
)
|
||||
self._model_capabilities = None
|
||||
self._model_capabilities_fetched_at = 0.0
|
||||
headers = _build_headers(token.access, wire_model)
|
||||
stage = "xai_request_retry"
|
||||
result = await _request_xai(
|
||||
DEFAULT_XAI_GROK_URL,
|
||||
headers,
|
||||
body,
|
||||
proxy=self.proxy,
|
||||
on_content_delta=on_content_delta,
|
||||
on_thinking_delta=on_thinking_delta,
|
||||
on_tool_call_delta=on_tool_call_delta,
|
||||
)
|
||||
|
||||
content, tool_calls, finish_reason, usage, reasoning_content = result
|
||||
usage = _combine_usage(retry_usage, usage)
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
@@ -259,7 +257,6 @@ class XAIGrokProvider(LLMProvider):
|
||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
||||
) -> LLMResponse:
|
||||
return await self._call_xai(
|
||||
messages,
|
||||
@@ -272,7 +269,6 @@ class XAIGrokProvider(LLMProvider):
|
||||
on_content_delta,
|
||||
on_thinking_delta,
|
||||
on_tool_call_delta,
|
||||
on_stream_recover,
|
||||
)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
@@ -292,14 +288,6 @@ def _build_reasoning_options(reasoning_effort: str | None) -> dict[str, str]:
|
||||
return options
|
||||
|
||||
|
||||
def _combine_usage(left: LLMUsage | None, right: LLMUsage | None) -> LLMUsage | None:
|
||||
if left is None:
|
||||
return right
|
||||
if right is None:
|
||||
return left
|
||||
return left + right
|
||||
|
||||
|
||||
def _build_headers(token: str, model: str) -> dict[str, str]:
|
||||
conversation_id = str(uuid.uuid4())
|
||||
return {
|
||||
@@ -320,6 +308,44 @@ def _build_headers(token: str, model: str) -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def _build_model_headers(token: XAIToken) -> dict[str, str]:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token.access}",
|
||||
"X-XAI-Token-Auth": "xai-grok-cli",
|
||||
"x-grok-client-version": XAI_CLIENT_VERSION,
|
||||
"x-grok-client-identifier": "nanobot",
|
||||
"x-grok-client-mode": "headless",
|
||||
"User-Agent": f"nanobot/{__version__} (python)",
|
||||
"accept": "application/json",
|
||||
}
|
||||
claims = _decode_access_token_claims(token.access)
|
||||
user_id = claims.get("sub")
|
||||
if claims.get("principal_type") == "Team":
|
||||
user_id = claims.get("principal_id") or user_id
|
||||
if isinstance(user_id, str) and user_id:
|
||||
headers["x-userid"] = user_id
|
||||
email = claims.get("email")
|
||||
if not isinstance(email, str) or "@" not in email:
|
||||
email = token.account_id if token.account_id and "@" in token.account_id else None
|
||||
if email:
|
||||
headers["x-email"] = email
|
||||
return headers
|
||||
|
||||
|
||||
def _decode_access_token_claims(token: str) -> dict[str, Any]:
|
||||
"""Read identity hints from the signed token; the server still authenticates it."""
|
||||
parts = token.split(".")
|
||||
if len(parts) < 2 or not parts[1]:
|
||||
return {}
|
||||
payload = parts[1]
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))
|
||||
claims = json.loads(decoded)
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
return cast(dict[str, Any], claims) if isinstance(claims, dict) else {}
|
||||
|
||||
|
||||
class _XAIHTTPError(RuntimeError):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -341,25 +367,65 @@ class _XAIHTTPError(RuntimeError):
|
||||
self.response_body = response_body
|
||||
|
||||
|
||||
class _XAIIncompleteHostedToolError(RuntimeError):
|
||||
"""A nominally successful xAI stream ended before a hosted tool did."""
|
||||
async def _fetch_xai_model_capabilities(
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
*,
|
||||
proxy: str | None = None,
|
||||
) -> dict[str, bool]:
|
||||
client_kwargs: dict[str, Any] = {"timeout": 10.0, "follow_redirects": False}
|
||||
if proxy:
|
||||
client_kwargs.update(proxy=proxy, trust_env=False)
|
||||
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
if response.status_code != 200:
|
||||
raw = response.content.decode("utf-8", "ignore")
|
||||
raise _build_xai_http_error(response.status_code, response.headers, raw)
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise RuntimeError("xAI model catalog returned invalid JSON.") from exc
|
||||
return _parse_xai_model_capabilities(payload)
|
||||
|
||||
should_retry = False # _call_xai already performs the one safe recovery attempt.
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
active_tools: list[dict[str, Any]],
|
||||
*,
|
||||
usage: LLMUsage | None,
|
||||
stream_output_emitted: bool = False,
|
||||
) -> None:
|
||||
names = [str(event.get("name") or "hosted_tool") for event in active_tools]
|
||||
super().__init__(
|
||||
"xAI ended the response before its hosted tool completed: " + ", ".join(names)
|
||||
def _parse_xai_model_capabilities(payload: Any) -> dict[str, bool]:
|
||||
if isinstance(payload, dict):
|
||||
payload = cast(dict[str, Any], payload)
|
||||
rows: object = payload.get("data")
|
||||
if not isinstance(rows, list):
|
||||
rows = payload.get("models")
|
||||
else:
|
||||
rows = payload
|
||||
if not isinstance(rows, list):
|
||||
return {}
|
||||
|
||||
capabilities: dict[str, bool] = {}
|
||||
for row_value in cast(list[object], rows):
|
||||
if not isinstance(row_value, dict):
|
||||
continue
|
||||
row = cast(dict[str, Any], row_value)
|
||||
meta_value = row.get("_meta")
|
||||
meta = cast(dict[str, Any], meta_value) if isinstance(meta_value, dict) else {}
|
||||
support_value = row.get("supportsBackendSearch")
|
||||
if not isinstance(support_value, bool):
|
||||
support_value = row.get("supports_backend_search")
|
||||
if not isinstance(support_value, bool):
|
||||
support_value = meta.get("supportsBackendSearch")
|
||||
if not isinstance(support_value, bool):
|
||||
support_value = meta.get("supports_backend_search")
|
||||
supports_backend_search = support_value if isinstance(support_value, bool) else False
|
||||
|
||||
identifiers = (
|
||||
row.get("model"),
|
||||
row.get("modelId"),
|
||||
row.get("id"),
|
||||
meta.get("model"),
|
||||
meta.get("modelId"),
|
||||
)
|
||||
self.tool_names = tuple(names)
|
||||
self.usage = usage
|
||||
self.stream_output_emitted = stream_output_emitted
|
||||
for identifier in identifiers:
|
||||
if isinstance(identifier, str) and identifier.strip():
|
||||
capabilities[_strip_model_prefix(identifier.strip())] = supports_backend_search
|
||||
return capabilities
|
||||
|
||||
|
||||
async def _request_xai(
|
||||
@@ -372,39 +438,10 @@ async def _request_xai(
|
||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||
) -> tuple[str, list[ToolCallRequest], str, LLMUsage | None, str | None]:
|
||||
active_hosted_tools: dict[str, dict[str, Any]] = {}
|
||||
stream_output_emitted = False
|
||||
|
||||
async def _forward_content_delta(delta: str) -> None:
|
||||
nonlocal stream_output_emitted
|
||||
if delta:
|
||||
stream_output_emitted = True
|
||||
if on_content_delta is not None:
|
||||
await on_content_delta(delta)
|
||||
|
||||
async def _forward_thinking_delta(delta: str) -> None:
|
||||
nonlocal stream_output_emitted
|
||||
if delta:
|
||||
stream_output_emitted = True
|
||||
if on_thinking_delta is not None:
|
||||
await on_thinking_delta(delta)
|
||||
|
||||
async def _track_and_forward_tool_event(event: dict[str, Any]) -> None:
|
||||
if event.get("kind") == "hosted_tool":
|
||||
call_id = event.get("call_id")
|
||||
if call_id:
|
||||
call_id = str(call_id)
|
||||
if event.get("phase") == "start":
|
||||
active_hosted_tools[call_id] = dict(event)
|
||||
elif event.get("phase") in {"end", "error"}:
|
||||
active_hosted_tools.pop(call_id, None)
|
||||
if on_tool_call_delta is not None:
|
||||
await on_tool_call_delta(event)
|
||||
|
||||
async def _on_response_event(event: dict[str, Any]) -> None:
|
||||
hosted_event = _xai_hosted_tool_event(event)
|
||||
if hosted_event is not None:
|
||||
await _track_and_forward_tool_event(hosted_event)
|
||||
if hosted_event is not None and on_tool_call_delta is not None:
|
||||
await on_tool_call_delta(hosted_event)
|
||||
|
||||
client_kwargs: dict[str, Any] = {"timeout": resolve_stream_idle_timeout_s()}
|
||||
if proxy:
|
||||
@@ -415,34 +452,13 @@ async def _request_xai(
|
||||
content = await response.aread()
|
||||
raw = content.decode("utf-8", "ignore")
|
||||
raise _build_xai_http_error(response.status_code, response.headers, raw)
|
||||
result = await consume_sse_with_reasoning(
|
||||
return await consume_sse_with_reasoning(
|
||||
response,
|
||||
on_content_delta=(_forward_content_delta if on_content_delta is not None else None),
|
||||
# Always observe tool events so protocol validation also works for
|
||||
# non-streaming callers that did not request UI progress callbacks.
|
||||
on_tool_call_delta=_track_and_forward_tool_event,
|
||||
on_reasoning_delta=(
|
||||
_forward_thinking_delta if on_thinking_delta is not None else None
|
||||
),
|
||||
on_response_event=_on_response_event,
|
||||
on_content_delta=on_content_delta,
|
||||
on_tool_call_delta=on_tool_call_delta,
|
||||
on_reasoning_delta=on_thinking_delta,
|
||||
on_response_event=_on_response_event if on_tool_call_delta else None,
|
||||
)
|
||||
if result[2] != "error" and active_hosted_tools:
|
||||
active = list(active_hosted_tools.values())
|
||||
for event in active:
|
||||
await _track_and_forward_tool_event(
|
||||
{
|
||||
**event,
|
||||
"phase": "error",
|
||||
"result": None,
|
||||
"error": "xAI ended the response before this hosted tool completed.",
|
||||
}
|
||||
)
|
||||
raise _XAIIncompleteHostedToolError(
|
||||
active,
|
||||
usage=result[3],
|
||||
stream_output_emitted=stream_output_emitted,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
@@ -456,33 +472,19 @@ def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"phase": "start",
|
||||
"call_id": str(call_id),
|
||||
"name": "x_search",
|
||||
"arguments": _xai_hosted_tool_arguments(event.get("input", event.get("arguments"))),
|
||||
"arguments": _xai_hosted_tool_arguments(
|
||||
event.get("input", event.get("arguments"))
|
||||
),
|
||||
"result": None,
|
||||
}
|
||||
|
||||
if event_type not in {"response.output_item.added", "response.output_item.done"}:
|
||||
if event_type != "response.output_item.done":
|
||||
return None
|
||||
item = event.get("item")
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
item = cast(dict[str, Any], item)
|
||||
item_type = item.get("type")
|
||||
if item_type == "x_search_call":
|
||||
call_id = item.get("id") or item.get("call_id") or event.get("item_id")
|
||||
if not call_id:
|
||||
return None
|
||||
phase = "start" if event_type == "response.output_item.added" else "end"
|
||||
return {
|
||||
"kind": "hosted_tool",
|
||||
"phase": phase,
|
||||
"call_id": str(call_id),
|
||||
"name": "x_search",
|
||||
"arguments": _xai_hosted_tool_arguments(item.get("action")),
|
||||
"result": (
|
||||
{"status": str(item.get("status") or "completed")} if phase == "end" else None
|
||||
),
|
||||
}
|
||||
if event_type != "response.output_item.done" or item_type != "custom_tool_call":
|
||||
if item.get("type") != "custom_tool_call":
|
||||
return None
|
||||
tool_name = item.get("name")
|
||||
if not isinstance(tool_name, str) or not tool_name.startswith("x_"):
|
||||
@@ -495,7 +497,9 @@ def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"phase": "end",
|
||||
"call_id": str(call_id),
|
||||
"name": "x_search",
|
||||
"arguments": _xai_hosted_tool_arguments(item.get("input", item.get("arguments"))),
|
||||
"arguments": _xai_hosted_tool_arguments(
|
||||
item.get("input", item.get("arguments"))
|
||||
),
|
||||
# Keep the useful search subtype, but do not persist large hosted results
|
||||
# in WebUI activity messages. The model answer already carries citations.
|
||||
"result": {"name": tool_name},
|
||||
@@ -604,8 +608,6 @@ def _xai_error_response(exc: Exception) -> LLMResponse:
|
||||
should_retry = True if should_retry is None else should_retry
|
||||
elif isinstance(exc, _XAIHTTPError):
|
||||
error_kind = "http"
|
||||
elif isinstance(exc, _XAIIncompleteHostedToolError):
|
||||
error_kind = "provider"
|
||||
if status_code is not None and should_retry is None:
|
||||
should_retry = _should_retry_status(
|
||||
int(status_code),
|
||||
@@ -615,11 +617,9 @@ def _xai_error_response(exc: Exception) -> LLMResponse:
|
||||
)
|
||||
message = str(exc).strip() or "unexpected error"
|
||||
retry_after = getattr(exc, "retry_after", None)
|
||||
usage = getattr(exc, "usage", None)
|
||||
return LLMResponse(
|
||||
content=f"Error calling xAI ({type(exc).__name__}): {message}",
|
||||
finish_reason="error",
|
||||
usage=usage if isinstance(usage, LLMUsage) else None,
|
||||
retry_after=retry_after,
|
||||
error_status_code=int(status_code) if status_code is not None else None,
|
||||
error_kind=error_kind,
|
||||
@@ -647,209 +647,3 @@ def _should_retry_status(
|
||||
)
|
||||
)
|
||||
return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500 # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
def get_xai_grok_model_catalog(proxy: str | None = None) -> OAuthModelCatalogSnapshot:
|
||||
token = get_xai_oauth_login_status()
|
||||
account_key = _catalog_account_key(getattr(token, "account_id", None))
|
||||
cache_key = f"{get_xai_oauth_storage_path()}\0{account_key}\0{proxy or ''}"
|
||||
return _XAI_GROK_MODEL_CATALOG.get(cache_key=cache_key, proxy=proxy)
|
||||
|
||||
|
||||
def invalidate_xai_grok_model_catalog() -> None:
|
||||
_XAI_GROK_MODEL_CATALOG.invalidate()
|
||||
|
||||
|
||||
def _fetch_xai_grok_models(proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||
token = get_xai_oauth_token(proxy=proxy)
|
||||
client_kwargs: dict[str, Any] = {"timeout": 10.0, "follow_redirects": False}
|
||||
if proxy:
|
||||
client_kwargs.update(proxy=proxy, trust_env=False)
|
||||
with httpx.Client(**client_kwargs) as client:
|
||||
response = client.get(
|
||||
DEFAULT_XAI_GROK_MODELS_URL,
|
||||
headers=_build_xai_model_headers(token.access, token.account_id),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _parse_xai_grok_models(response.json())
|
||||
|
||||
|
||||
def _parse_xai_grok_models(payload: Any) -> tuple[ProviderModelSpec, ...]:
|
||||
if isinstance(payload, dict):
|
||||
payload_mapping = cast(dict[str, Any], payload)
|
||||
rows: object = payload_mapping.get("data")
|
||||
if not isinstance(rows, list):
|
||||
rows = payload_mapping.get("models")
|
||||
else:
|
||||
rows = payload
|
||||
if not isinstance(rows, list):
|
||||
return ()
|
||||
|
||||
fallback_models = _oauth_fallback_models("xai_grok")
|
||||
fallback_by_id = {model.id.split("/", 1)[-1]: model for model in fallback_models}
|
||||
models: list[ProviderModelSpec] = []
|
||||
seen: set[str] = set()
|
||||
for value in cast(list[object], rows):
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
row = cast(dict[str, Any], value)
|
||||
meta = _catalog_mapping(row.get("_meta"))
|
||||
raw_id = next(
|
||||
(
|
||||
candidate.strip()
|
||||
for candidate in (
|
||||
row.get("id"),
|
||||
row.get("model"),
|
||||
row.get("modelId"),
|
||||
row.get("name"),
|
||||
meta.get("id"),
|
||||
meta.get("model"),
|
||||
meta.get("modelId"),
|
||||
)
|
||||
if isinstance(candidate, str) and candidate.strip()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if raw_id is None:
|
||||
continue
|
||||
wire_id = raw_id.split("/", 1)[-1]
|
||||
if wire_id in seen:
|
||||
continue
|
||||
seen.add(wire_id)
|
||||
fallback = fallback_by_id.get(wire_id)
|
||||
label = _catalog_first_text(row, "display_name", "label", "name") or _catalog_first_text(
|
||||
meta,
|
||||
"display_name",
|
||||
"label",
|
||||
"name",
|
||||
)
|
||||
if not label or label == raw_id:
|
||||
label = fallback.label if fallback is not None else wire_id
|
||||
models.append(
|
||||
ProviderModelSpec(
|
||||
id=f"xai-grok/{wire_id}",
|
||||
label=label,
|
||||
description=(
|
||||
_catalog_first_text(row, "description")
|
||||
or _catalog_first_text(meta, "description")
|
||||
or (fallback.description if fallback is not None else "")
|
||||
),
|
||||
owned_by=(
|
||||
_catalog_first_text(row, "owned_by", "owner", "organization")
|
||||
or _catalog_first_text(meta, "owned_by", "owner", "organization")
|
||||
or (fallback.owned_by if fallback is not None else "xAI")
|
||||
),
|
||||
context_window=(
|
||||
_catalog_positive_int(row, "context_window", "context_length")
|
||||
or _catalog_positive_int(meta, "context_window", "context_length")
|
||||
or (fallback.context_window if fallback is not None else None)
|
||||
),
|
||||
reasoning_efforts=_catalog_reasoning_efforts(
|
||||
row.get("reasoning_efforts", meta.get("reasoning_efforts"))
|
||||
),
|
||||
supports_backend_search=_catalog_bool_field(
|
||||
row,
|
||||
"supports_backend_search",
|
||||
"supportsBackendSearch",
|
||||
),
|
||||
)
|
||||
)
|
||||
return tuple(models)
|
||||
|
||||
|
||||
def _build_xai_model_headers(access_token: str, account_id: str | None) -> dict[str, str]:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"X-XAI-Token-Auth": "xai-grok-cli",
|
||||
"x-grok-client-version": XAI_CLIENT_VERSION,
|
||||
"x-grok-client-identifier": "nanobot",
|
||||
"x-grok-client-mode": "headless",
|
||||
"User-Agent": f"nanobot/{__version__} (python)",
|
||||
"accept": "application/json",
|
||||
}
|
||||
claims = _decode_access_token_claims(access_token)
|
||||
user_id = claims.get("sub")
|
||||
if claims.get("principal_type") == "Team":
|
||||
user_id = claims.get("principal_id") or user_id
|
||||
if isinstance(user_id, str) and user_id:
|
||||
headers["x-userid"] = user_id
|
||||
email = claims.get("email")
|
||||
if not isinstance(email, str) or "@" not in email:
|
||||
email = account_id if account_id and "@" in account_id else None
|
||||
if email:
|
||||
headers["x-email"] = email
|
||||
return headers
|
||||
|
||||
|
||||
def _decode_access_token_claims(token: str) -> dict[str, Any]:
|
||||
parts = token.split(".")
|
||||
if len(parts) < 2 or not parts[1]:
|
||||
return {}
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4))
|
||||
claims = json.loads(decoded)
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
return cast(dict[str, Any], claims) if isinstance(claims, dict) else {}
|
||||
|
||||
|
||||
def _oauth_fallback_models(provider_name: str) -> tuple[ProviderModelSpec, ...]:
|
||||
spec = find_by_name(provider_name)
|
||||
assert spec is not None
|
||||
return spec.builtin_models
|
||||
|
||||
|
||||
def _catalog_account_key(account_id: object) -> str:
|
||||
value = account_id if isinstance(account_id, str) else ""
|
||||
return hashlib.sha256(value.encode()).hexdigest()[:16] if value else "anonymous"
|
||||
|
||||
|
||||
def _catalog_mapping(value: Any) -> dict[str, Any]:
|
||||
return cast(dict[str, Any], value) if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _catalog_first_text(row: dict[str, Any], *keys: str) -> str:
|
||||
for key in keys:
|
||||
value = row.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _catalog_positive_int(row: dict[str, Any], *keys: str) -> int | None:
|
||||
for key in keys:
|
||||
value = row.get(key)
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
|
||||
return int(value)
|
||||
return None
|
||||
|
||||
|
||||
def _catalog_bool_field(row: dict[str, Any], *keys: str) -> bool:
|
||||
for key in keys:
|
||||
value = row.get(key)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
meta = row.get("_meta")
|
||||
return _catalog_bool_field(_catalog_mapping(meta), *keys) if isinstance(meta, dict) else False
|
||||
|
||||
|
||||
def _catalog_reasoning_efforts(value: Any) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
efforts: list[str] = []
|
||||
for item in cast(list[object], value):
|
||||
if isinstance(item, str):
|
||||
effort = item.strip()
|
||||
elif isinstance(item, dict):
|
||||
effort = _catalog_first_text(cast(dict[str, Any], item), "effort", "value", "id")
|
||||
else:
|
||||
effort = ""
|
||||
if effort and effort not in efforts:
|
||||
efforts.append(effort)
|
||||
return tuple(efforts)
|
||||
|
||||
|
||||
_XAI_GROK_MODEL_CATALOG = OAuthModelCatalog(
|
||||
fallback_models=_oauth_fallback_models("xai_grok"),
|
||||
fetch=_fetch_xai_grok_models,
|
||||
)
|
||||
|
||||
@@ -147,10 +147,10 @@ def prepare_save_boundary(ctx: TurnContext) -> None:
|
||||
if ctx.session is not None:
|
||||
clear_internal_continuation_state(ctx.session.metadata)
|
||||
|
||||
assert ctx.transcript_input is not None
|
||||
ctx.save_skip = _save_skip_for_turn(
|
||||
message_metadata=ctx.msg.metadata,
|
||||
initial_message_count=ctx.transcript_input.message_count,
|
||||
initial_message_count=len(ctx.initial_messages),
|
||||
history_count=len(ctx.history),
|
||||
input_persisted_early=ctx.input_persisted_early,
|
||||
)
|
||||
|
||||
@@ -185,6 +185,7 @@ def _save_skip_for_turn(
|
||||
*,
|
||||
message_metadata: Mapping[str, Any] | None,
|
||||
initial_message_count: int,
|
||||
history_count: int,
|
||||
input_persisted_early: bool,
|
||||
) -> int:
|
||||
"""Return the persisted-message append boundary for this turn."""
|
||||
@@ -192,7 +193,10 @@ def _save_skip_for_turn(
|
||||
return initial_message_count
|
||||
if internal_continuation_inbound(message_metadata):
|
||||
return initial_message_count
|
||||
if not input_persisted_early:
|
||||
# build_messages may merge the current message into a same-role history tail.
|
||||
# Runner-appended messages start at initial_message_count in either shape.
|
||||
has_standalone_current = initial_message_count > 1 + history_count
|
||||
if has_standalone_current and not input_persisted_early:
|
||||
return initial_message_count - 1
|
||||
return initial_message_count
|
||||
|
||||
|
||||
@@ -28,10 +28,6 @@ from nanobot.config.loader import resolve_config_env_vars
|
||||
from nanobot.config.schema import Config, FallbackCandidate, ModelPresetConfig, ProviderConfig
|
||||
from nanobot.providers.image_generation import get_image_gen_provider
|
||||
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
|
||||
from nanobot.providers.oauth_model_catalog import (
|
||||
get_oauth_model_catalog,
|
||||
invalidate_oauth_model_catalog,
|
||||
)
|
||||
from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name
|
||||
from nanobot.webui.settings_contracts import (
|
||||
QueryParams,
|
||||
@@ -665,30 +661,6 @@ def provider_models_payload(
|
||||
"models": rows,
|
||||
"model_count": len(rows),
|
||||
}
|
||||
if catalog_kind == "hybrid":
|
||||
proxy = _resolve_env_placeholders(provider_config.proxy)
|
||||
catalog = get_oauth_model_catalog(spec.name, proxy=proxy)
|
||||
rows = [
|
||||
{
|
||||
"id": model.id,
|
||||
"label": model.label or None,
|
||||
"description": model.description or None,
|
||||
"owned_by": model.owned_by or spec.label,
|
||||
"context_window": model.context_window,
|
||||
"reasoning_efforts": list(model.reasoning_efforts),
|
||||
"supports_backend_search": model.supports_backend_search,
|
||||
}
|
||||
for model in catalog.models
|
||||
]
|
||||
return {
|
||||
**base_payload,
|
||||
"status": "available",
|
||||
"source": catalog.source,
|
||||
"models": rows,
|
||||
"model_count": len(rows),
|
||||
"message": catalog.message,
|
||||
"fetched_at": catalog.fetched_at,
|
||||
}
|
||||
|
||||
api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base
|
||||
if spec.name == "openai" and not api_base:
|
||||
@@ -1534,7 +1506,6 @@ def login_oauth_provider(
|
||||
token = login_github_copilot(print_fn=lambda _message: None)
|
||||
if not (token and token.access):
|
||||
raise WebUISettingsError("OAuth login failed", status=401)
|
||||
invalidate_oauth_model_catalog(spec.name)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
if spec.name == "xai_grok":
|
||||
@@ -1620,7 +1591,6 @@ def complete_oauth_provider(
|
||||
oauth_flows.remove(spec.name, flow_id, flow, cancel=False)
|
||||
if not token.access:
|
||||
raise WebUISettingsError("OAuth login failed", status=401)
|
||||
invalidate_oauth_model_catalog(spec.name)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
@@ -1659,7 +1629,6 @@ def logout_oauth_provider(
|
||||
|
||||
oauth_flows.clear(spec.name)
|
||||
logout_xai_oauth()
|
||||
invalidate_oauth_model_catalog(spec.name)
|
||||
return settings_payload(config_path=config_path)
|
||||
else:
|
||||
raise WebUISettingsError("OAuth logout is not supported for this provider")
|
||||
@@ -1667,7 +1636,6 @@ def logout_oauth_provider(
|
||||
for path in (token_path, token_path.with_suffix(".lock")):
|
||||
with suppress(FileNotFoundError):
|
||||
path.unlink()
|
||||
invalidate_oauth_model_catalog(spec.name)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.agent.tools.filesystem import ReadFileTool
|
||||
@@ -149,10 +148,7 @@ async def test_pending_document_attachment_keeps_body_out_of_prompt(
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(
|
||||
history=[{"role": "user", "content": "hello"}],
|
||||
current_message=None,
|
||||
),
|
||||
[{"role": "user", "content": "hello"}],
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
||||
pending_queue=pending_queue,
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import ContextBuilder, TranscriptInput
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.runtime_context import RuntimeContextBlock
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -403,15 +403,6 @@ class TestBuildMessages:
|
||||
assert "user-only runtime context" not in messages[-1]["content"]
|
||||
assert "_meta" not in messages[-1]
|
||||
|
||||
def test_compatibility_builder_merges_system_role_without_history(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
|
||||
messages = builder.build_messages([], "system event", current_role="system")
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["role"] == "system"
|
||||
assert str(messages[0]["content"]).endswith("system event")
|
||||
|
||||
def test_explicit_skill_reference_loads_full_instructions_for_this_turn(self, tmp_path):
|
||||
skill_dir = tmp_path / "skills" / "review"
|
||||
skill_dir.mkdir(parents=True)
|
||||
@@ -481,20 +472,6 @@ class TestBuildMessages:
|
||||
assert "previous user message" in str(messages[1]["content"])
|
||||
assert "new message" in str(messages[1]["content"])
|
||||
|
||||
def test_structured_transcript_preserves_fresh_turn_boundary(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
transcript = TranscriptInput(
|
||||
history=[{"role": "user", "content": "previous user message"}],
|
||||
current_message="new message",
|
||||
)
|
||||
|
||||
messages = builder.build_transcript(transcript)
|
||||
|
||||
assert [message["role"] for message in messages] == ["system", "user", "user"]
|
||||
assert messages[-2]["content"] == "previous user message"
|
||||
assert messages[-1]["content"] == "new message"
|
||||
assert transcript.message_count == 3
|
||||
|
||||
def test_current_message_can_be_built_without_history_merge(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
current = builder.build_current_message(
|
||||
|
||||
@@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.hook import (
|
||||
AgentHook,
|
||||
AgentHookContext,
|
||||
@@ -460,7 +459,7 @@ async def test_agent_loop_extra_hook_receives_calls(tmp_path):
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[{"role": "user", "content": "hi"}], current_message=None),
|
||||
[{"role": "user", "content": "hi"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
)
|
||||
|
||||
@@ -505,7 +504,7 @@ async def test_agent_loop_turn_hook_factories_receive_context(tmp_path):
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
await loop._run_agent_loop(
|
||||
TranscriptInput(history=[{"role": "user", "content": "hi"}], current_message=None),
|
||||
[{"role": "user", "content": "hi"}],
|
||||
runtime=runtime,
|
||||
on_progress=on_progress,
|
||||
request_context=RequestContext(
|
||||
@@ -552,7 +551,7 @@ async def test_agent_loop_extra_hook_error_isolation(tmp_path):
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[{"role": "user", "content": "hi"}], current_message=None),
|
||||
[{"role": "user", "content": "hi"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
)
|
||||
|
||||
@@ -578,9 +577,7 @@ async def test_agent_loop_extra_hooks_do_not_swallow_loop_hook_errors(tmp_path):
|
||||
|
||||
with pytest.raises(RuntimeError, match="progress failed"):
|
||||
await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_progress=bad_progress,
|
||||
[], runtime=loop.llm_runtime(), on_progress=bad_progress
|
||||
)
|
||||
|
||||
|
||||
@@ -599,8 +596,7 @@ async def test_agent_loop_no_hooks_backward_compat(tmp_path):
|
||||
loop.max_iterations = 2
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
[], runtime=loop.llm_runtime()
|
||||
)
|
||||
assert result.final_content == (
|
||||
"I reached the maximum number of tool call iterations (2) "
|
||||
|
||||
@@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.hooks import create_file_edit_activity_hook
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.context import current_request_context
|
||||
@@ -85,9 +84,7 @@ class TestToolEventProgress:
|
||||
progress.append((content, tool_hint, tool_events))
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_progress=on_progress,
|
||||
[], runtime=loop.llm_runtime(), on_progress=on_progress
|
||||
)
|
||||
|
||||
assert result.final_content == "Done"
|
||||
@@ -158,9 +155,7 @@ class TestToolEventProgress:
|
||||
file_events.extend(file_edit_events)
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_progress=on_progress,
|
||||
[], runtime=loop.llm_runtime(), on_progress=on_progress
|
||||
)
|
||||
|
||||
assert result.final_content == "Done"
|
||||
@@ -230,9 +225,7 @@ class TestToolEventProgress:
|
||||
)
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_progress=on_progress,
|
||||
[], runtime=loop.llm_runtime(), on_progress=on_progress
|
||||
)
|
||||
|
||||
assert result.final_content == "Done"
|
||||
@@ -270,9 +263,7 @@ class TestToolEventProgress:
|
||||
file_events.extend(file_edit_events)
|
||||
|
||||
await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_progress=on_progress,
|
||||
[], runtime=loop.llm_runtime(), on_progress=on_progress
|
||||
)
|
||||
|
||||
assert file_events == []
|
||||
@@ -1028,7 +1019,7 @@ class TestToolEventProgress:
|
||||
progress.append((content, tool_hint, tool_events))
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
[],
|
||||
runtime=loop.llm_runtime(),
|
||||
on_progress=on_progress,
|
||||
on_stream=on_stream,
|
||||
|
||||
@@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.goal_permission import goal_mutation_allowed, goal_mutation_permission
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.bus.outbound_events import StreamedResponseEvent
|
||||
@@ -56,7 +55,7 @@ async def test_ephemeral_runner_enters_and_restores_turn_scopes(tmp_path):
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
[],
|
||||
runtime=loop.llm_runtime(),
|
||||
ephemeral=True,
|
||||
turn_scopes=[goal_mutation_permission(True)],
|
||||
@@ -341,8 +340,7 @@ async def test_loop_max_iterations_message_stays_stable(tmp_path):
|
||||
loop.max_iterations = 2
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
[], runtime=loop.llm_runtime()
|
||||
)
|
||||
|
||||
assert result.final_content == (
|
||||
@@ -364,7 +362,7 @@ async def test_loop_goal_turn_uses_standard_iteration_budget(tmp_path):
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
[],
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(
|
||||
channel="cli",
|
||||
@@ -403,7 +401,7 @@ async def test_loop_stream_filter_handles_think_only_prefix_without_crashing(tmp
|
||||
endings.append(resuming)
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
[],
|
||||
runtime=loop.llm_runtime(),
|
||||
on_stream=on_stream,
|
||||
on_stream_end=on_stream_end,
|
||||
@@ -430,9 +428,7 @@ async def test_loop_stream_filter_hides_partial_trailing_think_prefix(tmp_path):
|
||||
deltas.append(delta)
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_stream=on_stream,
|
||||
[], runtime=loop.llm_runtime(), on_stream=on_stream
|
||||
)
|
||||
|
||||
assert result.final_content == "Hello World"
|
||||
@@ -455,9 +451,7 @@ async def test_loop_stream_filter_hides_complete_trailing_think_tag(tmp_path):
|
||||
deltas.append(delta)
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_stream=on_stream,
|
||||
[], runtime=loop.llm_runtime(), on_stream=on_stream
|
||||
)
|
||||
|
||||
assert result.final_content == "Hello World"
|
||||
@@ -478,8 +472,7 @@ async def test_loop_retries_think_only_final_response(tmp_path):
|
||||
loop.provider.chat_with_retry = chat_with_retry
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
[], runtime=loop.llm_runtime()
|
||||
)
|
||||
|
||||
assert result.final_content == "Recovered answer"
|
||||
|
||||
@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.context import ContextBuilder, TranscriptInput
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.runner import AgentRunResult
|
||||
from nanobot.agent.tools.context import RequestContext, request_context
|
||||
@@ -79,13 +79,6 @@ def _agent_run_result(
|
||||
)
|
||||
|
||||
|
||||
def _assembled_messages(
|
||||
builder: ContextBuilder,
|
||||
transcript_input: TranscriptInput,
|
||||
) -> list[dict]:
|
||||
return builder.build_transcript(transcript_input, include_memory=False)
|
||||
|
||||
|
||||
def _mk_loop() -> AgentLoop:
|
||||
loop = AgentLoop.__new__(AgentLoop)
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
@@ -937,13 +930,10 @@ async def test_runtime_checkpoint_keeps_provider_state_out_of_public_metadata(
|
||||
session = loop.sessions.get_or_create("cli:private-checkpoint")
|
||||
|
||||
await loop._run_agent_loop(
|
||||
TranscriptInput(
|
||||
history=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "question"},
|
||||
],
|
||||
current_message=None,
|
||||
),
|
||||
[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "question"},
|
||||
],
|
||||
runtime=loop.llm_runtime(),
|
||||
session=session,
|
||||
)
|
||||
@@ -1018,7 +1008,7 @@ async def test_subagent_followup_state_is_durable_before_prompt_assembly(
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
loop.provider.can_resume_conversation_state.return_value = True
|
||||
loop.context.build_system_prompt = MagicMock( # type: ignore[method-assign]
|
||||
loop._build_initial_messages = MagicMock( # type: ignore[method-assign]
|
||||
side_effect=RuntimeError("prompt boom"),
|
||||
)
|
||||
session = loop.sessions.get_or_create("cli:subagent-prompt-crash")
|
||||
@@ -1051,8 +1041,8 @@ async def test_subagent_redelivery_does_not_duplicate_staged_provider_input(
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
loop.provider.can_resume_conversation_state.return_value = True
|
||||
build_system_prompt = loop.context.build_system_prompt
|
||||
loop.context.build_system_prompt = MagicMock( # type: ignore[method-assign]
|
||||
build_initial_messages = loop._build_initial_messages
|
||||
loop._build_initial_messages = MagicMock( # type: ignore[method-assign]
|
||||
side_effect=RuntimeError("prompt boom"),
|
||||
)
|
||||
session = loop.sessions.get_or_create("cli:subagent-redelivery")
|
||||
@@ -1076,7 +1066,7 @@ async def test_subagent_redelivery_does_not_duplicate_staged_provider_input(
|
||||
message.get("content")
|
||||
for message in persisted.provider_state.pending_messages
|
||||
].count("subagent result") == 1
|
||||
loop.context.build_system_prompt = build_system_prompt # type: ignore[method-assign]
|
||||
loop._build_initial_messages = build_initial_messages # type: ignore[method-assign]
|
||||
loop._run_agent_loop = AsyncMock( # type: ignore[method-assign]
|
||||
side_effect=RuntimeError("provider boom"),
|
||||
)
|
||||
@@ -1329,8 +1319,7 @@ async def test_internal_continuation_queues_turn_without_fake_user_history(
|
||||
|
||||
calls: list[dict] = []
|
||||
|
||||
async def fake_run_agent_loop(transcript_input, *, metadata=None, **_kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
async def fake_run_agent_loop(initial_messages, *, metadata=None, **_kwargs):
|
||||
calls.append({"initial_messages": initial_messages, "metadata": metadata})
|
||||
if len(calls) == 1:
|
||||
return _agent_run_result(
|
||||
@@ -1398,9 +1387,8 @@ async def test_internal_continuation_preserves_streaming_route_metadata(
|
||||
|
||||
calls = 0
|
||||
|
||||
async def fake_run_agent_loop(transcript_input, *, on_stream=None, on_stream_end=None, **_kwargs):
|
||||
async def fake_run_agent_loop(initial_messages, *, on_stream=None, on_stream_end=None, **_kwargs):
|
||||
nonlocal calls
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
return _agent_run_result(
|
||||
@@ -1472,9 +1460,8 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
|
||||
|
||||
calls = 0
|
||||
|
||||
async def fake_run_agent_loop(transcript_input, **_kwargs):
|
||||
async def fake_run_agent_loop(initial_messages, **_kwargs):
|
||||
nonlocal calls
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
return _agent_run_result(
|
||||
@@ -1636,7 +1623,7 @@ async def test_run_agent_loop_continuation_reads_latest_goal_metadata(
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
[],
|
||||
runtime=runtime,
|
||||
session=session,
|
||||
request_context=RequestContext(
|
||||
@@ -1766,7 +1753,7 @@ async def test_stop_preserves_runtime_checkpoint_for_next_turn(tmp_path: Path) -
|
||||
|
||||
checkpoint_saved = asyncio.Event()
|
||||
|
||||
async def interrupted_run_agent_loop(_transcript_input, *, session=None, **_kwargs):
|
||||
async def interrupted_run_agent_loop(_initial_messages, *, session=None, **_kwargs):
|
||||
assert session is not None
|
||||
loop._set_runtime_checkpoint(
|
||||
session,
|
||||
@@ -1826,8 +1813,7 @@ async def test_stop_preserves_runtime_checkpoint_for_next_turn(tmp_path: Path) -
|
||||
assert interrupted.metadata.get(AgentLoop._PENDING_USER_TURN_KEY) is True
|
||||
assert interrupted.metadata.get(AgentLoop._RUNTIME_CHECKPOINT_KEY) is not None
|
||||
|
||||
async def resumed_run_agent_loop(transcript_input, **_kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
async def resumed_run_agent_loop(initial_messages, **_kwargs):
|
||||
return _agent_run_result(
|
||||
"next answer",
|
||||
[*initial_messages, {"role": "assistant", "content": "next answer"}],
|
||||
@@ -1878,8 +1864,7 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
|
||||
record_runtime = MagicMock(wraps=loop.runtime_event_publisher.record_turn_runtime)
|
||||
loop.runtime_event_publisher.record_turn_runtime = record_runtime
|
||||
|
||||
async def fake_run_agent_loop(transcript_input, **kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
async def fake_run_agent_loop(initial_messages, **kwargs):
|
||||
seen["initial_messages"] = initial_messages
|
||||
seen["runtime"] = kwargs["runtime"]
|
||||
seen["request_context"] = kwargs["request_context"]
|
||||
@@ -1955,8 +1940,7 @@ async def test_turn_usage_is_persisted_with_the_saved_session(tmp_path: Path) ->
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
turn_usage = LLMUsage.reported(input_tokens=64, output_tokens=9)
|
||||
|
||||
async def fake_run_agent_loop(transcript_input, **_kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
async def fake_run_agent_loop(initial_messages, **_kwargs):
|
||||
return _agent_run_result(
|
||||
"done",
|
||||
[*initial_messages, {"role": "assistant", "content": "done"}],
|
||||
@@ -1982,8 +1966,7 @@ async def test_system_subagent_followup_does_not_log_content(tmp_path: Path) ->
|
||||
return_value=False
|
||||
)
|
||||
|
||||
async def fake_run_agent_loop(transcript_input, **_kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
async def fake_run_agent_loop(initial_messages, **_kwargs):
|
||||
return _agent_run_result(
|
||||
"done",
|
||||
[*initial_messages, {"role": "assistant", "content": "done"}],
|
||||
@@ -2039,8 +2022,7 @@ async def test_system_subagent_followup_uses_common_turn_lifecycle(tmp_path: Pat
|
||||
|
||||
setattr(loop, name, record)
|
||||
|
||||
async def fake_run_agent_loop(transcript_input, **_kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
async def fake_run_agent_loop(initial_messages, **_kwargs):
|
||||
return _agent_run_result(
|
||||
"done",
|
||||
[*initial_messages, {"role": "assistant", "content": "done"}],
|
||||
@@ -2083,8 +2065,7 @@ async def test_multiple_subagent_followups_all_persist_as_standalone_history(tmp
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
async def fake_run_agent_loop(transcript_input, **_kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
async def fake_run_agent_loop(initial_messages, **_kwargs):
|
||||
return _agent_run_result(
|
||||
"ack",
|
||||
[*initial_messages, {"role": "assistant", "content": "ack"}],
|
||||
@@ -2215,8 +2196,7 @@ async def test_system_subagent_followup_uses_thread_session_and_slack_metadata(t
|
||||
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
async def fake_run_agent_loop(transcript_input, **kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
async def fake_run_agent_loop(initial_messages, **kwargs):
|
||||
seen["initial_messages"] = initial_messages
|
||||
seen["request_context"] = kwargs["request_context"]
|
||||
return _agent_run_result(
|
||||
@@ -2272,11 +2252,8 @@ async def test_turn_after_unanswered_user_keeps_tool_call_pairing(tmp_path: Path
|
||||
session.add_message("user", "earlier question that never got an answer")
|
||||
loop.sessions.save(session)
|
||||
|
||||
async def fake_run_agent_loop(transcript_input, **_kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
assert [m["role"] for m in initial_messages] == ["system", "user", "user"]
|
||||
assert initial_messages[-2]["content"] == "earlier question that never got an answer"
|
||||
assert initial_messages[-1]["content"] == "and another thing"
|
||||
async def fake_run_agent_loop(initial_messages, **_kwargs):
|
||||
assert [m["role"] for m in initial_messages] == ["system", "user"]
|
||||
return _agent_run_result(
|
||||
"done",
|
||||
[
|
||||
|
||||
@@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.context import (
|
||||
RequestContext,
|
||||
@@ -134,7 +133,7 @@ async def test_loop_binds_request_context_for_tool_execution(tmp_path: Path) ->
|
||||
metadata = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}}
|
||||
runtime = loop.llm_runtime()
|
||||
await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
[],
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(
|
||||
channel="slack",
|
||||
@@ -235,7 +234,7 @@ async def test_agent_loop_restores_outer_request_context_after_runner_exception(
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="runner failed"):
|
||||
await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
[],
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(
|
||||
channel="slack",
|
||||
|
||||
@@ -10,7 +10,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from agent.runner_helpers import make_run_spec
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import (
|
||||
LLMProvider,
|
||||
@@ -35,35 +34,6 @@ def _make_usage_spec(provider, tools):
|
||||
)
|
||||
|
||||
|
||||
def test_initial_transcript_is_built_from_structured_turn_input() -> None:
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
transcript_input = TranscriptInput(
|
||||
history=[{"role": "user", "content": "earlier"}],
|
||||
current_message="fresh",
|
||||
)
|
||||
expected = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "earlier"},
|
||||
{"role": "user", "content": "fresh"},
|
||||
]
|
||||
transcript_builder = MagicMock(return_value=expected)
|
||||
spec = make_run_spec(
|
||||
provider,
|
||||
initial_messages=None,
|
||||
transcript_input=transcript_input,
|
||||
transcript_builder=transcript_builder,
|
||||
tools=MagicMock(),
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
)
|
||||
|
||||
assert AgentRunner._initial_transcript(spec) == expected
|
||||
transcript_builder.assert_called_once_with(transcript_input)
|
||||
|
||||
|
||||
def test_usage_or_estimate_replaces_reported_zero_for_content(monkeypatch) -> None:
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
|
||||
@@ -52,31 +52,7 @@ async def test_runner_returns_tool_exception_to_model_for_recovery():
|
||||
{"name": "list_dir", "status": "error", "detail": "boom"}
|
||||
]
|
||||
tool_message = next(message for message in result.messages if message.get("role") == "tool")
|
||||
retry_hint = "[Analyze the error above and try a different approach.]"
|
||||
assert "Error: RuntimeError: boom" in tool_message["content"]
|
||||
assert tool_message["content"].count(retry_hint) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_execution_does_not_duplicate_existing_retry_hint():
|
||||
retry_hint = "\n\n[Analyze the error above and try a different approach.]"
|
||||
tools = SimpleNamespace(
|
||||
execute=AsyncMock(return_value=ToolResult.error("Error: boom" + retry_hint)),
|
||||
)
|
||||
|
||||
results, events = await execute_tool_calls(
|
||||
tools,
|
||||
[ToolCallRequest(id="call_1", name="list_dir", arguments={})],
|
||||
concurrent=False,
|
||||
external_lookup_counts={},
|
||||
workspace_violation_counts={},
|
||||
hook=AgentHook(),
|
||||
context=AgentHookContext(iteration=0, messages=[]),
|
||||
)
|
||||
|
||||
assert results == ["Error: boom" + retry_hint]
|
||||
assert results[0].count(retry_hint) == 1
|
||||
assert events[0]["status"] == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -10,7 +10,6 @@ import pytest
|
||||
|
||||
from agent.runner_helpers import make_run_spec
|
||||
from nanobot.agent.automation_turns import publish_next_deferred_turn
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
@@ -618,7 +617,7 @@ async def test_loop_injected_followup_preserves_image_media(tmp_path):
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[{"role": "user", "content": "hello"}], current_message=None),
|
||||
[{"role": "user", "content": "hello"}],
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
||||
pending_queue=pending_queue,
|
||||
@@ -712,10 +711,7 @@ async def test_pending_injection_resolves_its_own_runtime_context(tmp_path):
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(
|
||||
history=[{"role": "user", "content": "initial message from user A"}],
|
||||
current_message=None,
|
||||
),
|
||||
[{"role": "user", "content": "initial message from user A"}],
|
||||
runtime=runtime,
|
||||
session=session,
|
||||
request_context=RequestContext(
|
||||
@@ -816,7 +812,7 @@ async def test_subagent_pending_injection_is_hidden_history_and_not_merged(tmp_p
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[{"role": "user", "content": "hello"}], current_message=None),
|
||||
[{"role": "user", "content": "hello"}],
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
||||
pending_queue=pending_queue,
|
||||
@@ -1480,7 +1476,7 @@ async def test_pending_queue_preserves_overflow_for_next_injection_cycle(tmp_pat
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[{"role": "user", "content": "hello"}], current_message=None),
|
||||
[{"role": "user", "content": "hello"}],
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
||||
pending_queue=pending_queue,
|
||||
|
||||
@@ -9,7 +9,6 @@ channels, gated by ``context.streamed_reasoning`` rather than
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
@@ -83,18 +82,6 @@ class _LifecycleRecordingHook(AgentHook):
|
||||
self.events.append(f"hosted_tool:{event.get('phase')}")
|
||||
|
||||
|
||||
class _BlockingReasoningEndHook(_LifecycleRecordingHook):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.reasoning_end_started = asyncio.Event()
|
||||
self.release_reasoning_end = asyncio.Event()
|
||||
|
||||
async def emit_reasoning_end(self) -> None:
|
||||
self.reasoning_end_started.set()
|
||||
await self.release_reasoning_end.wait()
|
||||
await super().emit_reasoning_end()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_preserves_reasoning_fields_in_assistant_history():
|
||||
"""Reasoning fields ride along on the persisted assistant message so
|
||||
@@ -567,86 +554,6 @@ async def test_runner_closes_native_reasoning_before_hosted_tool_event():
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_closes_native_reasoning_when_stream_is_cancelled():
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
reasoning_started = asyncio.Event()
|
||||
release_provider = asyncio.Event()
|
||||
|
||||
async def chat_stream_with_retry(
|
||||
*, on_thinking_delta=None, **kwargs
|
||||
):
|
||||
if on_thinking_delta:
|
||||
await on_thinking_delta("inspect")
|
||||
reasoning_started.set()
|
||||
await release_provider.wait()
|
||||
raise AssertionError("the cancelled provider call should not complete")
|
||||
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
hook = _LifecycleRecordingHook()
|
||||
|
||||
task = asyncio.create_task(AgentRunner().run(make_run_spec(
|
||||
provider,
|
||||
initial_messages=[{"role": "user", "content": "inspect"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=hook,
|
||||
)))
|
||||
await reasoning_started.wait()
|
||||
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert hook.events == ["reasoning:inspect", "reasoning_end"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_settles_native_reasoning_end_before_propagating_cancellation():
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
|
||||
async def chat_stream_with_retry(
|
||||
*, on_content_delta=None, on_thinking_delta=None, **kwargs
|
||||
):
|
||||
if on_thinking_delta:
|
||||
await on_thinking_delta("inspect")
|
||||
if on_content_delta:
|
||||
await on_content_delta("done")
|
||||
raise AssertionError("the cancelled provider call should not complete")
|
||||
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
hook = _BlockingReasoningEndHook()
|
||||
|
||||
task = asyncio.create_task(AgentRunner().run(make_run_spec(
|
||||
provider,
|
||||
initial_messages=[{"role": "user", "content": "inspect"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=hook,
|
||||
)))
|
||||
await hook.reasoning_end_started.wait()
|
||||
|
||||
task.cancel()
|
||||
await asyncio.sleep(0)
|
||||
hook.release_reasoning_end.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert hook.events == ["reasoning:inspect", "reasoning_end"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_strips_thinking_tags_from_native_thinking_deltas():
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
@@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import GenerationSettings
|
||||
@@ -569,10 +568,7 @@ async def test_agent_loop_syncs_updated_max_iterations_before_run(tmp_path):
|
||||
loop.runner.run = AsyncMock(side_effect=fake_run)
|
||||
loop.max_iterations = 55
|
||||
|
||||
await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
)
|
||||
await loop._run_agent_loop([], runtime=loop.llm_runtime())
|
||||
|
||||
loop.runner.run.assert_awaited_once()
|
||||
|
||||
@@ -613,7 +609,7 @@ async def test_drain_pending_no_block_when_no_subagents(tmp_path):
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
await loop._run_agent_loop(
|
||||
TranscriptInput(history=[{"role": "user", "content": "test"}], current_message=None),
|
||||
[{"role": "user", "content": "test"}],
|
||||
runtime=runtime,
|
||||
session=None,
|
||||
request_context=RequestContext(channel="test", chat_id="c1", runtime=runtime),
|
||||
@@ -672,7 +668,7 @@ async def test_terminal_drain_timeout(tmp_path):
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
await loop._run_agent_loop(
|
||||
TranscriptInput(history=[{"role": "user", "content": "test"}], current_message=None),
|
||||
[{"role": "user", "content": "test"}],
|
||||
runtime=runtime,
|
||||
session=session,
|
||||
request_context=RequestContext(
|
||||
@@ -746,7 +742,7 @@ async def test_terminal_drain_reuses_one_timeout_budget(tmp_path):
|
||||
loop.subagents._running_tasks["sub-deadline-1"] = hang_task
|
||||
|
||||
await loop._run_agent_loop(
|
||||
TranscriptInput(history=[{"role": "user", "content": "test"}], current_message=None),
|
||||
[{"role": "user", "content": "test"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
session=session,
|
||||
pending_queue=pending_queue,
|
||||
|
||||
@@ -799,7 +799,7 @@ def test_provider_login_can_set_xai_grok_as_main_provider(tmp_path):
|
||||
|
||||
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
||||
assert saved.agents.defaults.provider == "xai_grok"
|
||||
assert saved.agents.defaults.model == "xai-grok/grok-4.6"
|
||||
assert saved.agents.defaults.model == "xai-grok/grok-4.5"
|
||||
assert saved.agents.defaults.context_window_tokens == 500_000
|
||||
assert saved.agents.defaults.model_preset is None
|
||||
assert make_provider(saved).__class__.__name__ == "XAIGrokProvider"
|
||||
@@ -2654,14 +2654,12 @@ def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_
|
||||
assert seen["lease_release_wait_for_stop"] is False
|
||||
|
||||
|
||||
def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys, tmp_path: Path) -> None:
|
||||
def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys) -> None:
|
||||
stopped = False
|
||||
log_path = tmp_path / "gateway.log"
|
||||
log_path.touch()
|
||||
|
||||
class _FakeRuntime:
|
||||
def status(self):
|
||||
return SimpleNamespace(running=True, log_path=log_path)
|
||||
return SimpleNamespace(running=True)
|
||||
|
||||
def stop(self):
|
||||
nonlocal stopped
|
||||
@@ -2681,88 +2679,10 @@ def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys, tmp_path: Path)
|
||||
assert "WebUI launcher detached" in rendered
|
||||
|
||||
|
||||
def test_attach_to_background_gateway_follows_only_new_logs(capsys, tmp_path: Path) -> None:
|
||||
log_path = tmp_path / "gateway.log"
|
||||
log_path.write_text("historical log\n", encoding="utf-8")
|
||||
polls = 0
|
||||
|
||||
def test_attach_to_background_gateway_checks_owned_sidecar() -> None:
|
||||
class _FakeRuntime:
|
||||
def status(self):
|
||||
return SimpleNamespace(running=True, log_path=log_path)
|
||||
|
||||
def _append_then_interrupt(_seconds: float) -> None:
|
||||
nonlocal polls
|
||||
if polls == 0:
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write("[websocket] live log\n")
|
||||
polls += 1
|
||||
return
|
||||
raise KeyboardInterrupt
|
||||
|
||||
cli_webui_support._attach_to_background_gateway(
|
||||
_FakeRuntime(),
|
||||
sleep=_append_then_interrupt,
|
||||
)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "[websocket] live log" in output
|
||||
assert "historical log" not in output
|
||||
|
||||
|
||||
def test_read_new_gateway_logs_recovers_after_truncation(tmp_path: Path) -> None:
|
||||
log_path = tmp_path / "gateway.log"
|
||||
log_path.write_text("a much longer historical log line\n", encoding="utf-8")
|
||||
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
|
||||
log_path.write_text("fresh log\n", encoding="utf-8")
|
||||
|
||||
lines = cli_webui_support._read_new_gateway_logs(log_path, cursor)
|
||||
|
||||
assert lines == ["fresh log"]
|
||||
assert cursor.offset == log_path.stat().st_size
|
||||
|
||||
|
||||
def test_read_new_gateway_logs_detects_fast_rewrite_past_offset(tmp_path: Path) -> None:
|
||||
log_path = tmp_path / "gateway.log"
|
||||
log_path.write_text("historical log\n", encoding="utf-8")
|
||||
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
|
||||
log_path.write_text("first fresh log\nsecond fresh log\n", encoding="utf-8")
|
||||
|
||||
lines = cli_webui_support._read_new_gateway_logs(log_path, cursor)
|
||||
|
||||
assert lines == ["first fresh log", "second fresh log"]
|
||||
|
||||
|
||||
def test_read_new_gateway_logs_waits_for_complete_utf8_line(tmp_path: Path) -> None:
|
||||
log_path = tmp_path / "gateway.log"
|
||||
log_path.touch()
|
||||
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
|
||||
encoded = "模型 ready\n".encode()
|
||||
log_path.write_bytes(encoded[:2])
|
||||
|
||||
assert cli_webui_support._read_new_gateway_logs(log_path, cursor) == []
|
||||
|
||||
with log_path.open("ab") as handle:
|
||||
handle.write(encoded[2:])
|
||||
|
||||
assert cli_webui_support._read_new_gateway_logs(log_path, cursor) == ["模型 ready"]
|
||||
|
||||
|
||||
def test_read_new_gateway_logs_tolerates_missing_file(tmp_path: Path) -> None:
|
||||
log_path = tmp_path / "missing.log"
|
||||
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
|
||||
lines = cli_webui_support._read_new_gateway_logs(log_path, cursor)
|
||||
|
||||
assert lines == []
|
||||
assert cursor.offset == 0
|
||||
|
||||
|
||||
def test_attach_to_background_gateway_checks_owned_sidecar(tmp_path: Path) -> None:
|
||||
log_path = tmp_path / "gateway.log"
|
||||
log_path.touch()
|
||||
|
||||
class _FakeRuntime:
|
||||
def status(self):
|
||||
return SimpleNamespace(running=True, log_path=log_path)
|
||||
return SimpleNamespace(running=True)
|
||||
|
||||
def sidecar_exited() -> None:
|
||||
raise WebUIDevError("WebUI development server exited unexpectedly (code 23)")
|
||||
|
||||
+1
-82
@@ -1,85 +1,4 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.cli import entry
|
||||
from nanobot.cli.entry import _agent_invocation_args, _native_tui_candidate
|
||||
|
||||
|
||||
def test_root_command_routes_to_agent_without_copying_agent_options() -> None:
|
||||
assert _agent_invocation_args([]) == []
|
||||
assert _agent_invocation_args(["agent", "--theme", "dark"]) == ["--theme", "dark"]
|
||||
assert _agent_invocation_args(["--workspace", "./project"]) == [
|
||||
"--workspace",
|
||||
"./project",
|
||||
]
|
||||
assert _agent_invocation_args(["-mhello"]) == ["-mhello"]
|
||||
|
||||
|
||||
def test_root_metadata_and_subcommands_keep_the_root_cli() -> None:
|
||||
for args in (
|
||||
["--help"],
|
||||
["--version"],
|
||||
["--install-completion"],
|
||||
["gateway"],
|
||||
["webui"],
|
||||
):
|
||||
assert _agent_invocation_args(args) is None
|
||||
|
||||
|
||||
def test_root_shell_completion_keeps_root_subcommands() -> None:
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"_NANOBOT_COMPLETE": "complete_bash",
|
||||
"COMP_WORDS": "nanobot ",
|
||||
"COMP_CWORD": "1",
|
||||
}
|
||||
)
|
||||
script = (
|
||||
"import sys; "
|
||||
"from nanobot.cli.entry import main; "
|
||||
"sys.argv = ['nanobot']; "
|
||||
"main()"
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=Path(__file__).parents[2],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert {"agent", "gateway", "webui"} <= set(result.stdout.splitlines())
|
||||
assert "not supported" not in result.stderr
|
||||
|
||||
|
||||
def test_root_alias_dispatches_the_shared_agent_command(monkeypatch) -> None:
|
||||
calls: dict[str, object] = {}
|
||||
monkeypatch.setattr(entry.sys, "argv", ["nanobot", "-m", "hello"])
|
||||
monkeypatch.setattr(
|
||||
entry,
|
||||
"set_cli_process_identity",
|
||||
lambda args: calls.__setitem__("identity", args),
|
||||
)
|
||||
monkeypatch.setattr(entry, "_configure_windows_console", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
entry,
|
||||
"_run_agent",
|
||||
lambda args, *, prog_name: calls.update(args=args, prog_name=prog_name),
|
||||
)
|
||||
|
||||
entry.main()
|
||||
|
||||
assert calls == {
|
||||
"identity": ["agent", "-m", "hello"],
|
||||
"args": ["-m", "hello"],
|
||||
"prog_name": "nanobot",
|
||||
}
|
||||
from nanobot.cli.entry import _native_tui_candidate
|
||||
|
||||
|
||||
def test_native_agent_invocations_use_the_lightweight_entrypoint() -> None:
|
||||
|
||||
@@ -58,24 +58,6 @@ def test_legacy_console_entrypoint_still_sets_subcommand_identity(
|
||||
assert commands == [["webui"]]
|
||||
|
||||
|
||||
def test_legacy_console_entrypoint_routes_bare_command_to_agent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
identities: list[list[str]] = []
|
||||
launches: list[tuple[list[str], str]] = []
|
||||
monkeypatch.setattr("nanobot.cli.commands.set_cli_process_identity", identities.append)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.entry._run_agent",
|
||||
lambda args, *, prog_name: launches.append((args, prog_name)),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(app, [])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert identities == [["agent"]]
|
||||
assert launches == [([], "nanobot")]
|
||||
|
||||
|
||||
def test_named_executable_creates_stable_role_symlink(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -11,7 +11,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.providers.base import LLMResponse, LLMUsage
|
||||
|
||||
@@ -312,16 +311,10 @@ class TestRestartCommand:
|
||||
LLMResponse(content="second", usage=None),
|
||||
])
|
||||
|
||||
first = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
)
|
||||
first = await loop._run_agent_loop([], runtime=loop.llm_runtime())
|
||||
assert first.usage == LLMUsage.reported(input_tokens=9, output_tokens=4)
|
||||
|
||||
second = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
)
|
||||
second = await loop._run_agent_loop([], runtime=loop.llm_runtime())
|
||||
assert second.usage == LLMUsage.estimated(input_tokens=123, output_tokens=7)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -7,7 +7,6 @@ import pytest
|
||||
|
||||
from nanobot.cron.service import CronJobSkippedError, CronService
|
||||
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
|
||||
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META
|
||||
|
||||
|
||||
async def _wait_until(predicate, *, timeout: float = 1.0, interval: float = 0.01) -> None:
|
||||
@@ -293,12 +292,7 @@ def test_load_store_migrates_legacy_delivery_context(tmp_path) -> None:
|
||||
"deliver": True,
|
||||
"channel": "telegram",
|
||||
"to": "user-1",
|
||||
"channelMeta": {
|
||||
"message_thread_id": 42,
|
||||
RUNTIME_CONTEXT_INPUT_META: [
|
||||
{"source": "webui_quote", "content": "stale quote"}
|
||||
],
|
||||
},
|
||||
"channelMeta": {"message_thread_id": 42},
|
||||
"sessionKey": "telegram:user-1:topic:42",
|
||||
},
|
||||
"state": {},
|
||||
@@ -417,39 +411,6 @@ def test_add_job_preserves_origin_delivery_context(tmp_path) -> None:
|
||||
assert reloaded.payload.origin_metadata == metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_heals_runtime_context_from_pending_external_add(tmp_path) -> None:
|
||||
"""Flattened runtime blocks from older action files must not be replayed."""
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
external = CronService(store_path)
|
||||
job = external.add_job(
|
||||
name="quoted reminder",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="remember this",
|
||||
origin_metadata={"webui": True},
|
||||
**_bound_chat("quoted"),
|
||||
)
|
||||
|
||||
action_path = tmp_path / "cron" / "action.jsonl"
|
||||
action = json.loads(action_path.read_text(encoding="utf-8"))
|
||||
action["params"]["payload"]["origin_metadata"][RUNTIME_CONTEXT_INPUT_META] = [
|
||||
{"source": "webui_quote", "content": "quoted reply"}
|
||||
]
|
||||
action_path.write_text(json.dumps(action), encoding="utf-8")
|
||||
|
||||
owner = CronService(store_path)
|
||||
await owner.start()
|
||||
try:
|
||||
loaded = owner.get_job(job.id)
|
||||
assert loaded is not None
|
||||
assert loaded.payload.origin_metadata == {"webui": True}
|
||||
|
||||
raw = json.loads(store_path.read_text(encoding="utf-8"))
|
||||
assert raw["jobs"][0]["payload"]["originMetadata"] == {"webui": True}
|
||||
finally:
|
||||
owner.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_meta_and_session_key_survive_store_reload(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
|
||||
@@ -112,49 +112,14 @@ class TestEnforceRoleAlternation:
|
||||
assert result[1]["content"] is None
|
||||
assert result[2]["role"] == "tool"
|
||||
|
||||
def test_consecutive_user_messages_preserve_text_before_multimodal_content(self):
|
||||
image = {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,aW1hZ2U="},
|
||||
}
|
||||
def test_non_string_content_uses_latest(self):
|
||||
msgs = [
|
||||
{"role": "user", "content": "Earlier unanswered question"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [image, {"type": "text", "text": "The error is here"}],
|
||||
},
|
||||
{"role": "user", "content": [{"type": "text", "text": "A"}]},
|
||||
{"role": "user", "content": "B"},
|
||||
]
|
||||
result = LLMProvider._enforce_role_alternation(msgs)
|
||||
assert result == [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Earlier unanswered question"},
|
||||
image,
|
||||
{"type": "text", "text": "The error is here"},
|
||||
],
|
||||
}]
|
||||
|
||||
def test_consecutive_user_messages_preserve_multimodal_content_before_text(self):
|
||||
image = {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,aW1hZ2U="},
|
||||
}
|
||||
msgs = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [image, {"type": "text", "text": "First question"}],
|
||||
},
|
||||
{"role": "user", "content": "Follow-up detail"},
|
||||
]
|
||||
result = LLMProvider._enforce_role_alternation(msgs)
|
||||
assert result == [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
image,
|
||||
{"type": "text", "text": "First question"},
|
||||
{"type": "text", "text": "Follow-up detail"},
|
||||
],
|
||||
}]
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == "B"
|
||||
|
||||
def test_original_messages_not_mutated(self):
|
||||
msgs = [
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nanobot.providers.oauth_model_catalog import (
|
||||
OAuthModelCatalog,
|
||||
get_oauth_model_catalog,
|
||||
invalidate_oauth_model_catalog,
|
||||
)
|
||||
from nanobot.providers.openai_codex_provider import (
|
||||
DEFAULT_OPENAI_CODEX_MODELS_URL,
|
||||
OPENAI_CODEX_CATALOG_CLIENT_VERSION,
|
||||
)
|
||||
from nanobot.providers.registry import ProviderModelSpec
|
||||
from nanobot.providers.xai_grok_provider import DEFAULT_XAI_GROK_MODELS_URL
|
||||
from nanobot.providers.xai_oauth import XAIToken
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_oauth_catalogs() -> None:
|
||||
for provider in ("openai_codex", "xai_grok", "github_copilot"):
|
||||
invalidate_oauth_model_catalog(provider)
|
||||
yield
|
||||
for provider in ("openai_codex", "xai_grok", "github_copilot"):
|
||||
invalidate_oauth_model_catalog(provider)
|
||||
|
||||
|
||||
def _fallback_model() -> ProviderModelSpec:
|
||||
return ProviderModelSpec(id="provider/fallback", label="Fallback")
|
||||
|
||||
|
||||
def test_xai_catalog_fetches_remote_models_and_reuses_capability_metadata(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
original_client = httpx.Client
|
||||
captured: dict[str, object] = {}
|
||||
payload = (
|
||||
base64.urlsafe_b64encode(
|
||||
json.dumps({"sub": "user-42", "email": "user@example.com"}).encode()
|
||||
)
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
token = XAIToken(
|
||||
access=f"header.{payload}.signature",
|
||||
refresh="refresh-token",
|
||||
expires=int(time.time() * 1000) + 3_600_000,
|
||||
account_id="user@example.com",
|
||||
)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["request"] = request
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"data": [
|
||||
{
|
||||
"id": "grok-4.6",
|
||||
"name": "Grok 4.6",
|
||||
"description": "Latest frontier model",
|
||||
"owned_by": "xAI",
|
||||
"context_window": 500_000,
|
||||
"supports_backend_search": True,
|
||||
"reasoning_efforts": [
|
||||
{"value": "xhigh"},
|
||||
{"value": "high"},
|
||||
{"value": "low"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "grok-next",
|
||||
"_meta": {
|
||||
"name": "Grok Next",
|
||||
"context_window": 750_000,
|
||||
"reasoning_efforts": ["high", "low"],
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
|
||||
def fake_client(**kwargs: object) -> httpx.Client:
|
||||
captured["kwargs"] = kwargs
|
||||
return original_client(
|
||||
transport=httpx.MockTransport(handler),
|
||||
timeout=kwargs["timeout"],
|
||||
follow_redirects=kwargs["follow_redirects"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.xai_grok_provider.get_xai_oauth_storage_path",
|
||||
lambda: tmp_path / "auth" / "xai.json",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.xai_grok_provider.get_xai_oauth_login_status",
|
||||
lambda: token,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.xai_grok_provider.get_xai_oauth_token",
|
||||
lambda **_kwargs: token,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.providers.xai_grok_provider.httpx.Client", fake_client)
|
||||
|
||||
catalog = get_oauth_model_catalog("xai_grok")
|
||||
|
||||
assert catalog.source == "remote"
|
||||
assert [model.id for model in catalog.models] == [
|
||||
"xai-grok/grok-4.6",
|
||||
"xai-grok/grok-next",
|
||||
]
|
||||
grok = catalog.find("grok-4.6")
|
||||
assert grok is not None
|
||||
assert grok.description == "Latest frontier model"
|
||||
assert grok.context_window == 500_000
|
||||
assert grok.reasoning_efforts == ("xhigh", "high", "low")
|
||||
assert grok.supports_backend_search is True
|
||||
next_model = catalog.find("xai-grok/grok-next")
|
||||
assert next_model is not None
|
||||
assert next_model.label == "Grok Next"
|
||||
assert next_model.context_window == 750_000
|
||||
assert next_model.reasoning_efforts == ("high", "low")
|
||||
|
||||
request = captured["request"]
|
||||
assert isinstance(request, httpx.Request)
|
||||
assert str(request.url) == DEFAULT_XAI_GROK_MODELS_URL
|
||||
assert request.headers["Authorization"] == f"Bearer {token.access}"
|
||||
assert request.headers["X-XAI-Token-Auth"] == "xai-grok-cli"
|
||||
assert request.headers["x-userid"] == "user-42"
|
||||
assert request.headers["x-email"] == "user@example.com"
|
||||
assert captured["kwargs"] == {"timeout": 10.0, "follow_redirects": False}
|
||||
assert get_oauth_model_catalog("xai_grok").source == "cache"
|
||||
|
||||
|
||||
def test_openai_codex_catalog_uses_account_catalog_and_filters_hidden_models(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
original_client = httpx.Client
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["request"] = request
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"models": [
|
||||
{
|
||||
"slug": "gpt-new",
|
||||
"display_name": "GPT New",
|
||||
"description": "New model",
|
||||
"context_window": 300_000,
|
||||
"priority": 2,
|
||||
"visibility": "list",
|
||||
"supported_reasoning_levels": [
|
||||
{"effort": "low"},
|
||||
{"effort": "high"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "gpt-first",
|
||||
"display_name": "GPT First",
|
||||
"priority": 1,
|
||||
},
|
||||
{
|
||||
"slug": "internal-model",
|
||||
"display_name": "Internal",
|
||||
"visibility": "hide",
|
||||
"priority": 0,
|
||||
},
|
||||
]
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
|
||||
def fake_client(**kwargs: object) -> httpx.Client:
|
||||
captured["kwargs"] = kwargs
|
||||
return original_client(
|
||||
transport=httpx.MockTransport(handler),
|
||||
timeout=kwargs["timeout"],
|
||||
follow_redirects=kwargs["follow_redirects"],
|
||||
)
|
||||
|
||||
class Storage:
|
||||
def load(self) -> SimpleNamespace:
|
||||
return SimpleNamespace(access="secret", account_id="account-42")
|
||||
|
||||
def get_token_path(self) -> Path:
|
||||
return tmp_path / "auth" / "openai-codex.json"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.openai_codex_provider.FileTokenStorage",
|
||||
lambda **_kwargs: Storage(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.openai_codex_provider.get_codex_token",
|
||||
lambda **_kwargs: SimpleNamespace(access="secret", account_id="account-42"),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.providers.openai_codex_provider.httpx.Client", fake_client)
|
||||
|
||||
catalog = get_oauth_model_catalog("openai_codex")
|
||||
|
||||
assert catalog.source == "remote"
|
||||
assert [model.id for model in catalog.models] == [
|
||||
"openai-codex/gpt-first",
|
||||
"openai-codex/gpt-new",
|
||||
]
|
||||
assert catalog.models[1].context_window == 300_000
|
||||
assert catalog.models[1].reasoning_efforts == ("low", "high")
|
||||
request = captured["request"]
|
||||
assert isinstance(request, httpx.Request)
|
||||
assert request.url.copy_with(query=None) == httpx.URL(DEFAULT_OPENAI_CODEX_MODELS_URL)
|
||||
assert request.url.params["client_version"] == OPENAI_CODEX_CATALOG_CLIENT_VERSION
|
||||
assert request.headers["Authorization"] == "Bearer secret"
|
||||
assert request.headers["chatgpt-account-id"] == "account-42"
|
||||
|
||||
|
||||
def test_github_copilot_catalog_only_lists_compatible_chat_models(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
original_client = httpx.Client
|
||||
captured: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(request)
|
||||
if request.url.path.endswith("/copilot_internal/v2/token"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"token": "copilot-secret",
|
||||
"endpoints": {"api": "https://api.individual.githubcopilot.com"},
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"data": [
|
||||
{
|
||||
"id": "claude-sonnet",
|
||||
"name": "Claude Sonnet",
|
||||
"model_picker_enabled": True,
|
||||
"policy": {"state": "enabled"},
|
||||
"supported_endpoints": ["/chat/completions"],
|
||||
"capabilities": {
|
||||
"supports": {"reasoning_effort": ["low", "high"]},
|
||||
"limits": {"max_context_window_tokens": 200_000},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.4-mini",
|
||||
"name": "GPT-5.4 Mini",
|
||||
"model_picker_enabled": True,
|
||||
"supported_endpoints": ["/responses"],
|
||||
},
|
||||
{
|
||||
"id": "unknown-responses-only",
|
||||
"name": "Unknown Responses only",
|
||||
"model_picker_enabled": True,
|
||||
"supported_endpoints": ["/responses"],
|
||||
},
|
||||
{
|
||||
"id": "disabled",
|
||||
"model_picker_enabled": True,
|
||||
"policy": {"state": "disabled"},
|
||||
"supported_endpoints": ["/chat/completions"],
|
||||
},
|
||||
]
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
|
||||
def fake_client(**kwargs: object) -> httpx.Client:
|
||||
return original_client(
|
||||
transport=httpx.MockTransport(handler),
|
||||
timeout=kwargs["timeout"],
|
||||
follow_redirects=kwargs["follow_redirects"],
|
||||
)
|
||||
|
||||
class Storage:
|
||||
def load(self) -> SimpleNamespace:
|
||||
return SimpleNamespace(access="github-secret", account_id="octocat")
|
||||
|
||||
def get_token_path(self) -> Path:
|
||||
return tmp_path / "auth" / "github-copilot.json"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.github_copilot_provider.get_storage",
|
||||
lambda: Storage(),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.providers.github_copilot_provider.httpx.Client", fake_client)
|
||||
|
||||
catalog = get_oauth_model_catalog("github_copilot")
|
||||
|
||||
assert catalog.source == "remote"
|
||||
assert [model.id for model in catalog.models] == [
|
||||
"github-copilot/claude-sonnet",
|
||||
"github-copilot/gpt-5.4-mini",
|
||||
]
|
||||
assert catalog.models[0].context_window == 200_000
|
||||
assert catalog.models[0].reasoning_efforts == ("low", "high")
|
||||
assert len(captured) == 2
|
||||
assert captured[0].headers["Authorization"] == "token github-secret"
|
||||
assert captured[1].headers["Authorization"] == "Bearer copilot-secret"
|
||||
assert str(captured[1].url) == "https://api.individual.githubcopilot.com/models"
|
||||
assert get_oauth_model_catalog("github_copilot").source == "cache"
|
||||
assert get_oauth_model_catalog(
|
||||
"github_copilot",
|
||||
proxy="http://proxy.example:8080",
|
||||
).source == "remote"
|
||||
assert len(captured) == 4
|
||||
|
||||
|
||||
def test_catalog_single_flights_concurrent_refreshes() -> None:
|
||||
calls = 0
|
||||
calls_lock = threading.Lock()
|
||||
barrier = threading.Barrier(8)
|
||||
|
||||
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||
nonlocal calls
|
||||
with calls_lock:
|
||||
calls += 1
|
||||
time.sleep(0.05)
|
||||
return (ProviderModelSpec(id="provider/remote", label="Remote"),)
|
||||
|
||||
catalog = OAuthModelCatalog(fallback_models=(_fallback_model(),), fetch=fetch)
|
||||
|
||||
def get_catalog(_index: int):
|
||||
barrier.wait()
|
||||
return catalog.get(cache_key="shared")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
results = list(pool.map(get_catalog, range(8)))
|
||||
|
||||
assert calls == 1
|
||||
assert {result.models[0].id for result in results} == {"provider/remote"}
|
||||
assert [result.source for result in results].count("remote") == 1
|
||||
assert [result.source for result in results].count("cache") == 7
|
||||
|
||||
|
||||
def test_catalog_invalidation_discards_an_inflight_account_refresh() -> None:
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
identity = ["old-account"]
|
||||
|
||||
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||
current = identity[0]
|
||||
if current == "old-account":
|
||||
started.set()
|
||||
assert release.wait(timeout=2)
|
||||
return (ProviderModelSpec(id=f"provider/{current}", label=current),)
|
||||
|
||||
catalog = OAuthModelCatalog(fallback_models=(_fallback_model(),), fetch=fetch)
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
old_future = pool.submit(catalog.get, cache_key="old-key")
|
||||
assert started.wait(timeout=2)
|
||||
identity[0] = "new-account"
|
||||
catalog.invalidate()
|
||||
new_future = pool.submit(catalog.get, cache_key="new-key")
|
||||
new_result = new_future.result(timeout=2)
|
||||
release.set()
|
||||
old_result = old_future.result(timeout=2)
|
||||
|
||||
assert old_result.source == "fallback"
|
||||
assert new_result.models[0].id == "provider/new-account"
|
||||
|
||||
identity[0] = "old-account"
|
||||
assert catalog.get(cache_key="old-key").models[0].id == "provider/old-account"
|
||||
|
||||
|
||||
def test_catalog_bounds_failure_only_keys() -> None:
|
||||
calls = 0
|
||||
|
||||
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise httpx.ConnectError("offline")
|
||||
|
||||
catalog = OAuthModelCatalog(
|
||||
fallback_models=(_fallback_model(),),
|
||||
fetch=fetch,
|
||||
max_entries=2,
|
||||
)
|
||||
|
||||
for key in ("one", "two", "three"):
|
||||
assert catalog.get(cache_key=key).source == "fallback"
|
||||
|
||||
assert calls == 3
|
||||
assert catalog.get(cache_key="one").source == "fallback"
|
||||
assert calls == 4
|
||||
|
||||
|
||||
def test_catalog_returns_stale_then_negative_caches_refresh_failure() -> None:
|
||||
now = [0.0]
|
||||
calls = 0
|
||||
|
||||
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls > 1:
|
||||
raise httpx.ConnectError("offline")
|
||||
return (ProviderModelSpec(id="provider/remote", label="Remote"),)
|
||||
|
||||
catalog = OAuthModelCatalog(
|
||||
fallback_models=(_fallback_model(),),
|
||||
fetch=fetch,
|
||||
fresh_ttl_s=10,
|
||||
stale_ttl_s=100,
|
||||
failure_ttl_s=30,
|
||||
monotonic=lambda: now[0],
|
||||
wall_clock=lambda: 123.0,
|
||||
)
|
||||
|
||||
assert catalog.get(cache_key="one").source == "remote"
|
||||
now[0] = 11
|
||||
stale = catalog.get(cache_key="one")
|
||||
assert stale.source == "stale"
|
||||
assert stale.models[0].id == "provider/remote"
|
||||
assert catalog.get(cache_key="one").source == "stale"
|
||||
assert calls == 2
|
||||
|
||||
now[0] = 101
|
||||
fallback = catalog.get(cache_key="one")
|
||||
assert fallback.source == "fallback"
|
||||
assert fallback.models[0].id == "provider/fallback"
|
||||
assert calls == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"failure",
|
||||
[
|
||||
httpx.ConnectError("offline"),
|
||||
ValueError("invalid JSON"),
|
||||
httpx.HTTPStatusError(
|
||||
"unauthorized",
|
||||
request=httpx.Request("GET", DEFAULT_XAI_GROK_MODELS_URL),
|
||||
response=httpx.Response(401),
|
||||
),
|
||||
httpx.HTTPStatusError(
|
||||
"rate limited",
|
||||
request=httpx.Request("GET", DEFAULT_XAI_GROK_MODELS_URL),
|
||||
response=httpx.Response(429),
|
||||
),
|
||||
httpx.HTTPStatusError(
|
||||
"upstream failure",
|
||||
request=httpx.Request("GET", DEFAULT_XAI_GROK_MODELS_URL),
|
||||
response=httpx.Response(503),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_catalog_falls_back_for_remote_failures(failure: Exception) -> None:
|
||||
calls = 0
|
||||
|
||||
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise failure
|
||||
|
||||
catalog = OAuthModelCatalog(
|
||||
fallback_models=(_fallback_model(),),
|
||||
fetch=fetch,
|
||||
failure_ttl_s=30,
|
||||
)
|
||||
|
||||
first = catalog.get(cache_key="one")
|
||||
second = catalog.get(cache_key="one")
|
||||
|
||||
assert first.source == "fallback"
|
||||
assert second.source == "fallback"
|
||||
assert first.models == (_fallback_model(),)
|
||||
assert calls == 1
|
||||
|
||||
|
||||
def test_catalog_treats_empty_remote_list_as_failure_and_can_be_invalidated() -> None:
|
||||
calls = 0
|
||||
|
||||
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return () if calls == 1 else (ProviderModelSpec(id="provider/new", label="New"),)
|
||||
|
||||
catalog = OAuthModelCatalog(
|
||||
fallback_models=(_fallback_model(),),
|
||||
fetch=fetch,
|
||||
failure_ttl_s=30,
|
||||
)
|
||||
|
||||
assert catalog.get(cache_key="one").source == "fallback"
|
||||
catalog.invalidate()
|
||||
refreshed = catalog.get(cache_key="one")
|
||||
assert refreshed.source == "remote"
|
||||
assert refreshed.models[0].id == "provider/new"
|
||||
assert calls == 2
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
@@ -11,19 +12,21 @@ import pytest
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.providers.base import LLMUsage
|
||||
from nanobot.providers.factory import make_provider
|
||||
from nanobot.providers.oauth_model_catalog import OAuthModelCatalogSnapshot
|
||||
from nanobot.providers.registry import ProviderModelSpec, find_by_name
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.providers.xai_grok_provider import (
|
||||
DEFAULT_XAI_GROK_MODEL,
|
||||
DEFAULT_XAI_GROK_MODELS_URL,
|
||||
XAIGrokProvider,
|
||||
_bounded_error_body,
|
||||
_build_headers,
|
||||
_build_model_headers,
|
||||
_build_reasoning_options,
|
||||
_build_xai_http_error,
|
||||
_fetch_xai_model_capabilities,
|
||||
_parse_xai_model_capabilities,
|
||||
_request_xai,
|
||||
_xai_error_response,
|
||||
_XAIHTTPError,
|
||||
_XAIIncompleteHostedToolError,
|
||||
)
|
||||
|
||||
|
||||
@@ -48,41 +51,22 @@ def _mock_model_capabilities(
|
||||
*,
|
||||
supports_backend_search: bool,
|
||||
) -> None:
|
||||
def fake_catalog(*_args, **_kwargs):
|
||||
return OAuthModelCatalogSnapshot(
|
||||
models=(
|
||||
ProviderModelSpec(
|
||||
id="xai-grok/grok-4.5",
|
||||
label="Grok 4.5",
|
||||
supports_backend_search=supports_backend_search,
|
||||
),
|
||||
ProviderModelSpec(
|
||||
id="xai-grok/grok-4.6",
|
||||
label="Grok 4.6",
|
||||
supports_backend_search=supports_backend_search,
|
||||
),
|
||||
),
|
||||
source="remote",
|
||||
fetched_at=1,
|
||||
)
|
||||
async def fake_fetch(*_args, **_kwargs):
|
||||
return {"grok-4.5": supports_backend_search}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.xai_grok_provider.get_xai_grok_model_catalog",
|
||||
fake_catalog,
|
||||
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities",
|
||||
fake_fetch,
|
||||
)
|
||||
|
||||
|
||||
def test_xai_grok_registry_exposes_curated_x_search_models() -> None:
|
||||
def test_xai_grok_registry_exposes_curated_x_search_model() -> None:
|
||||
spec = find_by_name("xai_grok")
|
||||
|
||||
assert spec is not None
|
||||
assert spec.is_oauth is True
|
||||
assert spec.backend == "xai_grok"
|
||||
assert spec.builtin_models[0].id == DEFAULT_XAI_GROK_MODEL
|
||||
assert [model.id for model in spec.builtin_models] == [
|
||||
"xai-grok/grok-4.6",
|
||||
"xai-grok/grok-4.5",
|
||||
]
|
||||
assert spec.builtin_models[0].context_window == 500000
|
||||
assert "when supported" in spec.builtin_models[0].description
|
||||
|
||||
@@ -133,7 +117,7 @@ async def test_provider_injects_hosted_x_search_and_required_proxy_headers(monke
|
||||
assert response.content == "answer [[1]](https://x.com/example/status/1)"
|
||||
url, headers, body = calls[0]
|
||||
assert url == "https://cli-chat-proxy.grok.com/v1/responses"
|
||||
assert body["model"] == "grok-4.6"
|
||||
assert body["model"] == "grok-4.5"
|
||||
assert body["tools"] == [
|
||||
{
|
||||
"type": "function",
|
||||
@@ -148,13 +132,12 @@ async def test_provider_injects_hosted_x_search_and_required_proxy_headers(monke
|
||||
assert body["stream_tool_calls"] is True
|
||||
assert body["reasoning"] == {"summary": "concise", "effort": "high"}
|
||||
assert body["store"] is False
|
||||
assert body["max_turns"] == 5
|
||||
assert headers["Authorization"] == "Bearer subscription-token"
|
||||
assert headers["X-XAI-Token-Auth"] == "xai-grok-cli"
|
||||
assert headers["x-authenticateresponse"] == "authenticate-response"
|
||||
assert headers["x-grok-client-identifier"] == "nanobot"
|
||||
assert headers["x-grok-client-mode"] == "headless"
|
||||
assert headers["x-grok-model-override"] == "grok-4.6"
|
||||
assert headers["x-grok-model-override"] == "grok-4.5"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -164,7 +147,7 @@ async def test_explicit_parameterized_x_search_is_preserved_without_catalog_look
|
||||
_mock_token(monkeypatch)
|
||||
bodies: list[dict[str, Any]] = []
|
||||
|
||||
def unexpected_catalog_lookup(*_args, **_kwargs):
|
||||
async def unexpected_catalog_lookup(*_args, **_kwargs):
|
||||
raise AssertionError("explicit raw tools must not depend on model catalog metadata")
|
||||
|
||||
async def fake_request(_url, _headers, body, **_kwargs):
|
||||
@@ -172,7 +155,7 @@ async def test_explicit_parameterized_x_search_is_preserved_without_catalog_look
|
||||
return "ok", [], "stop", {}, None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.xai_grok_provider.get_xai_grok_model_catalog",
|
||||
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities",
|
||||
unexpected_catalog_lookup,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
||||
@@ -181,12 +164,10 @@ async def test_explicit_parameterized_x_search_is_preserved_without_catalog_look
|
||||
"allowed_x_handles": ["nanobot_ai"],
|
||||
"from_date": "2026-01-01",
|
||||
}
|
||||
provider = XAIGrokProvider(
|
||||
extra_body={
|
||||
"parallel_tool_calls": False,
|
||||
"tools": [hosted_tool, {"type": "code_interpreter", "container": "auto"}],
|
||||
}
|
||||
)
|
||||
provider = XAIGrokProvider(extra_body={
|
||||
"parallel_tool_calls": False,
|
||||
"tools": [hosted_tool, {"type": "code_interpreter", "container": "auto"}],
|
||||
})
|
||||
|
||||
response = await provider.chat(
|
||||
[{"role": "user", "content": "search"}],
|
||||
@@ -229,7 +210,7 @@ async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monk
|
||||
_mock_token(monkeypatch)
|
||||
bodies: list[dict[str, Any]] = []
|
||||
|
||||
def unexpected_catalog_lookup(*_args, **_kwargs):
|
||||
async def unexpected_catalog_lookup(*_args, **_kwargs):
|
||||
raise AssertionError("explicitly disabled X Search must not fetch model capabilities")
|
||||
|
||||
async def fake_request(_url, _headers, body, **_kwargs):
|
||||
@@ -237,7 +218,7 @@ async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monk
|
||||
return "ok", [], "stop", {}, None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.xai_grok_provider.get_xai_grok_model_catalog",
|
||||
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities",
|
||||
unexpected_catalog_lookup,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
||||
@@ -245,28 +226,23 @@ async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monk
|
||||
|
||||
response = await provider.chat(
|
||||
[{"role": "user", "content": "hello"}],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
}
|
||||
],
|
||||
tools=[{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
}],
|
||||
)
|
||||
|
||||
assert response.content == "ok"
|
||||
assert bodies[0]["tools"] == [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
}
|
||||
]
|
||||
assert "max_turns" not in bodies[0]
|
||||
assert bodies[0]["tools"] == [{
|
||||
"type": "function",
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object"},
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -305,8 +281,35 @@ async def test_provider_keeps_local_x_search_when_model_does_not_support_hosted_
|
||||
"parameters": {"type": "object"},
|
||||
}
|
||||
]
|
||||
assert "max_turns" not in bodies[0]
|
||||
assert bodies[0]["instructions"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_fails_closed_and_caches_model_catalog_failure(monkeypatch) -> None:
|
||||
_mock_token(monkeypatch)
|
||||
fetch_calls = 0
|
||||
bodies: list[dict[str, Any]] = []
|
||||
|
||||
async def failing_fetch(*_args, **_kwargs):
|
||||
nonlocal fetch_calls
|
||||
fetch_calls += 1
|
||||
raise httpx.ConnectError("catalog unavailable")
|
||||
|
||||
async def fake_request(_url, _headers, body, **_kwargs):
|
||||
bodies.append(body)
|
||||
return "ok", [], "stop", {}, None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities",
|
||||
failing_fetch,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
||||
provider = XAIGrokProvider()
|
||||
|
||||
await provider.chat([{"role": "user", "content": "first"}])
|
||||
await provider.chat([{"role": "user", "content": "second"}])
|
||||
|
||||
assert fetch_calls == 1
|
||||
assert all({"type": "x_search"} not in body["tools"] for body in bodies)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -392,10 +395,7 @@ async def test_factory_builds_xai_provider_and_applies_explicit_body_overrides(m
|
||||
"providers": {
|
||||
"xaiGrok": {
|
||||
"proxy": "http://127.0.0.1:7890",
|
||||
"extraBody": {
|
||||
"parallel_tool_calls": False,
|
||||
"max_turns": 2,
|
||||
},
|
||||
"extraBody": {"parallel_tool_calls": False},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -408,7 +408,6 @@ async def test_factory_builds_xai_provider_and_applies_explicit_body_overrides(m
|
||||
assert provider.proxy == "http://127.0.0.1:7890"
|
||||
assert response.content == "ok"
|
||||
assert bodies[0]["parallel_tool_calls"] is False
|
||||
assert bodies[0]["max_turns"] == 2
|
||||
assert {"type": "x_search"} in bodies[0]["tools"]
|
||||
|
||||
|
||||
@@ -528,183 +527,75 @@ async def test_raw_response_request_streams_hosted_x_search_lifecycle(monkeypatc
|
||||
assert "large hosted result" not in json.dumps(tool_events)
|
||||
|
||||
|
||||
def test_model_capabilities_follow_upstream_aliases_and_default_to_disabled() -> None:
|
||||
capabilities = _parse_xai_model_capabilities(
|
||||
{
|
||||
"data": [
|
||||
{"id": "grok-4.5", "supportsBackendSearch": False},
|
||||
{
|
||||
"model": "grok-search",
|
||||
"supports_backend_search": True,
|
||||
},
|
||||
{
|
||||
"modelId": "grok-meta",
|
||||
"_meta": {"supportsBackendSearch": True},
|
||||
},
|
||||
{"id": "grok-unknown"},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert capabilities == {
|
||||
"grok-4.5": False,
|
||||
"grok-search": True,
|
||||
"grok-meta": True,
|
||||
"grok-unknown": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_response_request_streams_official_x_search_lifecycle(monkeypatch) -> None:
|
||||
async def test_model_capability_request_uses_subscription_headers(monkeypatch) -> None:
|
||||
original_client = httpx.AsyncClient
|
||||
events = [
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"item": {
|
||||
"type": "x_search_call",
|
||||
"id": "x-search-1",
|
||||
"status": "in_progress",
|
||||
"action": {"query": "nanobot oauth"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "x_search_call",
|
||||
"id": "x-search-1",
|
||||
"status": "completed",
|
||||
"action": {"query": "nanobot oauth"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {"status": "completed", "usage": {}},
|
||||
},
|
||||
]
|
||||
content = "".join(f"data: {json.dumps(event)}\n\n" for event in events)
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, content=content, request=request)
|
||||
captured["request"] = request
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"data": [{"id": "grok-search", "supportsBackendSearch": True}]},
|
||||
request=request,
|
||||
)
|
||||
|
||||
def fake_client(**kwargs) -> httpx.AsyncClient:
|
||||
captured["kwargs"] = kwargs
|
||||
return original_client(
|
||||
transport=httpx.MockTransport(handler),
|
||||
timeout=kwargs["timeout"],
|
||||
follow_redirects=kwargs["follow_redirects"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.xai_grok_provider.httpx.AsyncClient", fake_client)
|
||||
tool_events: list[dict[str, Any]] = []
|
||||
payload = base64.urlsafe_b64encode(
|
||||
json.dumps({"sub": "user-42", "email": "user@example.com"}).encode()
|
||||
).decode().rstrip("=")
|
||||
access_token = f"header.{payload}.signature"
|
||||
headers = _build_model_headers(_token(access_token))
|
||||
|
||||
await _request_xai(
|
||||
"https://cli-chat-proxy.grok.com/v1/responses",
|
||||
_build_headers("secret", "grok-4.6"),
|
||||
{"model": "grok-4.6", "tools": [{"type": "x_search"}]},
|
||||
on_tool_call_delta=lambda event: _append(tool_events, event),
|
||||
capabilities = await _fetch_xai_model_capabilities(
|
||||
DEFAULT_XAI_GROK_MODELS_URL,
|
||||
headers,
|
||||
)
|
||||
|
||||
assert [(event["phase"], event["name"]) for event in tool_events] == [
|
||||
("start", "x_search"),
|
||||
("end", "x_search"),
|
||||
]
|
||||
assert tool_events[-1]["result"] == {"status": "completed"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_response_rejects_unfinished_hosted_tool_and_closes_progress(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
original_client = httpx.AsyncClient
|
||||
events = [
|
||||
{
|
||||
"type": "response.custom_tool_call_input.done",
|
||||
"item_id": "x-search-1",
|
||||
"input": '{"query":"nanobot oauth"}',
|
||||
},
|
||||
{"type": "response.output_text.delta", "delta": "I will keep searching."},
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"status": "completed",
|
||||
"usage": {"input_tokens": 8, "output_tokens": 4, "total_tokens": 12},
|
||||
},
|
||||
},
|
||||
]
|
||||
content = "".join(f"data: {json.dumps(event)}\n\n" for event in events)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, content=content, request=request)
|
||||
|
||||
def fake_client(**kwargs) -> httpx.AsyncClient:
|
||||
return original_client(
|
||||
transport=httpx.MockTransport(handler),
|
||||
timeout=kwargs["timeout"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.xai_grok_provider.httpx.AsyncClient", fake_client)
|
||||
tool_events: list[dict[str, Any]] = []
|
||||
|
||||
with pytest.raises(_XAIIncompleteHostedToolError) as caught:
|
||||
await _request_xai(
|
||||
"https://cli-chat-proxy.grok.com/v1/responses",
|
||||
_build_headers("secret", "grok-4.6"),
|
||||
{"model": "grok-4.6", "tools": [{"type": "x_search"}]},
|
||||
on_tool_call_delta=lambda event: _append(tool_events, event),
|
||||
)
|
||||
|
||||
assert caught.value.usage == LLMUsage.reported(input_tokens=8, output_tokens=4)
|
||||
assert [event["phase"] for event in tool_events] == ["start", "error"]
|
||||
assert "before this hosted tool completed" in tool_events[-1]["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_recovers_unfinished_hosted_tool_once_and_preserves_usage(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
_mock_token(monkeypatch)
|
||||
_mock_model_capabilities(monkeypatch, supports_backend_search=True)
|
||||
attempts = 0
|
||||
request_ids: list[str] = []
|
||||
streamed: list[str] = []
|
||||
recovered: list[bool] = []
|
||||
first_usage = LLMUsage.reported(input_tokens=10, output_tokens=2)
|
||||
second_usage = LLMUsage.reported(input_tokens=11, output_tokens=4)
|
||||
|
||||
async def fake_request(_url, headers, body, **kwargs):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
request_ids.append(headers["x-grok-req-id"])
|
||||
assert body["max_turns"] == 5
|
||||
if attempts == 1:
|
||||
await kwargs["on_content_delta"]("I will keep searching.")
|
||||
raise _XAIIncompleteHostedToolError(
|
||||
[{"name": "x_search", "call_id": "search-1"}],
|
||||
usage=first_usage,
|
||||
)
|
||||
await kwargs["on_content_delta"]("Final researched answer.")
|
||||
return "Final researched answer.", [], "stop", second_usage, None
|
||||
|
||||
async def on_recover() -> None:
|
||||
recovered.append(True)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
||||
provider = XAIGrokProvider()
|
||||
|
||||
response = await provider.chat_stream_with_retry(
|
||||
[{"role": "user", "content": "Search X"}],
|
||||
on_content_delta=lambda delta: _append(streamed, delta),
|
||||
on_stream_recover=on_recover,
|
||||
)
|
||||
|
||||
assert attempts == 2
|
||||
assert len(set(request_ids)) == 2
|
||||
assert recovered == [True]
|
||||
assert streamed == ["I will keep searching.", "Final researched answer."]
|
||||
assert response.content == "Final researched answer."
|
||||
assert response.usage == first_usage + second_usage
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_preserves_usage_when_hosted_tool_recovery_also_fails(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
_mock_token(monkeypatch)
|
||||
_mock_model_capabilities(monkeypatch, supports_backend_search=True)
|
||||
attempts = 0
|
||||
usage = LLMUsage.reported(input_tokens=10, output_tokens=2)
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise _XAIIncompleteHostedToolError(
|
||||
[{"name": "x_search", "call_id": f"search-{attempts}"}],
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
||||
provider = XAIGrokProvider()
|
||||
|
||||
response = await provider.chat_stream_with_retry(
|
||||
[{"role": "user", "content": "Search X"}],
|
||||
on_stream_recover=lambda: _append([], True),
|
||||
)
|
||||
|
||||
assert attempts == 2
|
||||
assert response.finish_reason == "error"
|
||||
assert response.usage == usage + usage
|
||||
request = captured["request"]
|
||||
assert isinstance(request, httpx.Request)
|
||||
assert request.method == "GET"
|
||||
assert str(request.url) == DEFAULT_XAI_GROK_MODELS_URL
|
||||
assert request.headers["Authorization"] == f"Bearer {access_token}"
|
||||
assert request.headers["X-XAI-Token-Auth"] == "xai-grok-cli"
|
||||
assert request.headers["x-userid"] == "user-42"
|
||||
assert request.headers["x-email"] == "user@example.com"
|
||||
assert captured["kwargs"] == {"timeout": 10.0, "follow_redirects": False}
|
||||
assert capabilities == {"grok-search": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -141,13 +141,14 @@ def test_internal_continuation_requires_budget_boundary_and_queue():
|
||||
)
|
||||
|
||||
|
||||
def test_save_skip_matches_prefix_when_current_message_was_persisted():
|
||||
def test_save_skip_matches_prefix_when_current_message_merged():
|
||||
skip = _save_skip_for_turn(
|
||||
message_metadata=None,
|
||||
initial_message_count=3, # [system, history user, current user]
|
||||
initial_message_count=2, # [system, merged user]
|
||||
history_count=1,
|
||||
input_persisted_early=True,
|
||||
)
|
||||
assert skip == 3
|
||||
assert skip == 2
|
||||
|
||||
|
||||
def test_save_skip_unchanged_for_standalone_current_message():
|
||||
@@ -155,10 +156,12 @@ def test_save_skip_unchanged_for_standalone_current_message():
|
||||
assert _save_skip_for_turn(
|
||||
message_metadata=None,
|
||||
initial_message_count=3,
|
||||
history_count=1,
|
||||
input_persisted_early=True,
|
||||
) == 3
|
||||
assert _save_skip_for_turn(
|
||||
message_metadata=None,
|
||||
initial_message_count=3,
|
||||
history_count=1,
|
||||
input_persisted_early=False,
|
||||
) == 2
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -12,7 +11,6 @@ from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.providers.base import GenerationSettings, LLMProvider
|
||||
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META, RuntimeContextBlock
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
@@ -301,41 +299,6 @@ async def test_webui_cron_tool_uses_origin_session_when_unified_enabled(tmp_path
|
||||
assert jobs[0].payload.origin_metadata == {"webui": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cron_tool_snapshots_only_persistable_request_metadata(tmp_path) -> None:
|
||||
"""Live runtime context must not poison a persisted WebUI cron job."""
|
||||
store_path = tmp_path / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
tool = CronTool(service)
|
||||
await service.start()
|
||||
try:
|
||||
with request_context(
|
||||
RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="chat-123",
|
||||
metadata={
|
||||
"webui": True,
|
||||
RUNTIME_CONTEXT_INPUT_META: [
|
||||
RuntimeContextBlock(source="webui_quote", content="quoted reply")
|
||||
],
|
||||
"opaque": object(),
|
||||
},
|
||||
session_key=UNIFIED_SESSION_KEY,
|
||||
)
|
||||
):
|
||||
result = await tool.execute(action="add", message="standup", every_seconds=300)
|
||||
|
||||
assert result.startswith("Created job")
|
||||
jobs = service.list_jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0].payload.origin_metadata == {"webui": True}
|
||||
|
||||
raw = json.loads(store_path.read_text(encoding="utf-8"))
|
||||
assert raw["jobs"][0]["payload"]["originMetadata"] == {"webui": True}
|
||||
finally:
|
||||
service.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cron_tool_preserves_thread_scoped_session_key(tmp_path) -> None:
|
||||
"""Channel-provided thread session keys should remain the cron owner."""
|
||||
|
||||
@@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
@@ -179,9 +178,7 @@ class TestMessageToolSuppressLogic:
|
||||
progress.append((content, tool_hint))
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_progress=on_progress,
|
||||
[], runtime=loop.llm_runtime(), on_progress=on_progress
|
||||
)
|
||||
|
||||
assert result.final_content == "Done"
|
||||
|
||||
@@ -183,66 +183,6 @@ async def test_rate_limit_is_per_source_session_and_uses_a_rolling_minute(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_releases_expired_source_state_and_keeps_recent_sources(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
_persist(
|
||||
sessions,
|
||||
"websocket:a",
|
||||
"websocket:b",
|
||||
"websocket:c",
|
||||
"websocket:target",
|
||||
)
|
||||
now = 0.0
|
||||
tool = SendSessionMessageTool(
|
||||
sessions=sessions,
|
||||
bus=MessageBus(),
|
||||
max_messages_per_minute=2,
|
||||
clock=lambda: now,
|
||||
)
|
||||
target = _handle(sessions, "websocket:target").name
|
||||
|
||||
for source in ("websocket:a", "websocket:b"):
|
||||
await tool.enqueue(
|
||||
source_session_key=source,
|
||||
target_handle=target,
|
||||
content="initial",
|
||||
expect_reply=False,
|
||||
)
|
||||
now = 30.0
|
||||
await tool.enqueue(
|
||||
source_session_key="websocket:a",
|
||||
target_handle=target,
|
||||
content="recent",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
now = 61.0
|
||||
await tool.enqueue(
|
||||
source_session_key="websocket:c",
|
||||
target_handle=target,
|
||||
content="trigger cleanup",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
assert set(tool._sent_at) == {"websocket:a", "websocket:c"}
|
||||
await tool.enqueue(
|
||||
source_session_key="websocket:a",
|
||||
target_handle=target,
|
||||
content="within rolling window",
|
||||
expect_reply=False,
|
||||
)
|
||||
with pytest.raises(SessionMessageError, match="rate limit"):
|
||||
await tool.enqueue(
|
||||
source_session_key="websocket:a",
|
||||
target_handle=target,
|
||||
content="over limit",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_timeout_injects_a_user_input_back_into_the_source(
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -13,8 +13,7 @@ from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfi
|
||||
from nanobot.llm_usage import get_llm_usage_store
|
||||
from nanobot.llm_usage.models import LLMCallRecord
|
||||
from nanobot.providers.base import LLMUsage
|
||||
from nanobot.providers.oauth_model_catalog import OAuthModelCatalogSnapshot
|
||||
from nanobot.providers.registry import ProviderModelSpec, find_by_name
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
||||
from nanobot.webui.settings_api import (
|
||||
@@ -184,13 +183,11 @@ def test_update_api_settings_requires_key_for_network_access(
|
||||
with pytest.raises(WebUISettingsError, match="API key"):
|
||||
update_api_settings({"host": ["0.0.0.0"], "port": ["8900"]})
|
||||
|
||||
payload = update_api_settings(
|
||||
{
|
||||
"host": ["0.0.0.0"],
|
||||
"port": ["9900"],
|
||||
"api_key": ["secret-token"],
|
||||
}
|
||||
)
|
||||
payload = update_api_settings({
|
||||
"host": ["0.0.0.0"],
|
||||
"port": ["9900"],
|
||||
"api_key": ["secret-token"],
|
||||
})
|
||||
saved = load_config(config_path)
|
||||
assert saved.api.host == "0.0.0.0"
|
||||
assert saved.api.port == 9900
|
||||
@@ -349,15 +346,13 @@ def test_create_model_configuration_rejects_dynamic_custom_provider_without_api_
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config.model_validate(
|
||||
{
|
||||
"providers": {
|
||||
DYNAMIC_PROVIDER_NAME: {
|
||||
"apiKey": "sk-test",
|
||||
}
|
||||
config = Config.model_validate({
|
||||
"providers": {
|
||||
DYNAMIC_PROVIDER_NAME: {
|
||||
"apiKey": "sk-test",
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
@@ -502,7 +497,9 @@ def test_update_model_configuration_rolls_back_sessions_when_config_save_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config(model_presets={"openai": ModelPresetConfig(model="openai/gpt-4.1")})
|
||||
config = Config(
|
||||
model_presets={"openai": ModelPresetConfig(model="openai/gpt-4.1")}
|
||||
)
|
||||
save_config(config, config_path)
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
@@ -893,13 +890,11 @@ def test_update_provider_settings_updates_and_clears_oauth_proxy(
|
||||
},
|
||||
)
|
||||
|
||||
payload = update_provider_settings(
|
||||
{
|
||||
"provider": [provider_name],
|
||||
"proxy": [" http://127.0.0.1:7890 "],
|
||||
"extraBody": [json.dumps({"tools": []})],
|
||||
}
|
||||
)
|
||||
payload = update_provider_settings({
|
||||
"provider": [provider_name],
|
||||
"proxy": [" http://127.0.0.1:7890 "],
|
||||
"extraBody": [json.dumps({"tools": []})],
|
||||
})
|
||||
|
||||
providers = {row["name"]: row for row in payload["providers"]}
|
||||
assert providers[provider_name]["proxy"] == "http://127.0.0.1:7890"
|
||||
@@ -1104,17 +1099,15 @@ def test_settings_payload_groups_opencode_compatibility_alias(tmp_path, monkeypa
|
||||
|
||||
def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monkeypatch) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config.model_validate(
|
||||
{
|
||||
"providers": {"opencodeZen": {"apiKey": "legacy-key"}},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "opencode_zen",
|
||||
"model": "opencode/deepseek-v4-pro",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
config = Config.model_validate({
|
||||
"providers": {"opencodeZen": {"apiKey": "legacy-key"}},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "opencode_zen",
|
||||
"model": "opencode/deepseek-v4-pro",
|
||||
}
|
||||
},
|
||||
})
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
@@ -1131,15 +1124,13 @@ def test_settings_payload_marks_dynamic_custom_provider_without_api_base_unconfi
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config.model_validate(
|
||||
{
|
||||
"providers": {
|
||||
DYNAMIC_PROVIDER_NAME: {
|
||||
"apiKey": "sk-test",
|
||||
}
|
||||
config = Config.model_validate({
|
||||
"providers": {
|
||||
DYNAMIC_PROVIDER_NAME: {
|
||||
"apiKey": "sk-test",
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
@@ -1475,18 +1466,16 @@ def test_settings_payload_includes_token_usage_summary(
|
||||
config = Config()
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
get_llm_usage_store().record(
|
||||
LLMCallRecord(
|
||||
started_at_ms=int(time.time() * 1000),
|
||||
duration_ms=1,
|
||||
provider="openai",
|
||||
model="gpt-5",
|
||||
source="user",
|
||||
stream=False,
|
||||
finish_reason="stop",
|
||||
usage=LLMUsage.reported(input_tokens=10, output_tokens=5),
|
||||
)
|
||||
)
|
||||
get_llm_usage_store().record(LLMCallRecord(
|
||||
started_at_ms=int(time.time() * 1000),
|
||||
duration_ms=1,
|
||||
provider="openai",
|
||||
model="gpt-5",
|
||||
source="user",
|
||||
stream=False,
|
||||
finish_reason="stop",
|
||||
usage=LLMUsage.reported(input_tokens=10, output_tokens=5),
|
||||
))
|
||||
|
||||
payload = settings_payload()
|
||||
|
||||
@@ -1507,18 +1496,16 @@ def test_settings_usage_payload_returns_lightweight_token_usage(
|
||||
config = Config()
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
get_llm_usage_store().record(
|
||||
LLMCallRecord(
|
||||
started_at_ms=int(time.time() * 1000),
|
||||
duration_ms=1,
|
||||
provider="openai",
|
||||
model="gpt-5",
|
||||
source="user",
|
||||
stream=False,
|
||||
finish_reason="stop",
|
||||
usage=LLMUsage.reported(input_tokens=20, output_tokens=2),
|
||||
)
|
||||
)
|
||||
get_llm_usage_store().record(LLMCallRecord(
|
||||
started_at_ms=int(time.time() * 1000),
|
||||
duration_ms=1,
|
||||
provider="openai",
|
||||
model="gpt-5",
|
||||
source="user",
|
||||
stream=False,
|
||||
finish_reason="stop",
|
||||
usage=LLMUsage.reported(input_tokens=20, output_tokens=2),
|
||||
))
|
||||
|
||||
payload = settings_usage_payload()
|
||||
|
||||
@@ -1942,7 +1929,9 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
||||
)
|
||||
|
||||
assert exc.value.status == 502
|
||||
assert str(exc.value) == ("xAI OAuth login failed: Could not reach xAI sign-in: ConnectError.")
|
||||
assert str(exc.value) == (
|
||||
"xAI OAuth login failed: Could not reach xAI sign-in: ConnectError."
|
||||
)
|
||||
assert exc.value.__cause__ is failure
|
||||
|
||||
|
||||
@@ -2006,126 +1995,39 @@ def test_provider_models_payload_fetches_openai_compatible_models(
|
||||
assert payload["models"][1]["context_window"] == 65536
|
||||
|
||||
|
||||
def test_provider_models_payload_returns_online_openai_codex_models(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_models.get_oauth_model_catalog",
|
||||
lambda *_args, **_kwargs: OAuthModelCatalogSnapshot(
|
||||
models=(
|
||||
ProviderModelSpec(
|
||||
id="openai-codex/gpt-5.6-sol",
|
||||
label="GPT-5.6-Sol",
|
||||
description="Latest frontier agentic coding model.",
|
||||
owned_by="OpenAI Codex",
|
||||
context_window=272_000,
|
||||
reasoning_efforts=("low", "medium", "high", "xhigh", "max", "ultra"),
|
||||
),
|
||||
),
|
||||
source="remote",
|
||||
fetched_at=123,
|
||||
),
|
||||
)
|
||||
|
||||
def test_provider_models_payload_returns_curated_openai_codex_models() -> None:
|
||||
payload = provider_models_payload({"provider": ["openai_codex"]})
|
||||
|
||||
assert payload["status"] == "available"
|
||||
assert payload["catalog_kind"] == "hybrid"
|
||||
assert payload["source"] == "remote"
|
||||
assert payload["model_count"] == 1
|
||||
assert payload["catalog_kind"] == "builtin"
|
||||
assert payload["model_count"] == 7
|
||||
assert payload["models"][0] == {
|
||||
"id": "openai-codex/gpt-5.6-sol",
|
||||
"label": "GPT-5.6-Sol",
|
||||
"description": "Latest frontier agentic coding model.",
|
||||
"owned_by": "OpenAI Codex",
|
||||
"context_window": 272000,
|
||||
"reasoning_efforts": ["low", "medium", "high", "xhigh", "max", "ultra"],
|
||||
"supports_backend_search": False,
|
||||
"context_window": 372000,
|
||||
}
|
||||
assert [model["id"] for model in payload["models"][:3]] == [
|
||||
"openai-codex/gpt-5.6-sol",
|
||||
"openai-codex/gpt-5.6-terra",
|
||||
"openai-codex/gpt-5.6-luna",
|
||||
]
|
||||
|
||||
|
||||
def test_provider_models_payload_returns_online_github_copilot_models(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_models.get_oauth_model_catalog",
|
||||
lambda *_args, **_kwargs: OAuthModelCatalogSnapshot(
|
||||
models=(
|
||||
ProviderModelSpec(
|
||||
id="github-copilot/claude-sonnet",
|
||||
label="Claude Sonnet",
|
||||
owned_by="GitHub Copilot",
|
||||
context_window=200_000,
|
||||
),
|
||||
),
|
||||
source="remote",
|
||||
fetched_at=123,
|
||||
),
|
||||
)
|
||||
|
||||
payload = provider_models_payload({"provider": ["github_copilot"]})
|
||||
|
||||
assert payload["status"] == "available"
|
||||
assert payload["catalog_kind"] == "hybrid"
|
||||
assert payload["source"] == "remote"
|
||||
assert payload["models"][0]["id"] == "github-copilot/claude-sonnet"
|
||||
|
||||
|
||||
def test_provider_models_payload_returns_online_xai_grok_models(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_models.get_oauth_model_catalog",
|
||||
lambda *_args, **_kwargs: OAuthModelCatalogSnapshot(
|
||||
models=(
|
||||
ProviderModelSpec(
|
||||
id="xai-grok/grok-4.6",
|
||||
label="Grok 4.6",
|
||||
description="Latest frontier model",
|
||||
owned_by="xAI",
|
||||
context_window=500_000,
|
||||
reasoning_efforts=("xhigh", "high", "medium", "low"),
|
||||
supports_backend_search=True,
|
||||
),
|
||||
ProviderModelSpec(
|
||||
id="xai-grok/grok-4.5",
|
||||
label="Grok 4.5",
|
||||
owned_by="xAI",
|
||||
context_window=500_000,
|
||||
reasoning_efforts=("high", "medium", "low"),
|
||||
supports_backend_search=True,
|
||||
),
|
||||
),
|
||||
source="remote",
|
||||
fetched_at=123,
|
||||
),
|
||||
)
|
||||
|
||||
def test_provider_models_payload_returns_xai_grok_model() -> None:
|
||||
payload = provider_models_payload({"provider": ["xai_grok"]})
|
||||
|
||||
assert payload["status"] == "available"
|
||||
assert payload["catalog_kind"] == "hybrid"
|
||||
assert payload["source"] == "remote"
|
||||
assert payload["fetched_at"] == 123
|
||||
assert payload["catalog_kind"] == "builtin"
|
||||
assert payload["models"] == [
|
||||
{
|
||||
"id": "xai-grok/grok-4.6",
|
||||
"label": "Grok 4.6",
|
||||
"description": "Latest frontier model",
|
||||
"owned_by": "xAI",
|
||||
"context_window": 500000,
|
||||
"reasoning_efforts": ["xhigh", "high", "medium", "low"],
|
||||
"supports_backend_search": True,
|
||||
},
|
||||
{
|
||||
"id": "xai-grok/grok-4.5",
|
||||
"label": "Grok 4.5",
|
||||
"description": None,
|
||||
"owned_by": "xAI",
|
||||
"description": "Grok via xAI subscription; X Search is enabled when supported.",
|
||||
"owned_by": "xAI Grok",
|
||||
"context_window": 500000,
|
||||
"reasoning_efforts": ["high", "medium", "low"],
|
||||
"supports_backend_search": True,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -2258,9 +2160,7 @@ def test_model_catalog_kind_uses_provider_spec_metadata() -> None:
|
||||
assert _model_catalog_kind(find_by_name("anthropic")) == "unsupported"
|
||||
assert _model_catalog_kind(find_by_name("openrouter")) == "catalog"
|
||||
assert _model_catalog_kind(find_by_name("orcarouter")) == "catalog"
|
||||
assert _model_catalog_kind(find_by_name("openai_codex")) == "hybrid"
|
||||
assert _model_catalog_kind(find_by_name("xai_grok")) == "hybrid"
|
||||
assert _model_catalog_kind(find_by_name("github_copilot")) == "hybrid"
|
||||
assert _model_catalog_kind(find_by_name("openai_codex")) == "builtin"
|
||||
|
||||
|
||||
def test_create_model_configuration_accepts_configured_oauth_provider(
|
||||
|
||||
+6
-4
@@ -9,17 +9,19 @@ bun run --cwd tui test
|
||||
bun run --cwd tui build
|
||||
```
|
||||
|
||||
`nanobot` (or the explicit `nanobot agent` form) launches this client, leases the shared local gateway or starts it on demand, and passes the local bootstrap endpoint through environment variables. The client paints before gateway readiness, retries bootstrap in the background, and obtains fresh WebSocket and REST credentials for each connection. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. `/detach` closes the TUI after promoting the gateway to persistent background mode, keeping any active agent turn running without clients; the restored terminal prints the exact stop command for that config and explicit workspace. `nanobot gateway --background` can start or promote it persistently before opening a client. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified archive that keeps the executable together with its licenses, notices, corresponding application source, source offer, and relinking instructions. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is selected with `nanobot --classic` or `nanobot agent --classic`.
|
||||
`nanobot agent` launches this client, leases the shared local gateway or starts it on demand, and passes the local bootstrap endpoint through environment variables. The client paints before gateway readiness, retries bootstrap in the background, and obtains fresh WebSocket and REST credentials for each connection. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. `/detach` closes the TUI after promoting the gateway to persistent background mode, keeping any active agent turn running without clients; the restored terminal prints the exact stop command for that config and explicit workspace. `nanobot gateway --background` can start or promote it persistently before opening a client. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified archive that keeps the executable together with its licenses, notices, corresponding application source, source offer, and relinking instructions. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
|
||||
|
||||
The TUI uses OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen.
|
||||
Standalone terminals use OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen.
|
||||
|
||||
Assistant math written with `$...$`, `$$...$$`, `\\(...\\)`, or `\\[...\\]` is presented as
|
||||
Unicode plain text so formulas remain readable in terminals without a math renderer. Currency and
|
||||
LaTeX inside inline or fenced code remain literal.
|
||||
|
||||
## Herdr pane titles
|
||||
## Herdr host mode
|
||||
|
||||
When Herdr supplies `HERDR_ENV=1` and `HERDR_PANE_ID`, nanobot keeps the same full-screen layout, controls, and navigation available in any other terminal. Its only host-specific behavior is reporting the latest user task as the Herdr pane title through the supported pane CLI. Creating a new chat, switching to a chat without a task, and exiting the TUI clear that title. Nanobot does not report agent lifecycle, session, model, Git branch, workspace, or action metadata to Herdr.
|
||||
When Herdr supplies `HERDR_ENV=1` and `HERDR_PANE_ID`, nanobot becomes a quiet hosted client. It uses OpenTUI's main-screen mode instead of hiding the whole run in a temporary alternate screen, removes the launch card and persistent session/model/task chrome, and keeps only the transcript, compact progress, and composer. Herdr remains responsible for workspace, tab, pane, task, and attention navigation, while nanobot keeps its application-level session, new-chat, and branch commands.
|
||||
|
||||
The TUI reports its WebSocket session ID, model, Git branch, workspace, last task, and current action through Herdr's supported pane CLI. Sending work reports `working`; a persisted explicit nanobot goal block reports `blocked`; a completed turn reports `idle`; exit releases lifecycle authority. The gateway session remains the durable transcript and resume path. Standalone terminals keep the richer full-screen navigation described below.
|
||||
|
||||
The model preset and workspace access labels above the composer are live controls. Click either
|
||||
label, then click a choice; arrow keys, `Enter`, and `Esc` provide the same flow without a mouse.
|
||||
|
||||
+42
-14
@@ -21,7 +21,7 @@ import type {
|
||||
SlashCommand,
|
||||
WorkspaceScopePayload,
|
||||
} from "./protocol"
|
||||
import type { TuiHost } from "./host"
|
||||
import type { HostAgentState, HostMetadata, TuiHost } from "./host"
|
||||
import type { ClipboardImageReader } from "./clipboard-image"
|
||||
import { userMessageText, type Transcript } from "./transcript"
|
||||
|
||||
@@ -415,7 +415,8 @@ describe("NanobotTui layout", () => {
|
||||
|
||||
await setup.mockInput.typeText("这是什么? ")
|
||||
setup.mockInput.pressKey("v", { ctrl: true })
|
||||
await waitUntil(() => ui.composer.plainText === "这是什么? [Image #1] ")
|
||||
await waitUntil(() => ui.status.plainText.includes("Pasted Image #1"), 3_000)
|
||||
expect(ui.composer.plainText).toBe("这是什么? [Image #1] ")
|
||||
setup.mockInput.pressTab()
|
||||
expect(ui.status.plainText).toContain("Images cannot be queued")
|
||||
expect(ui.composer.plainText).toBe("这是什么? [Image #1] ")
|
||||
@@ -3074,18 +3075,23 @@ describe("NanobotTui layout", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("NanobotTui with a Herdr pane title reporter", () => {
|
||||
test("keeps the full terminal experience while reporting task titles", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 22, screenMode: "alternate-screen" })
|
||||
const titles: string[] = []
|
||||
describe("NanobotTui in a Herdr pane", () => {
|
||||
test("keeps local navigation while reporting task, session, lifecycle, and metadata", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 22, screenMode: "main-screen" })
|
||||
const states: Array<{ state: HostAgentState; message?: string }> = []
|
||||
const metadata: HostMetadata[] = []
|
||||
const sessions: string[] = []
|
||||
let released = false
|
||||
const host: TuiHost = {
|
||||
reportTitle(title) { titles.push(title) },
|
||||
hosted: true,
|
||||
reportState(state, message) { states.push({ state, ...(message ? { message } : {}) }) },
|
||||
reportSession(sessionId) { sessions.push(sessionId) },
|
||||
reportMetadata(value) { metadata.push(value) },
|
||||
release() { released = true },
|
||||
}
|
||||
const app = NanobotTui.mount(
|
||||
setup.renderer,
|
||||
options,
|
||||
{ ...options, branch: "feat/herdr" },
|
||||
client(),
|
||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||
host,
|
||||
@@ -3121,14 +3127,10 @@ describe("NanobotTui with a Herdr pane title reporter", () => {
|
||||
})
|
||||
await setup.flush()
|
||||
const activeFrame = setup.captureCharFrame()
|
||||
expect(activeFrame).toContain(">_ nanobot")
|
||||
expect(activeFrame).toContain("test/model")
|
||||
expect(occurrences(activeFrame, "› Ship the Herdr integration")).toBe(1)
|
||||
expect(occurrences(activeFrame, "app.ts")).toBe(1)
|
||||
expect(ui.composer.placeholder).toBe("Enter send now · Tab send next")
|
||||
expect(ui.composerFrame.height).toBe(3)
|
||||
expect(titles).toEqual(["Ship the Herdr integration"])
|
||||
|
||||
app.accept({
|
||||
event: "turn_end",
|
||||
chat_id: "chat",
|
||||
@@ -3139,6 +3141,27 @@ describe("NanobotTui with a Herdr pane title reporter", () => {
|
||||
ui_summary: "Approval required",
|
||||
},
|
||||
})
|
||||
await setup.flush()
|
||||
const frame = setup.captureCharFrame()
|
||||
|
||||
expect(sessions).toEqual(["chat"])
|
||||
expect(occurrences(frame, "› Ship the Herdr integration")).toBe(1)
|
||||
expect(frame).not.toContain(">_ nanobot")
|
||||
expect(frame).not.toContain("test/model")
|
||||
expect(states.some(({ state }) => state === "working")).toBe(true)
|
||||
expect(states.at(-1)).toEqual({ state: "blocked", message: "Approval required" })
|
||||
expect(metadata.at(-1)).toMatchObject({
|
||||
model: "default · test/model",
|
||||
branch: "feat/herdr",
|
||||
workspace: "/tmp/nanobot-workspace",
|
||||
task: "Ship the Herdr integration",
|
||||
action: "Approval required",
|
||||
})
|
||||
|
||||
setup.resize(42, 6)
|
||||
await setup.renderOnce()
|
||||
expect(setup.captureCharFrame()).toContain("› Ship the Herdr integration")
|
||||
|
||||
app.accept({
|
||||
event: "user_message",
|
||||
chat_id: "chat",
|
||||
@@ -3146,8 +3169,13 @@ describe("NanobotTui with a Herdr pane title reporter", () => {
|
||||
turn_id: "turn-2",
|
||||
starts_turn: true,
|
||||
})
|
||||
|
||||
expect(titles).toEqual(["Ship the Herdr integration", "Approved"])
|
||||
app.accept({
|
||||
event: "turn_end",
|
||||
chat_id: "chat",
|
||||
turn_id: "turn-2",
|
||||
goal_state: { active: false },
|
||||
})
|
||||
expect(states.at(-1)?.state).toBe("idle")
|
||||
|
||||
app.stop()
|
||||
expect(released).toBe(true)
|
||||
|
||||
+167
-31
@@ -94,7 +94,7 @@ import {
|
||||
type FooterMode,
|
||||
type FooterHintTheme,
|
||||
} from "./footer-hints"
|
||||
import { configureOpenTuiEnvironment, createTuiHost, type TuiHost } from "./host"
|
||||
import { createTuiHost, currentGitBranch, type TuiHost } from "./host"
|
||||
|
||||
interface AppOptions {
|
||||
wsUrl?: string
|
||||
@@ -107,6 +107,8 @@ interface AppOptions {
|
||||
model: string
|
||||
modelPreset: string
|
||||
workspace: string
|
||||
hostWorkspace?: string
|
||||
branch?: string
|
||||
version: string
|
||||
access: string
|
||||
theme: "auto" | ThemeMode
|
||||
@@ -402,6 +404,10 @@ function connectionStatusText(
|
||||
return "Session ended"
|
||||
}
|
||||
|
||||
function singleLine(value: string, limit = 120): string {
|
||||
return value.replace(/\s+/gu, " ").trim().slice(0, limit)
|
||||
}
|
||||
|
||||
export function sessionExitMessage(chatId: string): string {
|
||||
const sessionId = `websocket:${chatId}`
|
||||
return `Resume with: nanobot agent --session ${sessionId}\n`
|
||||
@@ -459,6 +465,7 @@ export class NanobotTui {
|
||||
private activeTurnId: string | null = null
|
||||
private activeLabel = "Thinking"
|
||||
private activeStartedAt = 0
|
||||
private lastProgress = ""
|
||||
private finalMessage = ""
|
||||
private turnHadAnswer = false
|
||||
private historyLoaded = false
|
||||
@@ -507,8 +514,13 @@ export class NanobotTui {
|
||||
private readonly silentCommandTurns = new Set<string>()
|
||||
private currentFileEdits: FileEditEvent[] = []
|
||||
private lastFileEdits: FileEditEvent[] = []
|
||||
private currentTask = ""
|
||||
private currentAction = ""
|
||||
private hostBlocked = false
|
||||
private recoveryState: RecoveryState | null = null
|
||||
private recoveryPending = false
|
||||
private hostWorkspace: string
|
||||
private hostBranch: string
|
||||
private readonly apiReauthenticator: ApiReauthenticator | undefined
|
||||
private readonly clipboardImageReader: ClipboardImageReader
|
||||
private apiRefreshPromise: Promise<GatewayApiConnection> | null = null
|
||||
@@ -533,6 +545,8 @@ export class NanobotTui {
|
||||
this.defaultModelPreset = options.modelPreset
|
||||
this.modelName = options.model
|
||||
this.modelPreset = options.modelPreset
|
||||
this.hostWorkspace = options.hostWorkspace || options.workspace
|
||||
this.hostBranch = options.branch || ""
|
||||
this.apiReauthenticator = options.bootstrapUrl
|
||||
? (rejectedApiToken) => this.refreshApiConnection(rejectedApiToken)
|
||||
: undefined
|
||||
@@ -547,6 +561,7 @@ export class NanobotTui {
|
||||
transcriptTheme(this.palette, this.backgroundKnown),
|
||||
treeSitterClient,
|
||||
(state) => this.handleTranscriptNavigation(state),
|
||||
!host.hosted,
|
||||
options.workspace,
|
||||
)
|
||||
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
|
||||
@@ -657,19 +672,21 @@ export class NanobotTui {
|
||||
truncate: true,
|
||||
fg: this.palette.muted,
|
||||
selectable: false,
|
||||
onMouseOver: () => { this.titleText.fg = this.palette.accent },
|
||||
onMouseOut: () => this.renderTitleColor(),
|
||||
onMouseDown: (event) => {
|
||||
if (event.button !== 0) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
this.renderer.clearSelection()
|
||||
if (this.sessionLoading || this.sessionMenu.visible) {
|
||||
this.closeSessions()
|
||||
return
|
||||
}
|
||||
void this.openSessions()
|
||||
},
|
||||
...(host.hosted ? {} : {
|
||||
onMouseOver: () => { this.titleText.fg = this.palette.accent },
|
||||
onMouseOut: () => this.renderTitleColor(),
|
||||
onMouseDown: (event) => {
|
||||
if (event.button !== 0) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
this.renderer.clearSelection()
|
||||
if (this.sessionLoading || this.sessionMenu.visible) {
|
||||
this.closeSessions()
|
||||
return
|
||||
}
|
||||
void this.openSessions()
|
||||
},
|
||||
}),
|
||||
})
|
||||
this.runtimeControls = new RuntimeControls(
|
||||
renderer,
|
||||
@@ -704,9 +721,11 @@ export class NanobotTui {
|
||||
},
|
||||
)
|
||||
this.title.add(this.titleText)
|
||||
this.title.add(this.runtimeControls.modelText)
|
||||
this.title.add(this.runtimeControls.accessText)
|
||||
this.title.add(this.runtimeControls.contextText)
|
||||
if (!host.hosted) {
|
||||
this.title.add(this.runtimeControls.modelText)
|
||||
this.title.add(this.runtimeControls.accessText)
|
||||
this.title.add(this.runtimeControls.contextText)
|
||||
}
|
||||
const composerSurface = this.composerSurface()
|
||||
this.composerFrame = new BoxRenderable(renderer, {
|
||||
id: "nanobot-tui-composer-frame",
|
||||
@@ -800,7 +819,7 @@ export class NanobotTui {
|
||||
this.shell.add(this.branchMenu.root)
|
||||
this.shell.add(this.contextPanel.root)
|
||||
this.shell.add(this.runtimeControls.menuRoot)
|
||||
this.shell.add(this.title)
|
||||
if (!host.hosted) this.shell.add(this.title)
|
||||
this.shell.add(this.queuePreview.root)
|
||||
this.shell.add(this.recoveryNotice.root)
|
||||
this.shell.add(this.composerFrame)
|
||||
@@ -817,16 +836,16 @@ export class NanobotTui {
|
||||
this.handleResize()
|
||||
this.composer.focus()
|
||||
this.transcript.header(options)
|
||||
this.syncHostMetadata()
|
||||
}
|
||||
|
||||
static async create(options: AppOptions): Promise<NanobotTui> {
|
||||
configureOpenTuiEnvironment()
|
||||
const host = createTuiHost()
|
||||
const renderer = await createCliRenderer({
|
||||
targetFps: 30,
|
||||
exitOnCtrlC: false,
|
||||
useMouse: true,
|
||||
screenMode: "alternate-screen",
|
||||
screenMode: host.hosted ? "main-screen" : "alternate-screen",
|
||||
externalOutputMode: "passthrough",
|
||||
consoleMode: "disabled",
|
||||
})
|
||||
@@ -855,6 +874,7 @@ export class NanobotTui {
|
||||
// Network setup and small menu payloads do not depend on terminal colors.
|
||||
// Start them while OSC theme detection is in flight instead of serializing
|
||||
// up to one second of otherwise independent startup work.
|
||||
this.host.reportState("unknown", "Getting ready")
|
||||
this.client.connect()
|
||||
void this.loadCommands()
|
||||
void this.loadMentions()
|
||||
@@ -1001,7 +1021,8 @@ export class NanobotTui {
|
||||
prompt.options.media,
|
||||
prompt.displayContent,
|
||||
)
|
||||
this.host.reportTitle(prompt.content)
|
||||
this.hostBlocked = false
|
||||
this.setCurrentTask(prompt.content)
|
||||
if (steering) {
|
||||
this.renderActiveStatus()
|
||||
this.updateMeta()
|
||||
@@ -1016,9 +1037,12 @@ export class NanobotTui {
|
||||
this.readyDetail = ""
|
||||
this.finalMessage = ""
|
||||
this.turnHadAnswer = false
|
||||
this.lastProgress = ""
|
||||
this.activeLabel = "Thinking"
|
||||
this.currentFileEdits = []
|
||||
this.setCurrentAction("Thinking")
|
||||
this.setActive(true, startedAt)
|
||||
this.reportHostWorking()
|
||||
}
|
||||
|
||||
private reconcileTurnOwnership(event: {
|
||||
@@ -1041,6 +1065,7 @@ export class NanobotTui {
|
||||
if (event.event === "attached") {
|
||||
const switchedSession = Boolean(this.currentChatId && this.currentChatId !== event.chat_id)
|
||||
this.currentChatId = event.chat_id
|
||||
this.host.reportSession(event.chat_id)
|
||||
if (event.usage) this.lastUsage = event.usage
|
||||
if (event.model_preset !== undefined) {
|
||||
this.applyModelPreset(event.model_preset)
|
||||
@@ -1092,13 +1117,17 @@ export class NanobotTui {
|
||||
)) {
|
||||
this.recordPrompt(event.text)
|
||||
}
|
||||
this.host.reportTitle(event.text)
|
||||
this.hostBlocked = false
|
||||
this.setCurrentTask(event.text)
|
||||
this.reconcileTurnOwnership(event)
|
||||
if (this.activeTurn) this.reportHostWorking()
|
||||
return
|
||||
}
|
||||
case "delta":
|
||||
this.setActive(true)
|
||||
this.activeLabel = "Writing"
|
||||
if (!this.currentAction) this.setCurrentAction("Writing")
|
||||
this.reportHostWorking()
|
||||
this.turnHadAnswer = true
|
||||
this.transcript.stream(event.text)
|
||||
return
|
||||
@@ -1118,8 +1147,11 @@ export class NanobotTui {
|
||||
}
|
||||
if (event.kind) {
|
||||
this.activeLabel = event.kind === "tool_hint" ? "Working" : "Thinking"
|
||||
this.transcript.progress(event.text, event.tool_events)
|
||||
this.lastProgress = this.transcript.progress(event.text, event.tool_events)
|
||||
if (this.lastProgress) this.setCurrentAction(this.lastProgress)
|
||||
else if (!this.currentAction) this.setCurrentAction(this.activeLabel)
|
||||
this.setActive(true)
|
||||
this.reportHostWorking()
|
||||
} else {
|
||||
this.finalMessage = event.text
|
||||
}
|
||||
@@ -1128,8 +1160,10 @@ export class NanobotTui {
|
||||
this.activeLabel = "Editing"
|
||||
this.currentFileEdits = mergeFileEdits(this.currentFileEdits, event.edits)
|
||||
if (this.diffViewer.visible) this.diffViewer.update(this.currentFileEdits)
|
||||
this.transcript.fileEdits(event.edits)
|
||||
this.lastProgress = this.transcript.fileEdits(event.edits)
|
||||
this.setCurrentAction(this.lastProgress || "Editing")
|
||||
this.setActive(true)
|
||||
this.reportHostWorking()
|
||||
return
|
||||
case "reasoning_delta":
|
||||
this.activeLabel = "Thinking"
|
||||
@@ -1163,6 +1197,7 @@ export class NanobotTui {
|
||||
if (typeof event.context_window_tokens === "number") {
|
||||
this.contextWindowTokens = event.context_window_tokens
|
||||
}
|
||||
this.applyHostGoalState(event.goal_state)
|
||||
this.updateTitle()
|
||||
this.setActive(false)
|
||||
// A synthetic/rehydrated turn may already be idle, in which case
|
||||
@@ -1172,6 +1207,7 @@ export class NanobotTui {
|
||||
? `${(event.latency_ms / 1000).toFixed(1)}s`
|
||||
: ""
|
||||
this.status.content = this.readyStatus()
|
||||
this.reportHostResting()
|
||||
if (this.contextTokens !== null) void this.refreshContextEstimate(event.chat_id)
|
||||
this.sendNextFollowUp()
|
||||
return
|
||||
@@ -1180,12 +1216,17 @@ export class NanobotTui {
|
||||
if (event.status === "running") {
|
||||
if (event.turn_id) this.activeTurnId = event.turn_id
|
||||
this.activeLabel = "Working"
|
||||
if (!this.currentAction) this.setCurrentAction("Working")
|
||||
this.setActive(true, typeof event.started_at === "number" ? event.started_at * 1000 : undefined)
|
||||
this.reportHostWorking()
|
||||
} else {
|
||||
this.setActive(false)
|
||||
this.reportHostResting()
|
||||
}
|
||||
return
|
||||
case "goal_state":
|
||||
this.applyHostGoalState(event.goal_state)
|
||||
if (!this.activeTurn) this.reportHostResting()
|
||||
return
|
||||
case "recovery_state":
|
||||
this.applyRecoveryState(event)
|
||||
@@ -1232,6 +1273,8 @@ export class NanobotTui {
|
||||
this.turnHadAnswer = false
|
||||
this.restoreQueuedPrompts()
|
||||
this.setActive(false)
|
||||
this.setCurrentAction(event.reason || event.detail || "Error")
|
||||
this.reportHostResting()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1269,7 +1312,11 @@ export class NanobotTui {
|
||||
this.restorePromptHistory(history.messages)
|
||||
const reversedHistory = [...history.messages].reverse()
|
||||
const lastUser = reversedHistory.find((message) => message.role === "user")
|
||||
if (lastUser) this.host.reportTitle(lastUser.content)
|
||||
if (lastUser) this.setCurrentTask(lastUser.content)
|
||||
const lastActivity = reversedHistory.find((message) => message.role === "activity")
|
||||
if (lastActivity) {
|
||||
this.setCurrentAction(lastActivity.fileEdits?.length ? "Edited" : lastActivity.content)
|
||||
}
|
||||
this.lastFileEdits = latestTurnFileEdits(history.messages)
|
||||
if (this.diffViewer.visible) this.diffViewer.update(this.lastFileEdits)
|
||||
}
|
||||
@@ -1281,6 +1328,7 @@ export class NanobotTui {
|
||||
this.ready = true
|
||||
if (!this.activeTurn) {
|
||||
this.status.content = this.readyStatus()
|
||||
this.reportHostResting()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1306,24 +1354,35 @@ export class NanobotTui {
|
||||
this.recoveryPending = false
|
||||
if (state.status === "resuming") {
|
||||
this.recoveryNotice.hide()
|
||||
this.hostBlocked = false
|
||||
this.activeLabel = "Continuing"
|
||||
this.setCurrentAction("Continuing interrupted task")
|
||||
this.setActive(true)
|
||||
this.reportHostWorking()
|
||||
return
|
||||
}
|
||||
if (state.status === "awaiting_user" || state.status === "failed") {
|
||||
this.activeTurnId = null
|
||||
this.setActive(false)
|
||||
this.hostBlocked = true
|
||||
this.recoveryNotice.show(state)
|
||||
const detail = state.reason || (state.status === "failed"
|
||||
? "Recovery failed"
|
||||
: "Task interrupted")
|
||||
this.setCurrentAction(detail)
|
||||
this.status.content = state.can_continue === false
|
||||
? "Interrupted · dismiss to start a new message"
|
||||
: "Interrupted · continue or dismiss"
|
||||
this.host.reportState("blocked", detail)
|
||||
this.composer.focus()
|
||||
return
|
||||
}
|
||||
this.clearRecoveryState()
|
||||
this.activeTurnId = null
|
||||
this.hostBlocked = false
|
||||
this.setActive(false)
|
||||
if (this.ready) this.status.content = this.readyStatus()
|
||||
this.reportHostResting()
|
||||
}
|
||||
|
||||
private async updateRecovery(action: "continue" | "dismiss"): Promise<void> {
|
||||
@@ -1355,6 +1414,7 @@ export class NanobotTui {
|
||||
this.recoveryPending = false
|
||||
this.recoveryNotice.setBusy(false)
|
||||
this.status.content = error instanceof Error ? error.message : String(error)
|
||||
this.host.reportState("blocked", state.reason || "Task interrupted")
|
||||
} finally {
|
||||
this.composer.focus()
|
||||
}
|
||||
@@ -1408,11 +1468,13 @@ export class NanobotTui {
|
||||
this.connectionMessage = connectionStatusText(status, info)
|
||||
if (status === "connected") {
|
||||
this.ready = false
|
||||
this.host.reportState("unknown", "Getting ready")
|
||||
this.renderConnectionMessage()
|
||||
return
|
||||
}
|
||||
if (["starting", "connecting", "reconnecting", "unavailable"].includes(status)) {
|
||||
this.ready = false
|
||||
this.host.reportState("unknown", this.connectionMessage)
|
||||
if (status === "reconnecting" || status === "unavailable") this.setActive(false)
|
||||
this.renderConnectionMessage()
|
||||
return
|
||||
@@ -1420,12 +1482,14 @@ export class NanobotTui {
|
||||
if (status === "error") {
|
||||
if (info) this.ready = false
|
||||
this.setActive(false)
|
||||
this.host.reportState("unknown", this.connectionMessage)
|
||||
this.renderConnectionMessage()
|
||||
return
|
||||
}
|
||||
if (!this.quitting) {
|
||||
this.ready = false
|
||||
this.setActive(false)
|
||||
this.host.reportState("unknown", "Disconnected")
|
||||
this.renderConnectionMessage()
|
||||
}
|
||||
}
|
||||
@@ -1465,6 +1529,7 @@ export class NanobotTui {
|
||||
}
|
||||
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
|
||||
this.shimmerTimer = null
|
||||
this.lastProgress = ""
|
||||
this.status.content = this.readyStatus()
|
||||
}
|
||||
|
||||
@@ -1888,7 +1953,7 @@ export class NanobotTui {
|
||||
this.syncComposerPlaceholder()
|
||||
this.contextPanel.resize(this.renderer.height)
|
||||
this.diffViewer.resize(this.renderer.width)
|
||||
this.title.visible = this.renderer.height >= 14
|
||||
if (!this.host.hosted) this.title.visible = this.renderer.height >= 14
|
||||
this.runtimeControls.resize(this.renderer.width)
|
||||
this.updateTitle()
|
||||
this.updateMeta()
|
||||
@@ -1957,6 +2022,10 @@ export class NanobotTui {
|
||||
}
|
||||
|
||||
private updateTitle(): void {
|
||||
if (this.host.hosted) {
|
||||
this.syncHostMetadata()
|
||||
return
|
||||
}
|
||||
const identity = this.sessionTitle.trim() || "nanobot"
|
||||
this.titleText.maxWidth = Math.max(8, Math.floor(this.renderer.width * 0.38))
|
||||
this.titleText.content = identity
|
||||
@@ -1967,14 +2036,69 @@ export class NanobotTui {
|
||||
: ""} ctx`
|
||||
this.runtimeControls.updateModel(this.modelName, this.modelPreset)
|
||||
this.runtimeControls.updateContext(context)
|
||||
this.syncHostMetadata()
|
||||
}
|
||||
|
||||
private renderTitleColor(): void {
|
||||
this.titleText.fg = this.sessionLoading || this.sessionMenu.visible
|
||||
this.titleText.fg = !this.host.hosted && (this.sessionLoading || this.sessionMenu.visible)
|
||||
? this.palette.accent
|
||||
: this.palette.muted
|
||||
}
|
||||
|
||||
private setCurrentTask(task: string): void {
|
||||
const next = singleLine(task)
|
||||
if (!next || next === this.currentTask) return
|
||||
this.currentTask = next
|
||||
this.updateTitle()
|
||||
}
|
||||
|
||||
private setCurrentAction(action: string): void {
|
||||
const next = singleLine(action.replace(/^\s*[·›✓×]\s*/u, ""), 80)
|
||||
if (!next || next === this.currentAction) return
|
||||
this.currentAction = next
|
||||
this.syncHostMetadata()
|
||||
}
|
||||
|
||||
private clearHostContext(): void {
|
||||
this.currentTask = ""
|
||||
this.currentAction = ""
|
||||
this.hostBlocked = false
|
||||
this.updateTitle()
|
||||
}
|
||||
|
||||
private syncHostMetadata(): void {
|
||||
const model = [this.modelPreset, this.modelName].filter(Boolean).join(" · ")
|
||||
this.host.reportMetadata({
|
||||
model,
|
||||
branch: this.hostBranch,
|
||||
workspace: this.hostWorkspace,
|
||||
task: this.currentTask,
|
||||
action: this.currentAction,
|
||||
})
|
||||
}
|
||||
|
||||
private applyHostGoalState(state: Record<string, unknown> | undefined): void {
|
||||
if (!state) return
|
||||
this.hostBlocked = state.status === "blocked"
|
||||
if (!this.hostBlocked) return
|
||||
const summary = typeof state.ui_summary === "string" ? state.ui_summary : ""
|
||||
const recap = typeof state.recap === "string" ? state.recap : ""
|
||||
const objective = typeof state.objective === "string" ? state.objective : ""
|
||||
this.setCurrentAction(summary || recap || objective || "Needs input")
|
||||
this.host.reportState("blocked", summary || recap || objective || this.currentTask)
|
||||
}
|
||||
|
||||
private reportHostResting(): void {
|
||||
this.host.reportState(
|
||||
this.hostBlocked ? "blocked" : "idle",
|
||||
this.hostBlocked ? this.currentAction || this.currentTask : this.currentAction,
|
||||
)
|
||||
}
|
||||
|
||||
private reportHostWorking(): void {
|
||||
if (!this.hostBlocked) this.host.reportState("working", this.currentTask)
|
||||
}
|
||||
|
||||
private resizeComposer(): void {
|
||||
const verticalPadding = this.renderer.height >= 12 ? 1 : 0
|
||||
const maxContentHeight = Math.max(1, Math.min(12, Math.floor(this.renderer.height / 3)))
|
||||
@@ -2260,6 +2384,11 @@ export class NanobotTui {
|
||||
}
|
||||
|
||||
private applyWorkspaceScope(scope: WorkspaceScopePayload): void {
|
||||
if (scope.project_path) {
|
||||
this.hostWorkspace = scope.project_path
|
||||
this.hostBranch = currentGitBranch(scope.project_path)
|
||||
this.syncHostMetadata()
|
||||
}
|
||||
this.runtimeControls.updateWorkspaceScope(scope)
|
||||
this.updateTitle()
|
||||
if (!this.activeTurn && this.ready) this.status.content = this.readyStatus()
|
||||
@@ -2351,7 +2480,8 @@ export class NanobotTui {
|
||||
this.clearPromptQueue()
|
||||
this.sessionMetadataId += 1
|
||||
this.sessionTitle = `Fork · ${preview.slice(0, 48)}`
|
||||
this.host.reportTitle(preview)
|
||||
this.clearHostContext()
|
||||
this.setCurrentTask(preview)
|
||||
this.contextTokens = null
|
||||
this.lastUsage = null
|
||||
this.readyDetail = ""
|
||||
@@ -2448,7 +2578,7 @@ export class NanobotTui {
|
||||
this.clearRecoveryState()
|
||||
this.queuePreview.update([])
|
||||
this.sessionMetadataId += 1
|
||||
this.host.reportTitle("")
|
||||
this.clearHostContext()
|
||||
this.sessionTitle = sessionLabel(session)
|
||||
this.applySessionModel(session)
|
||||
this.applySessionScope(session)
|
||||
@@ -2484,7 +2614,7 @@ export class NanobotTui {
|
||||
this.clearRecoveryState()
|
||||
this.clearPromptQueue()
|
||||
this.sessionMetadataId += 1
|
||||
this.host.reportTitle("")
|
||||
this.clearHostContext()
|
||||
this.sessionTitle = "New chat"
|
||||
this.sessionModelPreset = null
|
||||
this.modelName = this.defaultModelName
|
||||
@@ -2531,13 +2661,17 @@ export class NanobotTui {
|
||||
if (!silent) this.recordPrompt(content)
|
||||
|
||||
if (lifecycle === "agent_turn") {
|
||||
this.host.reportTitle(content)
|
||||
this.hostBlocked = false
|
||||
this.setCurrentTask(content)
|
||||
this.activeTurnId = turnId
|
||||
this.finalMessage = ""
|
||||
this.turnHadAnswer = false
|
||||
this.lastProgress = ""
|
||||
this.activeLabel = "Thinking"
|
||||
this.currentFileEdits = []
|
||||
this.setCurrentAction("Thinking")
|
||||
this.setActive(true)
|
||||
this.reportHostWorking()
|
||||
} else if (lifecycle === "finalize_active_turn") {
|
||||
this.activeTurnId = null
|
||||
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
||||
@@ -2545,10 +2679,12 @@ export class NanobotTui {
|
||||
this.finalMessage = ""
|
||||
this.turnHadAnswer = false
|
||||
this.setActive(false)
|
||||
this.reportHostResting()
|
||||
this.status.content = "Resetting chat…"
|
||||
} else if (lifecycle === "stop_active_turn") {
|
||||
this.activeTurnId = null
|
||||
this.setActive(false)
|
||||
this.reportHostResting()
|
||||
this.status.content = "Stopping…"
|
||||
} else if (!this.activeTurn) {
|
||||
this.status.content = `Running ${content.split(/\s+/u, 1)[0]}…`
|
||||
|
||||
+51
-60
@@ -1,91 +1,82 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import {
|
||||
configureOpenTuiEnvironment,
|
||||
createTuiHost,
|
||||
} from "./host"
|
||||
import { createTuiHost, currentGitBranch } from "./host"
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await Bun.sleep(0)
|
||||
await Bun.sleep(40)
|
||||
await Bun.sleep(0)
|
||||
}
|
||||
|
||||
describe("TUI host integration", () => {
|
||||
test("disables the explicit-width probe on Windows", () => {
|
||||
const environment: Record<string, string | undefined> = {}
|
||||
|
||||
configureOpenTuiEnvironment(environment, "win32")
|
||||
|
||||
expect(environment.OPENTUI_FORCE_EXPLICIT_WIDTH).toBe("false")
|
||||
})
|
||||
|
||||
test("preserves explicit probe choices and leaves other platforms unchanged", () => {
|
||||
const overridden = {
|
||||
OPENTUI_FORCE_EXPLICIT_WIDTH: "true",
|
||||
}
|
||||
const nonWindows: Record<string, string | undefined> = {}
|
||||
|
||||
configureOpenTuiEnvironment(overridden, "win32")
|
||||
configureOpenTuiEnvironment(nonWindows, "linux")
|
||||
|
||||
expect(overridden.OPENTUI_FORCE_EXPLICIT_WIDTH).toBe("true")
|
||||
expect(nonWindows.OPENTUI_FORCE_EXPLICIT_WIDTH).toBeUndefined()
|
||||
test("reads the current workspace branch without leaking git errors", () => {
|
||||
expect(currentGitBranch(process.cwd())).not.toBe("")
|
||||
expect(currentGitBranch("/definitely/not/a/repository")).toBe("")
|
||||
})
|
||||
|
||||
test("standalone terminals remain a no-op", async () => {
|
||||
const commands: string[][] = []
|
||||
const host = createTuiHost({}, async (command) => { commands.push([...command]) })
|
||||
|
||||
host.reportTitle("task")
|
||||
host.reportState("working", "task")
|
||||
host.reportSession("chat")
|
||||
host.reportMetadata({ model: "gpt", task: "task" })
|
||||
host.release()
|
||||
await settle()
|
||||
|
||||
expect(host.hosted).toBe(false)
|
||||
expect(commands).toEqual([])
|
||||
})
|
||||
|
||||
test("requires both Herdr environment markers", async () => {
|
||||
const commands: string[][] = []
|
||||
const run = async (command: readonly string[]) => { commands.push([...command]) }
|
||||
|
||||
createTuiHost({ HERDR_ENV: "1" }, run).reportTitle("missing pane")
|
||||
createTuiHost({ HERDR_PANE_ID: "w1:p2" }, run).reportTitle("missing host")
|
||||
await settle()
|
||||
|
||||
expect(commands).toEqual([])
|
||||
})
|
||||
|
||||
test("reports only normalized pane title changes and clears the title on release", async () => {
|
||||
test("reports semantic lifecycle, session identity, metadata, and release", async () => {
|
||||
const commands: string[][] = []
|
||||
const host = createTuiHost(
|
||||
{ HERDR_ENV: "1", HERDR_PANE_ID: "w1:p2", HERDR_BIN_PATH: "/bin/herdr" },
|
||||
async (command) => { commands.push([...command]) },
|
||||
)
|
||||
|
||||
host.reportTitle(" Fix\nHerdr integration ")
|
||||
host.reportTitle("Fix Herdr integration")
|
||||
host.reportTitle("Review results")
|
||||
host.reportMetadata({
|
||||
model: "openai/gpt",
|
||||
branch: "feat/host",
|
||||
workspace: "/repo",
|
||||
task: " Fix\nHerdr integration ",
|
||||
action: "Testing",
|
||||
})
|
||||
host.reportSession("chat-1")
|
||||
host.reportState("working", "Fix Herdr integration")
|
||||
host.reportState("working", "Fix Herdr integration")
|
||||
host.reportState("blocked", "Approval required")
|
||||
host.release()
|
||||
host.reportTitle("ignored after release")
|
||||
await settle()
|
||||
|
||||
expect(commands).toEqual([
|
||||
[
|
||||
"/bin/herdr", "pane", "report-metadata", "w1:p2",
|
||||
"--source", "nanobot:tui:metadata", "--seq", "1",
|
||||
"--title", "Fix Herdr integration",
|
||||
],
|
||||
[
|
||||
"/bin/herdr", "pane", "report-metadata", "w1:p2",
|
||||
"--source", "nanobot:tui:metadata", "--seq", "2",
|
||||
"--title", "Review results",
|
||||
],
|
||||
[
|
||||
"/bin/herdr", "pane", "report-metadata", "w1:p2",
|
||||
"--source", "nanobot:tui:metadata", "--seq", "3", "--clear-title",
|
||||
],
|
||||
])
|
||||
expect(commands.flat()).not.toContain("report-agent")
|
||||
expect(commands.flat()).not.toContain("report-agent-session")
|
||||
expect(commands.flat()).not.toContain("--token")
|
||||
expect(host.hosted).toBe(true)
|
||||
expect(commands).toHaveLength(6)
|
||||
expect(commands[0]).toContain("pane")
|
||||
expect(commands[0]).toContain("report-metadata")
|
||||
expect(commands[0]).toContain("task=Fix Herdr integration")
|
||||
expect(commands[1]).toContain("report-agent-session")
|
||||
expect(commands[1]).toContain("chat-1")
|
||||
expect(commands[2]).toContain("working")
|
||||
expect(commands[2]).toContain("--agent-session-id")
|
||||
expect(commands[3]).toContain("blocked")
|
||||
expect(commands[4]).toContain("--clear-token")
|
||||
expect(commands[5]).toContain("release-agent")
|
||||
})
|
||||
|
||||
test("metadata patches only changed tokens", async () => {
|
||||
const commands: string[][] = []
|
||||
const host = createTuiHost(
|
||||
{ HERDR_ENV: "1", HERDR_PANE_ID: "w1:p2" },
|
||||
async (command) => { commands.push([...command]) },
|
||||
)
|
||||
|
||||
host.reportMetadata({ model: "gpt", branch: "main" })
|
||||
host.reportMetadata({ model: "gpt", branch: "main" })
|
||||
host.reportMetadata({ model: "gpt", branch: "" })
|
||||
await settle()
|
||||
|
||||
expect(commands).toHaveLength(1)
|
||||
expect(commands[0]).toContain("model=gpt")
|
||||
expect(commands[0]).toContain("--clear-token")
|
||||
expect(commands[0]).toContain("branch")
|
||||
})
|
||||
})
|
||||
|
||||
+128
-25
@@ -1,35 +1,60 @@
|
||||
export type HostAgentState = "idle" | "working" | "blocked" | "unknown"
|
||||
|
||||
export interface HostMetadata {
|
||||
model?: string
|
||||
branch?: string
|
||||
workspace?: string
|
||||
task?: string
|
||||
action?: string
|
||||
}
|
||||
|
||||
export interface TuiHost {
|
||||
reportTitle(title: string): void
|
||||
readonly hosted: boolean
|
||||
reportState(state: HostAgentState, message?: string): void
|
||||
reportSession(sessionId: string): void
|
||||
reportMetadata(metadata: HostMetadata): void
|
||||
release(): void
|
||||
}
|
||||
|
||||
export function currentGitBranch(workspace: string): string {
|
||||
const path = workspace.trim()
|
||||
if (!path) return ""
|
||||
try {
|
||||
const branch = spawnText(["git", "-C", path, "branch", "--show-current"])
|
||||
if (branch) return branch
|
||||
const revision = spawnText(["git", "-C", path, "rev-parse", "--short", "HEAD"])
|
||||
return revision ? `@${revision}` : ""
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
type Environment = Record<string, string | undefined>
|
||||
type CommandRunner = (command: readonly string[]) => Promise<void>
|
||||
|
||||
const AGENT = "nanobot"
|
||||
const LIFECYCLE_SOURCE = "nanobot:tui"
|
||||
const METADATA_SOURCE = "nanobot:tui:metadata"
|
||||
|
||||
export function configureOpenTuiEnvironment(
|
||||
environment: Environment = process.env,
|
||||
platform = process.platform,
|
||||
): void {
|
||||
if (platform !== "win32") return
|
||||
|
||||
// OpenTUI probes OSC 66 support on the main screen before its renderer is
|
||||
// active. Some Windows terminal hosts do not restore the cursor around that
|
||||
// probe, so shutdown resumes in terminal history instead of below the TUI.
|
||||
// Keep an explicit user choice, but use the safe default on Windows.
|
||||
environment.OPENTUI_FORCE_EXPLICIT_WIDTH ??= "false"
|
||||
}
|
||||
const METADATA_KEYS = ["model", "branch", "workspace", "task", "action"] as const
|
||||
const METADATA_FLUSH_MS = 32
|
||||
|
||||
class StandaloneHost implements TuiHost {
|
||||
reportTitle(): void {}
|
||||
readonly hosted = false
|
||||
reportState(): void {}
|
||||
reportSession(): void {}
|
||||
reportMetadata(): void {}
|
||||
release(): void {}
|
||||
}
|
||||
|
||||
class HerdrHost implements TuiHost {
|
||||
readonly hosted = true
|
||||
private sequence = 0
|
||||
private released = false
|
||||
private lastTitle = ""
|
||||
private lastState = ""
|
||||
private lastSession = ""
|
||||
private metadata: HostMetadata = {}
|
||||
private readonly pendingMetadata = new Set<typeof METADATA_KEYS[number]>()
|
||||
private metadataTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private queue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(
|
||||
@@ -38,25 +63,97 @@ class HerdrHost implements TuiHost {
|
||||
private readonly run: CommandRunner,
|
||||
) {}
|
||||
|
||||
reportTitle(title: string): void {
|
||||
reportState(state: HostAgentState, message = ""): void {
|
||||
if (this.released) return
|
||||
const cleanTitle = normalize(title)
|
||||
if (cleanTitle === this.lastTitle) return
|
||||
this.lastTitle = cleanTitle
|
||||
const cleanMessage = normalize(message)
|
||||
const fingerprint = `${state}\0${cleanMessage}\0${this.lastSession}`
|
||||
if (fingerprint === this.lastState) return
|
||||
this.lastState = fingerprint
|
||||
// Preserve causal ordering when a semantic state transition follows a
|
||||
// pending metadata snapshot; repeated working heartbeats still stay free.
|
||||
this.flushMetadata()
|
||||
const args = [
|
||||
"pane", "report-agent", this.paneId,
|
||||
"--source", LIFECYCLE_SOURCE,
|
||||
"--agent", AGENT,
|
||||
"--state", state,
|
||||
"--seq", String(this.nextSequence()),
|
||||
]
|
||||
if (cleanMessage) args.push("--message", cleanMessage)
|
||||
if (this.lastSession) args.push("--agent-session-id", this.lastSession)
|
||||
this.enqueue(args)
|
||||
}
|
||||
|
||||
reportSession(sessionId: string): void {
|
||||
if (this.released) return
|
||||
const cleanSession = normalize(sessionId, 256)
|
||||
if (!cleanSession || cleanSession === this.lastSession) return
|
||||
this.lastSession = cleanSession
|
||||
this.lastState = ""
|
||||
this.flushMetadata()
|
||||
this.enqueue([
|
||||
"pane", "report-agent-session", this.paneId,
|
||||
"--source", LIFECYCLE_SOURCE,
|
||||
"--agent", AGENT,
|
||||
"--agent-session-id", cleanSession,
|
||||
"--seq", String(this.nextSequence()),
|
||||
])
|
||||
}
|
||||
|
||||
reportMetadata(next: HostMetadata): void {
|
||||
if (this.released) return
|
||||
for (const key of METADATA_KEYS) {
|
||||
if (!(key in next)) continue
|
||||
const value = normalize(next[key])
|
||||
if (value === normalize(this.metadata[key])) continue
|
||||
this.pendingMetadata.add(key)
|
||||
}
|
||||
if (!this.pendingMetadata.size) return
|
||||
this.metadata = { ...this.metadata, ...next }
|
||||
if (this.metadataTimer) return
|
||||
this.metadataTimer = setTimeout(() => this.flushMetadata(), METADATA_FLUSH_MS)
|
||||
}
|
||||
|
||||
private flushMetadata(): void {
|
||||
if (this.metadataTimer) clearTimeout(this.metadataTimer)
|
||||
this.metadataTimer = null
|
||||
if (!this.pendingMetadata.size) return
|
||||
const args = [
|
||||
"pane", "report-metadata", this.paneId,
|
||||
"--source", METADATA_SOURCE,
|
||||
"--agent", AGENT,
|
||||
"--display-agent", AGENT,
|
||||
"--seq", String(this.nextSequence()),
|
||||
cleanTitle ? "--title" : "--clear-title",
|
||||
]
|
||||
if (cleanTitle) args.push(cleanTitle)
|
||||
const task = normalize(this.metadata.task)
|
||||
args.push(task ? "--title" : "--clear-title")
|
||||
if (task) args.push(task)
|
||||
for (const key of this.pendingMetadata) {
|
||||
const value = normalize(this.metadata[key])
|
||||
args.push(value ? "--token" : "--clear-token", value ? `${key}=${value}` : key)
|
||||
}
|
||||
this.pendingMetadata.clear()
|
||||
this.enqueue(args)
|
||||
}
|
||||
|
||||
release(): void {
|
||||
if (this.released) return
|
||||
this.reportTitle("")
|
||||
this.flushMetadata()
|
||||
this.released = true
|
||||
const clear = [
|
||||
"pane", "report-metadata", this.paneId,
|
||||
"--source", METADATA_SOURCE,
|
||||
"--clear-title", "--clear-display-agent", "--clear-state-labels",
|
||||
"--seq", String(this.nextSequence()),
|
||||
]
|
||||
for (const key of METADATA_KEYS) clear.push("--clear-token", key)
|
||||
this.enqueue(clear)
|
||||
this.enqueue([
|
||||
"pane", "release-agent", this.paneId,
|
||||
"--source", LIFECYCLE_SOURCE,
|
||||
"--agent", AGENT,
|
||||
"--seq", String(this.nextSequence()),
|
||||
])
|
||||
}
|
||||
|
||||
private nextSequence(): number {
|
||||
@@ -70,8 +167,8 @@ class HerdrHost implements TuiHost {
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(value: string, limit = 80): string {
|
||||
return value.replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/\s+/gu, " ").trim().slice(0, limit)
|
||||
function normalize(value: string | undefined, limit = 80): string {
|
||||
return (value || "").replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/\s+/gu, " ").trim().slice(0, limit)
|
||||
}
|
||||
|
||||
async function runCommand(command: readonly string[]): Promise<void> {
|
||||
@@ -79,6 +176,12 @@ async function runCommand(command: readonly string[]): Promise<void> {
|
||||
await child.exited
|
||||
}
|
||||
|
||||
function spawnText(command: readonly string[]): string {
|
||||
const result = Bun.spawnSync([...command], { stdout: "pipe", stderr: "ignore" })
|
||||
if (result.exitCode !== 0) return ""
|
||||
return new TextDecoder().decode(result.stdout).trim()
|
||||
}
|
||||
|
||||
export function createTuiHost(
|
||||
environment: Environment = process.env,
|
||||
run: CommandRunner = runCommand,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NanobotTui, sessionExitMessage, type AppOptions } from "./app"
|
||||
import { currentGitBranch } from "./host"
|
||||
|
||||
// Keep in sync with _TUI_DETACH_EXIT_CODE in nanobot/cli/tui_launcher.py.
|
||||
const TUI_DETACH_EXIT_CODE = 90
|
||||
@@ -10,6 +11,7 @@ function themePreference(): AppOptions["theme"] {
|
||||
}
|
||||
|
||||
const workspace = process.env.NANOBOT_TUI_WORKSPACE?.trim() || ""
|
||||
const hostWorkspace = process.cwd()
|
||||
const bootstrapUrl = process.env.NANOBOT_TUI_BOOTSTRAP_URL?.trim() || ""
|
||||
const wsUrl = process.env.NANOBOT_TUI_WS_URL?.trim() || ""
|
||||
const healthUrl = process.env.NANOBOT_TUI_HEALTH_URL?.trim() || ""
|
||||
@@ -32,6 +34,8 @@ const options: AppOptions = {
|
||||
model: process.env.NANOBOT_TUI_MODEL?.trim() || "unknown model",
|
||||
modelPreset: process.env.NANOBOT_TUI_MODEL_PRESET?.trim() || "default",
|
||||
workspace,
|
||||
hostWorkspace,
|
||||
branch: currentGitBranch(hostWorkspace),
|
||||
version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev",
|
||||
access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access",
|
||||
theme: themePreference(),
|
||||
|
||||
@@ -145,6 +145,7 @@ export class Transcript {
|
||||
private theme: TranscriptTheme,
|
||||
private readonly treeSitterClient: TreeSitterClient,
|
||||
private readonly onNavigationChange?: (state: TranscriptNavigation) => void,
|
||||
private readonly showHeader = true,
|
||||
private readonly workspace = "",
|
||||
) {
|
||||
this.root = new ScrollBoxRenderable(renderer, {
|
||||
@@ -197,6 +198,7 @@ export class Transcript {
|
||||
}
|
||||
|
||||
header(options: TranscriptHeader): void {
|
||||
if (!this.showHeader) return
|
||||
const row = new BoxRenderable(this.renderer, {
|
||||
id: this.id("header-row"),
|
||||
width: "100%",
|
||||
@@ -263,7 +265,7 @@ export class Transcript {
|
||||
if (messages.length === 0) return
|
||||
const previousTop = this.root.scrollTop
|
||||
const previousHeight = this.root.scrollHeight
|
||||
let index = 1 // Keep the launch header first.
|
||||
let index = this.showHeader ? 1 : 0
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
if (message.turnId && this.userTurnIds.has(message.turnId)) continue
|
||||
|
||||
+2
-9
@@ -2154,16 +2154,9 @@ function Shell({
|
||||
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
|
||||
const deletingActive = activeKey !== null && deletingKeys.has(activeKey);
|
||||
const currentIndex = topicSessions.findIndex((s) => s.key === activeKey);
|
||||
const availableKeys = new Set(topicSessions.map((session) => session.key));
|
||||
const siblingFallbackKey = deletingActive
|
||||
? activeTabState?.paneKeys.find((key) => (
|
||||
!deletingKeys.has(key) && availableKeys.has(key)
|
||||
)) ?? null
|
||||
: null;
|
||||
const fallbackKey = deletingActive
|
||||
? (
|
||||
siblingFallbackKey
|
||||
?? topicSessions.slice(currentIndex + 1).find((session) => (
|
||||
topicSessions.slice(currentIndex + 1).find((session) => (
|
||||
!deletingKeys.has(session.key)
|
||||
))?.key
|
||||
?? topicSessions.slice(0, Math.max(0, currentIndex)).reverse().find((session) => (
|
||||
@@ -2198,7 +2191,7 @@ function Shell({
|
||||
} catch (e) {
|
||||
console.error("Failed to delete session", e);
|
||||
}
|
||||
}, [pendingDelete, deleteChat, activeKey, activeTabState, navigate, topicSessions]);
|
||||
}, [pendingDelete, deleteChat, activeKey, navigate, topicSessions]);
|
||||
|
||||
const onRequestDeleteMany = useCallback(async (items: SidebarDeleteItem[]) => {
|
||||
const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values());
|
||||
|
||||
@@ -527,22 +527,18 @@ function MarketplaceSkillRow({
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 truncate text-[12px] text-muted-foreground">
|
||||
{skill.source}
|
||||
{skill.version ? <span>· v{skill.version}</span> : null}
|
||||
{skill.provider === "skills_sh" ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
{skill.metric === "installs_24h"
|
||||
? t("settings.skills.marketplaceInstalls24h", {
|
||||
count: skill.installs,
|
||||
formattedCount: skill.installs.toLocaleString(),
|
||||
defaultValue: "{{formattedCount}} installs / 24h",
|
||||
})
|
||||
: t("settings.skills.marketplaceInstalls", {
|
||||
count: skill.installs,
|
||||
formattedCount: skill.installs.toLocaleString(),
|
||||
defaultValue: "{{formattedCount}} installs",
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
<span>·</span>
|
||||
{skill.metric === "installs_24h"
|
||||
? t("settings.skills.marketplaceInstalls24h", {
|
||||
count: skill.installs,
|
||||
formattedCount: skill.installs.toLocaleString(),
|
||||
defaultValue: "{{formattedCount}} installs / 24h",
|
||||
})
|
||||
: t("settings.skills.marketplaceInstalls", {
|
||||
count: skill.installs,
|
||||
formattedCount: skill.installs.toLocaleString(),
|
||||
defaultValue: "{{formattedCount}} installs",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{skill.provider === "skills_sh" ? <TrendSparkline values={trend} /> : null}
|
||||
|
||||
@@ -546,7 +546,7 @@ export function ModelsSettings({
|
||||
>
|
||||
{saving || creatingSaving
|
||||
? tx("settings.actions.saving", "Saving...")
|
||||
: tx("settings.actions.savePreset", "Save")}
|
||||
: tx("settings.actions.savePreset", "Save preset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -204,15 +204,13 @@ export function ModelIdPicker({
|
||||
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
|
||||
const providerRequiresConfiguration =
|
||||
!hasStaticModels && hasConcreteProvider && !providerConfigured;
|
||||
const providerHasManagedModels = ["builtin", "hybrid"].includes(
|
||||
providerRow?.model_catalog ?? "",
|
||||
);
|
||||
const providerHasBuiltinModels = providerRow?.model_catalog === "builtin";
|
||||
const providerUsesManualModelIds =
|
||||
!hasStaticModels &&
|
||||
hasConcreteProvider &&
|
||||
providerConfigured &&
|
||||
providerRow?.auth_type === "oauth" &&
|
||||
!providerHasManagedModels;
|
||||
!providerHasBuiltinModels;
|
||||
const canFetchModels =
|
||||
!hasStaticModels &&
|
||||
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
|
||||
|
||||
@@ -72,10 +72,9 @@ function normalizeTab(value: unknown): WorkbenchTabState {
|
||||
...requestedLayoutPaneKeys,
|
||||
...paneKeys.filter((key) => !requestedLayoutPaneKeys.includes(key)),
|
||||
];
|
||||
const title = normalizeTitle(candidate.title);
|
||||
return {
|
||||
explicit: candidate.explicit === true || title !== null,
|
||||
title,
|
||||
explicit: candidate.explicit === true,
|
||||
title: normalizeTitle(candidate.title),
|
||||
paneKeys,
|
||||
layoutPaneKeys,
|
||||
layout: isLayout(candidate.layout) ? candidate.layout : "columns",
|
||||
@@ -310,9 +309,7 @@ export function renameWorkbenchTab(
|
||||
const normalized = normalizeTitle(title);
|
||||
if (!normalized) return state;
|
||||
return updateTab(state, tabKey, (tab) => (
|
||||
tab.title === normalized && tab.explicit
|
||||
? tab
|
||||
: { ...tab, explicit: true, title: normalized }
|
||||
tab.title === normalized ? tab : { ...tab, title: normalized }
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -483,7 +483,7 @@
|
||||
"save": "Save",
|
||||
"saving": "Saving",
|
||||
"saveOrder": "Save order",
|
||||
"savePreset": "Save",
|
||||
"savePreset": "Save preset",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"deleting": "Deleting...",
|
||||
|
||||
@@ -271,7 +271,7 @@
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando",
|
||||
"saveOrder": "Guardar orden",
|
||||
"savePreset": "Guardar",
|
||||
"savePreset": "Guardar preajuste",
|
||||
"delete": "Eliminar",
|
||||
"deleting": "Eliminando...",
|
||||
"edit": "Editar",
|
||||
|
||||
@@ -271,7 +271,7 @@
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement",
|
||||
"saveOrder": "Enregistrer l’ordre",
|
||||
"savePreset": "Enregistrer",
|
||||
"savePreset": "Enregistrer le préréglage",
|
||||
"delete": "Supprimer",
|
||||
"deleting": "Suppression...",
|
||||
"edit": "Modifier",
|
||||
|
||||
@@ -271,7 +271,7 @@
|
||||
"save": "Simpan",
|
||||
"saving": "Menyimpan",
|
||||
"saveOrder": "Simpan urutan",
|
||||
"savePreset": "Simpan",
|
||||
"savePreset": "Simpan prasetel",
|
||||
"delete": "Hapus",
|
||||
"deleting": "Menghapus...",
|
||||
"edit": "Ubah",
|
||||
|
||||
@@ -271,7 +271,7 @@
|
||||
"save": "保存",
|
||||
"saving": "保存中",
|
||||
"saveOrder": "順序を保存",
|
||||
"savePreset": "保存",
|
||||
"savePreset": "プリセットを保存",
|
||||
"delete": "削除",
|
||||
"deleting": "削除中...",
|
||||
"edit": "編集",
|
||||
|
||||
@@ -271,7 +271,7 @@
|
||||
"save": "저장",
|
||||
"saving": "저장 중",
|
||||
"saveOrder": "순서 저장",
|
||||
"savePreset": "저장",
|
||||
"savePreset": "프리셋 저장",
|
||||
"delete": "삭제",
|
||||
"deleting": "삭제 중...",
|
||||
"edit": "편집",
|
||||
|
||||
@@ -483,7 +483,7 @@
|
||||
"save": "Salvar",
|
||||
"saving": "Salvando",
|
||||
"saveOrder": "Salvar ordem",
|
||||
"savePreset": "Salvar",
|
||||
"savePreset": "Salvar predefinição",
|
||||
"delete": "Excluir",
|
||||
"deleting": "Excluindo...",
|
||||
"edit": "Editar",
|
||||
|
||||
@@ -271,7 +271,7 @@
|
||||
"save": "Lưu",
|
||||
"saving": "Đang lưu",
|
||||
"saveOrder": "Lưu thứ tự",
|
||||
"savePreset": "Lưu",
|
||||
"savePreset": "Lưu cấu hình đặt trước",
|
||||
"delete": "Xóa",
|
||||
"deleting": "Đang xóa...",
|
||||
"edit": "Sửa",
|
||||
|
||||
@@ -483,7 +483,7 @@
|
||||
"save": "保存",
|
||||
"saving": "正在保存",
|
||||
"saveOrder": "保存顺序",
|
||||
"savePreset": "保存",
|
||||
"savePreset": "保存预设",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"deleting": "正在删除...",
|
||||
|
||||
@@ -271,7 +271,7 @@
|
||||
"save": "儲存",
|
||||
"saving": "正在儲存",
|
||||
"saveOrder": "儲存順序",
|
||||
"savePreset": "儲存",
|
||||
"savePreset": "儲存預設",
|
||||
"delete": "刪除",
|
||||
"deleting": "正在刪除…",
|
||||
"edit": "編輯",
|
||||
|
||||
+1
-11
@@ -510,8 +510,6 @@ interface ProviderModelInfo {
|
||||
description?: string | null;
|
||||
owned_by?: string | null;
|
||||
context_window?: number | null;
|
||||
reasoning_efforts?: string[];
|
||||
supports_backend_search?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderModelsPayload {
|
||||
@@ -523,15 +521,7 @@ export interface ProviderModelsPayload {
|
||||
| "not_configured"
|
||||
| "missing_api_base"
|
||||
| "error";
|
||||
catalog_kind:
|
||||
| "builtin"
|
||||
| "hybrid"
|
||||
| "official"
|
||||
| "catalog"
|
||||
| "local"
|
||||
| "custom"
|
||||
| "unsupported";
|
||||
source?: "remote" | "cache" | "stale" | "fallback";
|
||||
catalog_kind: "builtin" | "official" | "catalog" | "local" | "custom" | "unsupported";
|
||||
models: ProviderModelInfo[];
|
||||
model_count: number;
|
||||
message?: string | null;
|
||||
|
||||
@@ -1201,7 +1201,6 @@ describe("App layout", () => {
|
||||
expect(screen.getAllByText("SkillHub")).toHaveLength(2);
|
||||
expect(screen.getAllByText("skills.sh")).toHaveLength(2);
|
||||
expect(screen.getByText(/14,481 installs \/ 24h/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/11,831 installs/)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "SkillHub" }));
|
||||
expect(screen.getByText("ima-skills")).toBeInTheDocument();
|
||||
expect(screen.queryByText("find-skills")).not.toBeInTheDocument();
|
||||
@@ -2531,7 +2530,7 @@ describe("App layout", () => {
|
||||
).toBe(true);
|
||||
await user.click(screen.getByRole("button", { name: "Select model" }));
|
||||
await user.click(await screen.findByRole("option", { name: /openai\/gpt-4o-mini/ }));
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.queryByText("Up to date.")).not.toBeInTheDocument();
|
||||
fireEvent.click(
|
||||
@@ -3415,23 +3414,6 @@ describe("App layout", () => {
|
||||
expect(restoredGrid).toHaveAttribute("data-layout", "rows");
|
||||
});
|
||||
|
||||
const alphaGroupButton = within(sidebar).getByRole("button", {
|
||||
name: "Group: Alpha",
|
||||
});
|
||||
const alphaGroup = alphaGroupButton.closest("[data-sidebar-tab-group]") as HTMLElement;
|
||||
fireEvent.pointerDown(within(alphaGroup).getByLabelText("Topic actions for Alpha"), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
});
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" }));
|
||||
const renameDialog = await screen.findByRole("dialog", { name: "Rename group" });
|
||||
fireEvent.change(within(renameDialog).getByPlaceholderText("Group name"), {
|
||||
target: { value: "Research" },
|
||||
});
|
||||
fireEvent.click(within(renameDialog).getByRole("button", { name: "Save" }));
|
||||
expect(await within(sidebar).findByRole("button", { name: "Group: Research" }))
|
||||
.toBeInTheDocument();
|
||||
|
||||
fireEvent.pointerDown(within(sidebar).getByRole("button", {
|
||||
name: "New topic pane actions",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
@@ -3439,93 +3421,9 @@ describe("App layout", () => {
|
||||
name: "Remove",
|
||||
}));
|
||||
await waitFor(() => expect(screen.getByTestId("pane-grid").children).toHaveLength(1));
|
||||
const researchGroup = within(sidebar).getByRole("button", {
|
||||
name: "Group: Research",
|
||||
}).closest("[data-sidebar-tab-group]") as HTMLElement;
|
||||
expect(within(researchGroup).getByRole("list", { name: "Panes in Research" }))
|
||||
.toBeInTheDocument();
|
||||
expect(within(researchGroup).getByRole("button", { name: "Alpha" }))
|
||||
.toBeInTheDocument();
|
||||
expect(within(sidebar).getAllByRole("button", { name: "New topic" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps a named group and its remaining pane active after deleting a pane", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:new-pane",
|
||||
channel: "websocket",
|
||||
chatId: "new-pane",
|
||||
createdAt: "2026-08-05T12:00:00Z",
|
||||
updatedAt: "2026-08-05T12:00:00Z",
|
||||
title: "New topic",
|
||||
preview: "",
|
||||
},
|
||||
{
|
||||
key: "websocket:unrelated",
|
||||
channel: "websocket",
|
||||
chatId: "unrelated",
|
||||
createdAt: "2026-08-05T11:00:00Z",
|
||||
updatedAt: "2026-08-05T11:00:00Z",
|
||||
title: "Unrelated",
|
||||
preview: "",
|
||||
},
|
||||
{
|
||||
key: "websocket:alpha",
|
||||
channel: "websocket",
|
||||
chatId: "alpha",
|
||||
createdAt: "2026-08-05T10:00:00Z",
|
||||
updatedAt: "2026-08-05T10:00:00Z",
|
||||
title: "Alpha",
|
||||
preview: "",
|
||||
},
|
||||
];
|
||||
window.history.replaceState(null, "", "/#/chat/websocket%3Anew-pane");
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string | URL | Request) => {
|
||||
if (String(url) === "/api/webui/sidebar-state") {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
workbench: {
|
||||
version: 1,
|
||||
tabs: {
|
||||
"tab:websocket:alpha": {
|
||||
explicit: false,
|
||||
title: "Research",
|
||||
paneKeys: ["websocket:alpha", "websocket:new-pane"],
|
||||
layoutPaneKeys: ["websocket:alpha", "websocket:new-pane"],
|
||||
layout: "columns",
|
||||
splitRatios: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 404 };
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
expect(await within(sidebar).findByRole("button", { name: "Group: Research" }))
|
||||
.toBeInTheDocument();
|
||||
fireEvent.pointerDown(within(sidebar).getByRole("button", {
|
||||
name: "New topic pane actions",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Delete" }));
|
||||
expect(await screen.findByText("Delete this topic?")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => expect(deleteChatSpy).toHaveBeenCalledWith("websocket:new-pane"));
|
||||
await waitFor(() => expect(window.location.hash).toBe("#/chat/websocket%3Aalpha"));
|
||||
expect(within(sidebar).getByRole("button", { name: "Group: Research" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByTestId("pane-grid").children).toHaveLength(1);
|
||||
expect(screen.getByTestId("pane-grid").firstElementChild)
|
||||
.toHaveAttribute("aria-label", "Alpha");
|
||||
}, 15_000);
|
||||
|
||||
it("opens search from the keyboard shortcut", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
|
||||
@@ -141,7 +141,7 @@ describe("Settings models", () => {
|
||||
fireEvent.change(screen.getByLabelText("Temperature"), {
|
||||
target: { value: "0.4" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save preset" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestMutationMock).toHaveBeenCalledWith(
|
||||
@@ -173,7 +173,7 @@ describe("Settings models", () => {
|
||||
|
||||
const nameInput = screen.getByRole("textbox", { name: "Preset name" });
|
||||
fireEvent.change(nameInput, { target: { value: "Codex" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save preset" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestMutationMock).toHaveBeenCalledWith(
|
||||
@@ -196,7 +196,7 @@ describe("Settings models", () => {
|
||||
|
||||
const nameInput = screen.getByRole("textbox", { name: "Preset name" });
|
||||
fireEvent.change(nameInput, { target: { value: "Codex" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save preset" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"A preset with this name already exists.",
|
||||
@@ -368,7 +368,7 @@ describe("Settings models", () => {
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Save order" })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Temperature")).toHaveValue(0.4);
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("keeps repeated fallback preset rows stable when changing the primary preset", async () => {
|
||||
@@ -604,7 +604,7 @@ describe("Settings models", () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "New model preset" }));
|
||||
expect(screen.queryByRole("dialog", { name: "New model preset" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Save preset" })).toBeDisabled();
|
||||
expect(
|
||||
screen.queryByText("Complete the preset before saving."),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -619,7 +619,7 @@ describe("Settings models", () => {
|
||||
target: { value: "openai/gpt-4o-mini" },
|
||||
});
|
||||
fireEvent.keyDown(modelSearch, { key: "Enter" });
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
const saveButton = screen.getByRole("button", { name: "Save preset" });
|
||||
expect(saveButton).toBeEnabled();
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
@@ -656,7 +656,7 @@ describe("Settings models", () => {
|
||||
});
|
||||
fireEvent.change(modelSearch, { target: { value: "openai/gpt-4o-mini" } });
|
||||
fireEvent.keyDown(modelSearch, { key: "Enter" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save preset" }));
|
||||
|
||||
expect(requestMutationMock).not.toHaveBeenCalled();
|
||||
expect(nameInput).toHaveAttribute("aria-invalid", "true");
|
||||
@@ -1295,88 +1295,6 @@ describe("Settings models", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("loads hybrid online models for configured OAuth providers", async () => {
|
||||
const base = settingsPayload();
|
||||
const payload: SettingsPayload = {
|
||||
...base,
|
||||
agent: {
|
||||
...base.agent,
|
||||
model: "xai-grok/grok-4.5",
|
||||
provider: "xai_grok",
|
||||
resolved_provider: "xai_grok",
|
||||
},
|
||||
model_presets: [
|
||||
{
|
||||
...base.model_presets[0],
|
||||
model: "xai-grok/grok-4.5",
|
||||
provider: "xai_grok",
|
||||
},
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
name: "xai_grok",
|
||||
label: "xAI Grok",
|
||||
configured: true,
|
||||
auth_type: "oauth",
|
||||
api_key_required: false,
|
||||
api_key_hint: null,
|
||||
api_base: null,
|
||||
default_api_base: "https://cli-chat-proxy.grok.com/v1",
|
||||
model_catalog: "hybrid",
|
||||
oauth_account: "acct-test",
|
||||
oauth_expires_at: null,
|
||||
oauth_login_supported: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings/provider-models?provider=xai_grok") {
|
||||
return jsonResponse({
|
||||
provider: "xai_grok",
|
||||
label: "xAI Grok",
|
||||
status: "available",
|
||||
catalog_kind: "hybrid",
|
||||
source: "remote",
|
||||
models: [
|
||||
{
|
||||
id: "xai-grok/grok-4.6",
|
||||
label: "Grok 4.6",
|
||||
description: "Latest frontier model",
|
||||
owned_by: "xAI",
|
||||
context_window: 500_000,
|
||||
},
|
||||
{
|
||||
id: "xai-grok/grok-4.5",
|
||||
label: "Grok 4.5",
|
||||
owned_by: "xAI",
|
||||
context_window: 500_000,
|
||||
},
|
||||
],
|
||||
model_count: 2,
|
||||
fetched_at: 1,
|
||||
});
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderSettingsView({ initialSection: "models", initialSettings: payload });
|
||||
|
||||
await togglePresetEditor();
|
||||
const modelButtons = await screen.findAllByRole("button", {
|
||||
name: /xai-grok\/grok-4\.5/i,
|
||||
});
|
||||
await openPopover(modelButtons[modelButtons.length - 1]);
|
||||
|
||||
expect(await screen.findByText("Grok 4.6")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Latest frontier model/)).toBeInTheDocument();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/settings/provider-models?provider=xai_grok",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates presets in the inline editor and can cancel without opening a dialog", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -1499,7 +1417,7 @@ describe("Settings models", () => {
|
||||
fireEvent.change(screen.getByLabelText("Reasoning effort"), {
|
||||
target: { value: "provider-native-mode" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save preset" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("workbench model", () => {
|
||||
state = renameWorkbenchTab(state, tabKey, "Research");
|
||||
|
||||
expect(workbenchTab(state, tabKey)).toEqual({
|
||||
explicit: true,
|
||||
explicit: false,
|
||||
title: "Research",
|
||||
paneKeys: ["pane-a", "pane-b"],
|
||||
layoutPaneKeys: ["pane-a", "pane-b"],
|
||||
@@ -62,24 +62,6 @@ describe("workbench model", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a named group when a pane is detached", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
|
||||
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||
state = renameWorkbenchTab(state, tabKey, "Research");
|
||||
state = detachWorkbenchPane(state, tabKey, "pane-b");
|
||||
|
||||
expect(workbenchTab(state, tabKey)).toEqual({
|
||||
explicit: true,
|
||||
title: "Research",
|
||||
paneKeys: ["pane-a"],
|
||||
layoutPaneKeys: ["pane-a"],
|
||||
layout: "columns",
|
||||
splitRatios: [],
|
||||
});
|
||||
expect(normalizeWorkbenchState(state)).toEqual(state);
|
||||
expect(reconcileWorkbench(state, new Set(["pane-a"]))).toEqual(state);
|
||||
});
|
||||
|
||||
it("detaches a pane without persisting its standalone projection", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
|
||||
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||
@@ -216,7 +198,7 @@ describe("workbench model", () => {
|
||||
);
|
||||
|
||||
expect(workbenchTab(reconciled, "alpha")).toEqual({
|
||||
explicit: true,
|
||||
explicit: false,
|
||||
title: "Alpha",
|
||||
paneKeys: ["pane-a", "pane-b"],
|
||||
layoutPaneKeys: ["pane-a", "pane-b"],
|
||||
|
||||
Reference in New Issue
Block a user