feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)

* feat(desktop): add native host scaffold

* feat(webui): track turns and usage in gateway

* feat(webui): polish desktop chat experience

* feat(apps): add ArcGIS and Joplin logos

* feat(desktop): polish shell and shared surfaces

* fix(webui): avoid preview chips for glob references

* test: align CI expectations for token fallback

* feat(webui): preview prompt rail entries

* feat(webui): add prompt navigator drawer

* style(webui): refine prompt navigator placement

* style(webui): align prompt navigator with header actions

* style(webui): simplify prompt navigator header

* refactor(webui): clean thread resource refresh

* feat(desktop): add native reply notifications

* fix(webui): preserve desktop restart and replay state

* fix(desktop): harden gateway proxy startup

* fix(web): fall back when readability is unavailable

* fix(desktop): hide window instead of closing on macos

* fix(webui): unify desktop header actions

* fix(webui): simplify prompt history rows

* fix(desktop): log notification delivery failures

* chore(desktop): clean source package artifacts

* fix(cron): support one-time relative reminders

* fix(webui): reveal scroll button in place

* Revert "fix(cron): support one-time relative reminders"

This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b.

* refactor(webui): extract token usage heatmap

* docs(desktop): clarify contributor guides

---------

Co-authored-by: chengyongru <2755839590@qq.com>
This commit is contained in:
Xubin Ren
2026-06-06 19:49:33 +08:00
committed by GitHub
co-authored by chengyongru
parent a1b9577224
commit ab9f49970d
103 changed files with 10483 additions and 1003 deletions
-1
View File
@@ -356,7 +356,6 @@ class TestEphemeralHooks:
await loop.process_direct("test", session_key="cli:normal")
spy.before_iteration.assert_called()
class TestDreamCommitMessage:
async def test_commit_includes_response_summary(self, tmp_path):
"""Git auto-commit after Dream should include the LLM response in the body."""
+12 -12
View File
@@ -592,16 +592,16 @@ async def test_internal_continuation_queues_turn_without_fake_user_history(
"paused",
[],
[*initial_messages, {"role": "assistant", "content": "paused"}],
"max_iterations",
False,
)
"max_iterations",
False,
)
return (
"done",
[],
[*initial_messages, {"role": "assistant", "content": "done"}],
"completed",
False,
)
"completed",
False,
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
pending: asyncio.Queue[InboundMessage] = asyncio.Queue()
@@ -665,9 +665,9 @@ async def test_internal_continuation_preserves_streaming_route_metadata(
"paused",
[],
[*initial_messages, {"role": "assistant", "content": "paused"}],
"max_iterations",
False,
)
"max_iterations",
False,
)
assert on_stream is not None
assert on_stream_end is not None
await on_stream("done")
@@ -744,9 +744,9 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
"paused",
[],
[*initial_messages, {"role": "assistant", "content": "paused"}],
"max_iterations",
False,
)
"max_iterations",
False,
)
return (
"done",
[],
+48 -1
View File
@@ -170,6 +170,48 @@ async def test_runner_passes_cached_tokens_to_hook_context():
assert len(captured_usage) == 1
assert captured_usage[0]["cached_tokens"] == 150
assert captured_usage[0]["provider_tokens"] == 220
@pytest.mark.asyncio
async def test_runner_estimates_usage_when_provider_omits_usage(monkeypatch):
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock(spec=LLMProvider)
captured_usage: list[dict] = []
class UsageHook(AgentHook):
async def after_iteration(self, context: AgentHookContext) -> None:
captured_usage.append(dict(context.usage))
async def chat_with_retry(**kwargs):
return LLMResponse(content="done", tool_calls=[], usage={})
provider.chat_with_retry = chat_with_retry
tools = MagicMock()
tools.get_definitions.return_value = [{"type": "function", "function": {"name": "lookup"}}]
monkeypatch.setattr(
"nanobot.agent.runner.estimate_prompt_tokens_chain",
lambda provider, model, messages, tools: (123, "test"),
)
monkeypatch.setattr("nanobot.agent.runner.estimate_message_tokens", lambda message: 7)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "hi"}],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=UsageHook(),
))
assert result.usage["prompt_tokens"] == 123
assert result.usage["completion_tokens"] == 7
assert result.usage["total_tokens"] == 130
assert result.usage["estimated_tokens"] == 130
assert captured_usage[0]["estimated_tokens"] == 130
@pytest.mark.asyncio
@@ -232,7 +274,12 @@ async def test_runner_calls_run_level_hooks_on_success():
"done",
"completed",
None,
{"prompt_tokens": 3, "completion_tokens": 2},
{
"prompt_tokens": 3,
"completion_tokens": 2,
"total_tokens": 5,
"provider_tokens": 5,
},
["user", "assistant"],
),
("on_finally", "completed", None),
+173 -2
View File
@@ -104,6 +104,7 @@ def bus() -> MagicMock:
@pytest.fixture(autouse=True)
def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
monkeypatch.setattr(
"nanobot.webui.workspaces.get_webui_dir",
lambda: tmp_path / "webui",
@@ -277,6 +278,8 @@ async def test_token_issue_route_requires_secret_when_static_token_configured(bu
@pytest.mark.asyncio
async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) -> None:
from nanobot.webui.transcript import read_transcript_lines
channel = _ch(bus)
conn = MagicMock()
conn.remote_address = ("127.0.0.1", 50123)
@@ -284,14 +287,30 @@ async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) ->
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "message", "chat_id": "chat-1", "content": "hello", "webui": True},
{
"type": "message",
"chat_id": "chat-1",
"content": "hello",
"webui": True,
"turn_id": "turn-1",
},
)
msg = bus.publish_inbound.await_args.args[0]
assert msg.channel == "websocket"
assert msg.chat_id == "chat-1"
assert msg.metadata["webui"] is True
assert msg.metadata["webui_turn_id"] == "turn-1"
assert msg.metadata["_wants_stream"] is True
lines = read_transcript_lines("websocket:chat-1")
assert lines == [{
"event": "user",
"chat_id": "chat-1",
"text": "hello",
"turn_id": "turn-1",
"turn_phase": "user",
"turn_seq": 1,
}]
@pytest.mark.asyncio
@@ -359,7 +378,7 @@ async def test_webui_user_transcript_append_failure_does_not_block_inbound(
def fail_append(_session_key: str, _obj: dict[str, Any]) -> None:
raise OSError("disk full")
monkeypatch.setattr("nanobot.channels.websocket.append_transcript_object", fail_append)
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", fail_append)
channel = _ch(bus)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
@@ -664,6 +683,58 @@ async def test_webui_scope_rejects_non_loopback_custom_scope(bus: MagicMock, tmp
assert sessions.read_session_file("websocket:chat-remote") is None
@pytest.mark.asyncio
async def test_native_webui_scope_allows_custom_scope_without_loopback(
bus: MagicMock,
tmp_path,
) -> None:
default_workspace = tmp_path / "default"
project = tmp_path / "project"
default_workspace.mkdir()
project.mkdir()
sessions = SessionManager(tmp_path / "sessions")
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
gateway=_basic_handler(
bus,
session_manager=sessions,
workspace_path=default_workspace,
runtime_surface="native",
),
)
conn = AsyncMock()
conn.remote_address = None
await channel._dispatch_envelope(
conn,
"native-client",
{
"type": "set_workspace_scope",
"chat_id": "chat-native",
"workspace_scope": {
"project_path": str(project),
"access_mode": "full",
},
},
)
payload = json.loads(conn.send.await_args.args[0])
assert payload["event"] == "session_updated"
assert payload["chat_id"] == "chat-native"
assert payload["workspace_scope"]["project_path"] == str(project.resolve())
assert payload["workspace_scope"]["project_name"] == "project"
assert payload["workspace_scope"]["access_mode"] == "full"
assert payload["workspace_scope"]["restrict_to_workspace"] is False
assert payload["workspace_scope"]["sandbox_status"]["restrict_to_workspace"] is False
assert payload["workspace_scope"]["sandbox_status"]["workspace_root"] == str(project.resolve())
saved = sessions.read_session_file("websocket:chat-native")
assert saved["metadata"]["workspace_scope"] == {
"project_path": str(project.resolve()),
"access_mode": "full",
}
@pytest.mark.asyncio
async def test_send_delivers_json_message_with_media_and_reply() -> None:
bus = MagicMock()
@@ -799,6 +870,7 @@ async def test_send_progress_includes_structured_tool_events() -> None:
metadata={
"_progress": True,
"_tool_hint": True,
"webui_turn_id": "turn-1",
"_tool_events": [
{
"version": 1,
@@ -818,6 +890,9 @@ async def test_send_progress_includes_structured_tool_events() -> None:
payload = json.loads(mock_ws.send.await_args.args[0])
assert payload["event"] == "message"
assert payload["kind"] == "tool_hint"
assert payload["turn_id"] == "turn-1"
assert payload["turn_phase"] == "activity"
assert payload["turn_seq"] == 1
assert payload["tool_events"] == [
{
"version": 1,
@@ -1091,6 +1166,37 @@ async def test_send_reasoning_without_subscribers_is_noop() -> None:
assert channel._subs == {}
@pytest.mark.asyncio
async def test_stream_transcript_persists_without_subscribers() -> None:
from nanobot.webui.transcript import build_webui_thread_response, read_transcript_lines
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
bus,
gateway=_basic_handler(bus),
)
await channel.send_delta("chat-1", "hello", {"_stream_delta": True, "_stream_id": "s1"})
await channel.send_delta("chat-1", " world", {"_stream_delta": True, "_stream_id": "s1"})
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "s1"})
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_turn_end": True, "latency_ms": 42},
))
assert channel._subs == {}
lines = read_transcript_lines("websocket:chat-1")
assert [line["event"] for line in lines] == ["delta", "delta", "stream_end", "turn_end"]
body = build_webui_thread_response("websocket:chat-1")
assert body is not None
assert body["messages"][-1]["role"] == "assistant"
assert body["messages"][-1]["content"] == "hello world"
assert body["messages"][-1]["latencyMs"] == 42
@pytest.mark.asyncio
async def test_send_turn_end_emits_turn_end_event() -> None:
bus = MagicMock()
@@ -2494,6 +2600,71 @@ def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None:
assert body["messages"][0]["content"] == "hi"
def test_handle_file_preview_returns_workspace_file(tmp_path) -> None:
from urllib.parse import quote
from websockets.datastructures import Headers
from websockets.http11 import Request
workspace = tmp_path / "workspace"
source = workspace / "nanobot" / "agent" / "hook.py"
source.parent.mkdir(parents=True)
source.write_text("print('hello')\n", encoding="utf-8")
gateway = _basic_handler(MagicMock(), workspace_path=workspace)
gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
key = "websocket:file-preview"
enc = quote(key, safe="")
path = quote("nanobot/agent/hook.py:12", safe="")
req = Request(
f"/api/sessions/{enc}/file-preview?path={path}",
Headers([("Authorization", "Bearer tok")]),
)
resp = gateway.http._handle_file_preview(req, enc)
assert resp.status_code == 200
body = json.loads(resp.body.decode())
assert body["display_path"] == "nanobot/agent/hook.py"
assert body["language"] == "python"
assert body["content"].splitlines() == ["print('hello')"]
assert body["truncated"] is False
def test_file_preview_normalizes_windows_file_url() -> None:
from nanobot.webui.file_preview import _clean_preview_path
assert _clean_preview_path("file:///C:/Users/me/project/app.py") == (
"C:/Users/me/project/app.py"
)
assert _clean_preview_path("file:///tmp/project/app.py") == "/tmp/project/app.py"
def test_handle_file_preview_rejects_paths_outside_workspace(tmp_path) -> None:
from urllib.parse import quote
from websockets.datastructures import Headers
from websockets.http11 import Request
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "secret.py"
outside.write_text("secret = True\n", encoding="utf-8")
gateway = _basic_handler(MagicMock(), workspace_path=workspace)
gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
key = "websocket:file-preview"
enc = quote(key, safe="")
req = Request(
f"/api/sessions/{enc}/file-preview?path={quote(str(outside), safe='')}",
Headers([("Authorization", "Bearer tok")]),
)
resp = gateway.http._handle_file_preview(req, enc)
assert resp.status_code == 403
def test_handle_webui_thread_get_backfills_legacy_missing_user_rows(
tmp_path,
monkeypatch,
+160 -1
View File
@@ -12,6 +12,8 @@ import httpx
import pytest
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
from nanobot.session.manager import Session, SessionManager
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
@@ -24,10 +26,12 @@ def _make_handler(
*,
session_manager: SessionManager | None = None,
static_dist_path: Path | None = None,
workspace_path: Path | None = None,
runtime_model_name: Any | None = None,
cron_service: CronService | None = None,
) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
workspace = Path.cwd()
workspace = workspace_path or Path.cwd()
return build_gateway_services(
config=config,
bus=bus,
@@ -38,6 +42,7 @@ def _make_handler(
runtime_model_name=runtime_model_name,
runtime_surface="browser",
runtime_capabilities_overrides=None,
cron_service=cron_service,
)
@@ -46,8 +51,10 @@ def _ch(
*,
session_manager: SessionManager | None = None,
static_dist_path: Path | None = None,
workspace_path: Path | None = None,
port: int = _PORT,
runtime_model_name: Any | None = None,
cron_service: CronService | None = None,
**extra: Any,
) -> WebSocketChannel:
cfg: dict[str, Any] = {
@@ -63,7 +70,9 @@ def _ch(
cfg, bus,
session_manager=session_manager,
static_dist_path=static_dist_path,
workspace_path=workspace_path,
runtime_model_name=runtime_model_name,
cron_service=cron_service,
)
return WebSocketChannel(cfg, bus, gateway=gateway)
@@ -161,6 +170,156 @@ async def test_sessions_routes_require_bearer_token(
await server_task
@pytest.mark.asyncio
async def test_session_automations_route_filters_by_webui_session(
bus: MagicMock, tmp_path: Path
) -> None:
cron = CronService(tmp_path / "cron" / "jobs.json")
hourly = CronSchedule(kind="every", every_ms=3_600_000)
for name, message, to in (
("Morning check", "Check the project status", "abc"),
("Other session", "Do not show", "other"),
):
cron.add_job(
name=name,
schedule=hourly,
message=message,
channel="websocket",
to=to,
session_key=f"websocket:{to}",
)
cron.register_system_job(
CronJob(
id="heartbeat",
name="heartbeat",
schedule=CronSchedule(kind="every", every_ms=60_000),
payload=CronPayload(kind="system_event"),
)
)
channel = _ch(
bus,
session_manager=_seed_session(tmp_path, key="websocket:abc"),
cron_service=cron,
port=29914,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
deny = await _http_get(
"http://127.0.0.1:29914/api/sessions/websocket:abc/automations"
)
assert deny.status_code == 401
boot = await _http_get("http://127.0.0.1:29914/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
resp = await _http_get(
"http://127.0.0.1:29914/api/sessions/websocket%3Aabc/automations",
headers=auth,
)
assert resp.status_code == 200
body = resp.json()
assert [job["name"] for job in body["jobs"]] == ["Morning check"]
job = body["jobs"][0]
assert job["schedule"]["kind"] == "every"
assert job["schedule"]["every_ms"] == 3_600_000
assert job["payload"]["message"] == "Check the project status"
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_webui_skills_route_requires_token_and_hides_paths(
bus: MagicMock, tmp_path: Path
) -> None:
workspace_skill = tmp_path / "skills" / "workspace-skill"
workspace_skill.mkdir(parents=True)
(workspace_skill / "SKILL.md").write_text(
"---\nname: workspace-skill\ndescription: Workspace skill.\n---\n",
encoding="utf-8",
)
unavailable_skill = tmp_path / "skills" / "zz-unavailable-skill"
unavailable_skill.mkdir(parents=True)
(unavailable_skill / "SKILL.md").write_text(
"\n".join([
"---",
"name: zz-unavailable-skill",
"description: Missing CLI skill.",
"metadata:",
" nanobot:",
" requires:",
" bins:",
" - definitely-missing-nanobot-skill-cli",
" env:",
" - DEFINITELY_MISSING_NANOBOT_SKILL_ENV",
"---",
"Use the missing CLI and env var.",
]),
encoding="utf-8",
)
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
workspace_path=tmp_path,
port=29920,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
deny = await _http_get("http://127.0.0.1:29920/api/webui/skills")
assert deny.status_code == 401
deny_detail = await _http_get("http://127.0.0.1:29920/api/webui/skills/workspace-skill")
assert deny_detail.status_code == 401
boot = await _http_get("http://127.0.0.1:29920/webui/bootstrap")
token = boot.json()["token"]
resp = await _http_get(
"http://127.0.0.1:29920/api/webui/skills",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
body = resp.json()
names = [skill["name"] for skill in body["skills"]]
assert names[0] == "workspace-skill"
assert "cron" in names
assert all("path" not in skill for skill in body["skills"])
workspace = body["skills"][0]
assert workspace == {
"name": "workspace-skill",
"description": "Workspace skill.",
"source": "workspace",
"available": True,
"unavailable_reason": "",
}
unavailable = next(skill for skill in body["skills"] if skill["name"] == "zz-unavailable-skill")
assert unavailable["available"] is False
assert unavailable["unavailable_reason"] == (
"CLI: definitely-missing-nanobot-skill-cli, "
"ENV: DEFINITELY_MISSING_NANOBOT_SKILL_ENV"
)
detail = await _http_get(
"http://127.0.0.1:29920/api/webui/skills/zz-unavailable-skill",
headers={"Authorization": f"Bearer {token}"},
)
assert detail.status_code == 200
detail_body = detail.json()
assert "path" not in detail_body
assert detail_body["requirements"] == {
"bins": ["definitely-missing-nanobot-skill-cli"],
"env": ["DEFINITELY_MISSING_NANOBOT_SKILL_ENV"],
"missing_bins": ["definitely-missing-nanobot-skill-cli"],
"missing_env": ["DEFINITELY_MISSING_NANOBOT_SKILL_ENV"],
}
assert "Use the missing CLI and env var." in detail_body["raw_markdown"]
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_cli_apps_routes_require_token_and_return_payload(
bus: MagicMock,
+118 -4
View File
@@ -9,17 +9,37 @@ import pytest
from typer.testing import CliRunner
from nanobot.bus.events import OutboundMessage
from nanobot.cli.commands import app
from nanobot.providers.factory import make_provider
from nanobot.cli.commands import _proactive_delivery_metadata, app
from nanobot.config.schema import Config
from nanobot.cron.types import CronJob, CronPayload
from nanobot.providers.factory import ProviderSnapshot
from nanobot.providers.factory import ProviderSnapshot, make_provider
from nanobot.providers.openai_codex_provider import _strip_model_prefix
from nanobot.providers.registry import find_by_name
runner = CliRunner()
def test_proactive_websocket_delivery_gets_fresh_turn_id() -> None:
metadata = {
"webui": True,
"webui_turn_id": "turn-that-created-the-reminder",
"workspace_scope": {"mode": "default"},
}
out = _proactive_delivery_metadata(
"websocket",
metadata,
turn_seed="cron:drink-water",
source_label="drink water",
)
assert out["webui"] is True
assert out["workspace_scope"] == {"mode": "default"}
assert out["webui_turn_id"].startswith("cron:drink-water:")
assert out["webui_turn_id"] != metadata["webui_turn_id"]
assert out["_webui_message_source"] == {"kind": "cron", "label": "drink water"}
def _fake_provider():
"""Return a minimal fake provider that satisfies AgentLoop.__init__."""
p = MagicMock()
@@ -542,8 +562,8 @@ def test_openai_compat_provider_passes_model_through():
def test_make_provider_uses_github_copilot_backend():
from nanobot.providers.factory import make_provider
from nanobot.config.schema import Config
from nanobot.providers.factory import make_provider
config = Config.model_validate(
{
@@ -1317,6 +1337,41 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
}
]
bus.publish_outbound.reset_mock()
old_turn_id = "turn-that-created-the-reminder"
websocket_job = CronJob(
id="drink-water",
name="drink water",
payload=CronPayload(
message="Remind me to drink water.",
deliver=True,
channel="websocket",
to="chat-1",
channel_meta={
"webui": True,
"webui_turn_id": old_turn_id,
"workspace_scope": {"mode": "default"},
},
session_key="websocket:chat-1",
),
)
response = asyncio.run(cron.on_job(websocket_job))
assert response == "Time to stretch."
bus.publish_outbound.assert_awaited_once()
delivered = bus.publish_outbound.await_args.args[0]
assert delivered.channel == "websocket"
assert delivered.chat_id == "chat-1"
assert delivered.metadata["webui"] is True
assert delivered.metadata["workspace_scope"] == {"mode": "default"}
assert delivered.metadata["webui_turn_id"].startswith("cron:drink-water:")
assert delivered.metadata["webui_turn_id"] != old_turn_id
assert delivered.metadata["_webui_message_source"] == {
"kind": "cron",
"label": "drink water",
}
def test_gateway_cron_job_suppresses_intermediate_progress(
monkeypatch, tmp_path: Path
@@ -1599,6 +1654,65 @@ def test_configure_desktop_gateway_forces_local_websocket_only() -> None:
assert extras["websocket"]["websocket_requires_token"] is True
def test_load_or_create_desktop_config_bootstraps_without_api_key(tmp_path: Path) -> None:
from nanobot.cli.commands import _load_or_create_desktop_config
config_path = tmp_path / "config.json"
loaded = _load_or_create_desktop_config(
str(config_path),
str(tmp_path / "workspace"),
)
assert loaded.agents.defaults.provider == "openai_codex"
assert loaded.agents.defaults.model == "openai-codex/gpt-5.1-codex"
assert loaded.agents.defaults.model_preset is None
assert config_path.exists()
saved = json.loads(config_path.read_text(encoding="utf-8"))
assert saved["agents"]["defaults"]["provider"] == ""
assert saved["agents"]["defaults"]["model"] == ""
assert make_provider(loaded).get_default_model() == "openai-codex/gpt-5.1-codex"
def test_load_or_create_desktop_config_repairs_existing_unconfigured_default(
tmp_path: Path,
) -> None:
from nanobot.cli.commands import _load_or_create_desktop_config
from nanobot.config.loader import save_config
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
loaded = _load_or_create_desktop_config(str(config_path), None)
saved = json.loads(config_path.read_text(encoding="utf-8"))
assert loaded.agents.defaults.provider == "openai_codex"
assert loaded.agents.defaults.model == "openai-codex/gpt-5.1-codex"
assert saved["agents"]["defaults"]["provider"] == ""
assert saved["agents"]["defaults"]["model"] == ""
assert make_provider(loaded).get_default_model() == "openai-codex/gpt-5.1-codex"
def test_load_or_create_desktop_config_unwinds_persisted_bootstrap(
tmp_path: Path,
) -> None:
from nanobot.cli.commands import _load_or_create_desktop_config
from nanobot.config.loader import save_config
config_path = tmp_path / "config.json"
config = Config()
config.agents.defaults.provider = "openai_codex"
config.agents.defaults.model = "openai-codex/gpt-5.1-codex"
save_config(config, config_path)
loaded = _load_or_create_desktop_config(str(config_path), None)
saved = json.loads(config_path.read_text(encoding="utf-8"))
assert loaded.agents.defaults.provider == "openai_codex"
assert loaded.agents.defaults.model == "openai-codex/gpt-5.1-codex"
assert saved["agents"]["defaults"]["provider"] == ""
assert saved["agents"]["defaults"]["model"] == ""
def test_gateway_health_endpoint_binds_and_serves_expected_responses(
monkeypatch, tmp_path: Path
) -> None:
+12 -3
View File
@@ -214,8 +214,16 @@ class TestRestartCommand:
assert "Tasks: 3 active" in response.content
@pytest.mark.asyncio
async def test_run_agent_loop_resets_usage_when_provider_omits_it(self):
async def test_run_agent_loop_estimates_usage_when_provider_omits_it(self, monkeypatch):
loop, _bus = _make_loop()
monkeypatch.setattr(
"nanobot.agent.runner.estimate_prompt_tokens_chain",
lambda *_args, **_kwargs: (123, "test"),
)
monkeypatch.setattr(
"nanobot.agent.runner.estimate_message_tokens",
lambda _message: 7,
)
loop.provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse(content="first", usage={"prompt_tokens": 9, "completion_tokens": 4}),
LLMResponse(content="second", usage={}),
@@ -226,8 +234,9 @@ class TestRestartCommand:
assert loop._last_usage["completion_tokens"] == 4
await loop._run_agent_loop([])
assert loop._last_usage["prompt_tokens"] == 0
assert loop._last_usage["completion_tokens"] == 0
assert loop._last_usage["prompt_tokens"] == 123
assert loop._last_usage["completion_tokens"] == 7
assert loop._last_usage["estimated_tokens"] == 130
@pytest.mark.asyncio
async def test_status_falls_back_to_last_usage_when_context_estimate_missing(self):
@@ -11,8 +11,8 @@ from loguru import logger
import nanobot.providers.base as provider_base
from nanobot.providers.openai_codex_provider import (
OpenAICodexProvider,
_codex_error_response,
_build_reasoning_options,
_codex_error_response,
_CodexHTTPError,
_friendly_error,
_request_codex,
@@ -134,7 +134,7 @@ async def test_codex_prompt_cache_key_uses_stable_conversation_prefix(monkeypatc
):
_ = on_thinking_delta, on_tool_call_delta
bodies.append(body)
return "ok", [], "stop", None
return "ok", [], "stop", {}, None
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
@@ -259,7 +259,7 @@ async def test_codex_retry_uses_structured_timeout_metadata(monkeypatch) -> None
calls += 1
if calls == 1:
raise httpx.ReadTimeout("")
return "ok", [], "stop", None
return "ok", [], "stop", {}, None
async def fake_sleep(delay: float) -> None:
delays.append(delay)
@@ -429,7 +429,7 @@ async def test_codex_stream_surfaces_reasoning_summary(monkeypatch) -> None:
await on_content_delta("answer")
if on_thinking_delta:
await on_thinking_delta("summary")
return "answer", [], "stop", "summary"
return "answer", [], "stop", {"prompt_tokens": 10, "completion_tokens": 5}, "summary"
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
@@ -447,6 +447,7 @@ async def test_codex_stream_surfaces_reasoning_summary(monkeypatch) -> None:
assert content_deltas == ["answer"]
assert thinking_deltas == ["summary"]
assert response.content == "answer"
assert response.usage == {"prompt_tokens": 10, "completion_tokens": 5}
assert response.reasoning_content == "summary"
+21 -5
View File
@@ -19,7 +19,6 @@ from nanobot.providers.openai_responses.parsing import (
parse_response_output,
)
# ======================================================================
# converters - split_tool_call_id
# ======================================================================
@@ -478,7 +477,7 @@ class TestConsumeSse:
async def on_reasoning(delta: str) -> None:
deltas.append(delta)
content, tool_calls, finish_reason, reasoning = await consume_sse_with_reasoning(
content, tool_calls, finish_reason, usage, reasoning = await consume_sse_with_reasoning(
response,
on_reasoning_delta=on_reasoning,
)
@@ -486,6 +485,7 @@ class TestConsumeSse:
assert content == "answer"
assert tool_calls == []
assert finish_reason == "stop"
assert usage == {}
assert reasoning == "thinking briefly"
assert deltas == ["thinking ", "briefly"]
@@ -506,7 +506,7 @@ class TestConsumeSse:
},
])
_, _, _, reasoning = await consume_sse_with_reasoning(response)
_, _, _, _, reasoning = await consume_sse_with_reasoning(response)
assert reasoning == "cached summary"
@@ -527,7 +527,7 @@ class TestConsumeSse:
async def on_reasoning(delta: str) -> None:
deltas.append(delta)
_, _, _, reasoning = await consume_sse_with_reasoning(
_, _, _, _, reasoning = await consume_sse_with_reasoning(
response,
on_reasoning_delta=on_reasoning,
)
@@ -545,10 +545,26 @@ class TestConsumeSse:
{"type": "response.completed", "response": {"status": "completed"}},
])
_, _, _, reasoning = await consume_sse_with_reasoning(response)
_, _, _, _, reasoning = await consume_sse_with_reasoning(response)
assert reasoning == "part summary"
@pytest.mark.asyncio
async def test_raw_sse_usage_extracted(self):
response = _SseResponse([
{
"type": "response.completed",
"response": {
"status": "completed",
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
},
},
])
_, _, _, usage, _ = await consume_sse_with_reasoning(response)
assert usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
@pytest.mark.asyncio
async def test_tool_call_done_arguments_callback(self):
response = _SseResponse([
+47 -1
View File
@@ -12,7 +12,11 @@ import pytest
from nanobot.agent.tools import web as web_module
from nanobot.agent.tools.web import WebFetchTool
from nanobot.config.schema import WebFetchConfig
from nanobot.security.workspace_access import bind_workspace_scope, build_workspace_scope, reset_workspace_scope
from nanobot.security.workspace_access import (
bind_workspace_scope,
build_workspace_scope,
reset_workspace_scope,
)
_REAL_GETADDRINFO = socket.getaddrinfo
@@ -147,6 +151,7 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
return FakeResponse()
monkeypatch.setattr(tool, "_fetch_jina", _fail_jina)
monkeypatch.setattr(tool, "_extract_readable_html", lambda html, mode: "Hello world")
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
@@ -160,6 +165,47 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
]
@pytest.mark.asyncio
async def test_web_fetch_falls_back_when_readability_dependency_is_missing(monkeypatch):
tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))
class FakeResponse:
status_code = 200
url = "https://example.com/page"
text = "<html><head><title>Test</title></head><body><p>Hello world</p></body></html>"
headers = {"content-type": "text/html"}
def raise_for_status(self):
return None
class FakeClient:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, url, headers=None, follow_redirects=False, **kwargs):
return FakeResponse()
def _missing_readability(*args, **kwargs):
raise ModuleNotFoundError("No module named 'lxml_html_clean'")
monkeypatch.setattr(tool, "_extract_readable_html", _missing_readability)
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
result = await tool._fetch_readability("https://example.com/page", "markdown", 5000)
data = json.loads(result)
assert data["extractor"] == "html"
assert data["untrusted"] is True
assert "Hello world" in data["text"]
@pytest.mark.asyncio
async def test_web_fetch_blocks_private_redirect_before_readability_request(monkeypatch):
tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))
+171 -2
View File
@@ -43,6 +43,177 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
assert msgs[1]["latencyMs"] == 42
def test_replay_preserves_turn_metadata(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-turn"
for ev in (
{
"event": "user",
"chat_id": "t-turn",
"text": "q",
"turn_id": "turn-1",
"turn_phase": "user",
"turn_seq": 1,
},
{
"event": "reasoning_delta",
"chat_id": "t-turn",
"text": "think",
"turn_id": "turn-1",
"turn_phase": "reasoning",
"turn_seq": 2,
},
{
"event": "delta",
"chat_id": "t-turn",
"text": "a",
"turn_id": "turn-1",
"turn_phase": "answer",
"turn_seq": 3,
},
{
"event": "turn_end",
"chat_id": "t-turn",
"latency_ms": 12,
"turn_id": "turn-1",
"turn_phase": "complete",
"turn_seq": 4,
},
):
append_transcript_object(key, ev)
msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
assert msgs[0]["turnId"] == "turn-1"
assert msgs[0]["turnPhase"] == "user"
assert msgs[0]["turnSeq"] == 1
assert msgs[1]["turnId"] == "turn-1"
assert msgs[1]["turnPhase"] == "answer"
assert msgs[1]["turnSeq"] == 3
def test_replay_reused_turn_id_after_turn_end_starts_new_turn(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-reused-turn"
def event(
event: str,
phase: str,
seq: int,
text: str | None = None,
source: dict[str, str] | None = None,
) -> dict[str, object]:
out = {
"event": event,
"chat_id": "t-reused-turn",
"turn_id": "turn-1",
"turn_phase": phase,
"turn_seq": seq,
}
if text is not None:
out["text"] = text
if source is not None:
out["source"] = source
return out
for record in (
event("user", "user", 1, "remind me later"),
event("message", "answer", 2, "Reminder set."),
event("turn_end", "complete", 3),
event(
"message", "answer", 1, "Time to drink water.",
{"kind": "cron", "label": "drink water"},
),
event("turn_end", "complete", 2),
):
append_transcript_object(key, record)
msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
assert [m["content"] for m in msgs] == [
"remind me later",
"Reminder set.",
"Time to drink water.",
]
assert msgs[1]["turnId"] == "turn-1"
assert msgs[2]["turnId"].startswith("turn-1:replay:")
assert msgs[2]["turnId"] != msgs[1]["turnId"]
assert msgs[2]["source"] == {"kind": "cron", "label": "drink water"}
def test_build_response_restores_session_users_for_legacy_transcript(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:legacy-users"
append_transcript_object(
key,
{"event": "message", "chat_id": "legacy-users", "text": "assistant one"},
)
append_transcript_object(key, {"event": "turn_end", "chat_id": "legacy-users"})
append_transcript_object(
key,
{"event": "message", "chat_id": "legacy-users", "text": "assistant two"},
)
append_transcript_object(key, {"event": "turn_end", "chat_id": "legacy-users"})
out = build_webui_thread_response(
key,
session_messages=[
{"role": "user", "content": "prompt one", "timestamp": "2026-06-02T10:00:00"},
{"role": "assistant", "content": "assistant one"},
{"role": "user", "content": "prompt two", "timestamp": "2026-06-02T10:01:00"},
{"role": "assistant", "content": "assistant two"},
],
)
assert out is not None
assert [(m["role"], m["content"]) for m in out["messages"]] == [
("user", "prompt one"),
("assistant", "assistant one"),
("user", "prompt two"),
("assistant", "assistant two"),
]
def test_build_response_restores_session_users_without_duplicating_new_transcript_users(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:mixed-users"
append_transcript_object(
key,
{"event": "message", "chat_id": "mixed-users", "text": "old assistant"},
)
append_transcript_object(key, {"event": "turn_end", "chat_id": "mixed-users"})
append_transcript_object(key, {"event": "user", "chat_id": "mixed-users", "text": "new prompt"})
append_transcript_object(
key,
{"event": "message", "chat_id": "mixed-users", "text": "new assistant"},
)
append_transcript_object(key, {"event": "turn_end", "chat_id": "mixed-users"})
out = build_webui_thread_response(
key,
session_messages=[
{"role": "user", "content": "old prompt"},
{"role": "assistant", "content": "old assistant"},
{"role": "user", "content": "new prompt"},
{"role": "assistant", "content": "new assistant"},
],
)
assert out is not None
assert [(m["role"], m["content"]) for m in out["messages"]] == [
("user", "old prompt"),
("assistant", "old assistant"),
("user", "new prompt"),
("assistant", "new assistant"),
]
def test_replay_augments_assistant_text() -> None:
msgs = replay_transcript_to_ui_messages(
[
@@ -675,8 +846,6 @@ def test_replay_keeps_new_file_edit_after_reasoning_in_order(tmp_path, monkeypat
def test_build_response_schema(monkeypatch, tmp_path) -> None:
from nanobot.webui.transcript import build_webui_thread_response
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t3"
append_transcript_object(key, {"event": "user", "chat_id": "t3", "text": "x"})
+47
View File
@@ -14,6 +14,7 @@ from nanobot.webui.settings_api import (
create_model_configuration,
provider_models_payload,
settings_payload,
settings_usage_payload,
update_agent_settings,
update_model_configuration,
update_network_safety_settings,
@@ -242,6 +243,52 @@ def test_settings_payload_includes_network_safety_fields(
assert payload["advanced"]["ssrf_whitelist_count"] == 1
def test_settings_payload_includes_token_usage_summary(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
from nanobot.webui.token_usage import record_token_usage
record_token_usage({"prompt_tokens": 10, "completion_tokens": 5})
payload = settings_payload()
assert payload["usage"]["total_tokens_30d"] == 15
assert payload["usage"]["total_tokens"] == 15
assert payload["usage"]["peak_day_tokens"] == 15
assert payload["usage"]["current_streak_days"] == 1
assert payload["usage"]["longest_streak_days"] == 1
assert payload["usage"]["active_days_30d"] == 1
assert payload["usage"]["requests_30d"] == 1
def test_settings_usage_payload_returns_lightweight_token_usage(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
from nanobot.webui.token_usage import record_token_usage
record_token_usage({"prompt_tokens": 20, "completion_tokens": 2})
payload = settings_usage_payload()
assert payload["total_tokens"] == 22
assert payload["requests_30d"] == 1
assert "agent" not in payload
def test_update_network_safety_settings_writes_local_service_flag(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
from nanobot.agent.hook import AgentHookContext
from nanobot.webui.token_usage import (
TokenUsageHook,
record_response_token_usage,
record_token_usage,
token_usage_payload,
)
def test_record_token_usage_aggregates_by_local_day(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
record_token_usage(
{"prompt_tokens": 100, "completion_tokens": 40, "cached_tokens": 20},
timezone_name="Asia/Shanghai",
now=datetime(2026, 6, 2, 18, 0, tzinfo=timezone.utc),
)
record_token_usage(
{"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
timezone_name="Asia/Shanghai",
now=datetime(2026, 6, 2, 19, 0, tzinfo=timezone.utc),
)
payload = token_usage_payload(
timezone_name="Asia/Shanghai",
now=datetime(2026, 6, 3, 12, 0, tzinfo=timezone.utc),
)
assert payload["total_tokens_30d"] == 155
assert payload["active_days_30d"] == 1
assert payload["requests_30d"] == 2
assert payload["days"] == [
{
"date": "2026-06-03",
"prompt_tokens": 110,
"completion_tokens": 45,
"cached_tokens": 20,
"total_tokens": 155,
"provider_tokens": 155,
"estimated_tokens": 0,
"requests": 2,
"provider_requests": 2,
"estimated_requests": 0,
"sources": {
"user": {
"prompt_tokens": 110,
"completion_tokens": 45,
"cached_tokens": 20,
"total_tokens": 155,
"provider_tokens": 155,
"estimated_tokens": 0,
"requests": 2,
"provider_requests": 2,
"estimated_requests": 0,
}
},
}
]
def test_record_token_usage_skips_empty_usage(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
record_token_usage({"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
payload = token_usage_payload(now=datetime(2026, 6, 3, tzinfo=timezone.utc))
assert payload["days"] == []
assert payload["total_tokens_30d"] == 0
def test_record_token_usage_keeps_estimated_split(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
record_token_usage(
{"prompt_tokens": 100, "completion_tokens": 25, "estimated_tokens": 125},
now=datetime(2026, 6, 3, tzinfo=timezone.utc),
)
payload = token_usage_payload(now=datetime(2026, 6, 3, tzinfo=timezone.utc))
assert payload["days"][0]["total_tokens"] == 125
assert payload["days"][0]["provider_tokens"] == 0
assert payload["days"][0]["estimated_tokens"] == 125
assert payload["days"][0]["estimated_requests"] == 1
def test_record_token_usage_keeps_source_breakdown(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
record_token_usage(
{"prompt_tokens": 100, "completion_tokens": 25},
source="user",
now=datetime(2026, 6, 3, tzinfo=timezone.utc),
)
record_token_usage(
{"prompt_tokens": 20, "completion_tokens": 5},
source="dream",
now=datetime(2026, 6, 3, tzinfo=timezone.utc),
)
payload = token_usage_payload(now=datetime(2026, 6, 3, tzinfo=timezone.utc))
row = payload["days"][0]
assert row["total_tokens"] == 150
assert row["sources"]["user"]["total_tokens"] == 125
assert row["sources"]["user"]["requests"] == 1
assert row["sources"]["dream"]["total_tokens"] == 25
assert row["sources"]["dream"]["requests"] == 1
def test_record_response_token_usage_uses_response_usage(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
monkeypatch.setattr("nanobot.webui.token_usage._local_day", lambda *_, **__: "2026-06-03")
record_response_token_usage(
SimpleNamespace(usage={"prompt_tokens": 20, "completion_tokens": 5}),
source="dream",
)
payload = token_usage_payload(now=datetime(2026, 6, 3, tzinfo=timezone.utc))
assert payload["days"][0]["sources"]["dream"]["total_tokens"] == 25
@pytest.mark.asyncio
async def test_token_usage_hook_classifies_source_from_session_key(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
monkeypatch.setattr("nanobot.webui.token_usage._local_day", lambda *_, **__: "2026-06-03")
hook = TokenUsageHook()
await hook.after_iteration(
AgentHookContext(
iteration=0,
messages=[],
session_key="cron:drink-water",
usage={"prompt_tokens": 10, "completion_tokens": 5},
)
)
payload = token_usage_payload(now=datetime(2026, 6, 3, tzinfo=timezone.utc))
assert payload["days"][0]["sources"]["cron"]["total_tokens"] == 15