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:
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
[],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user