feat: unify card runtime and event-driven web ui
Some checks failed
CI / Backend Checks (push) Failing after 36s
CI / Frontend Checks (push) Failing after 40s

This commit is contained in:
kacper 2026-04-06 15:42:53 -04:00
parent 0edf8c3fef
commit 4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions

View file

@ -0,0 +1,44 @@
from __future__ import annotations
import json
import unittest
from types import SimpleNamespace
from routes.sessions import stream_ui_events
from ui_event_bus import UiEventBus
class DummyRequest:
def __init__(self, chat_id: str) -> None:
self.query_params = {"chat_id": chat_id}
async def is_disconnected(self) -> bool:
return False
class EventsRouteTests(unittest.IsolatedAsyncioTestCase):
async def test_events_stream_emits_ready_and_published_payload(self) -> None:
runtime = SimpleNamespace(event_bus=UiEventBus())
response = await stream_ui_events(DummyRequest("test-chat"), runtime=runtime)
iterator = response.body_iterator
first_chunk = await anext(iterator)
self.assertEqual(first_chunk, ": stream-open\n\n")
second_chunk = await anext(iterator)
ready_payload = json.loads(second_chunk.removeprefix("data: ").strip())
self.assertEqual(ready_payload["type"], "events.ready")
self.assertEqual(ready_payload["chat_id"], "test-chat")
await runtime.event_bus.publish(
{"type": "cards.changed", "chat_id": "test-chat"},
chat_id="test-chat",
)
third_chunk = await anext(iterator)
event_payload = json.loads(third_chunk.removeprefix("data: ").strip())
self.assertEqual(event_payload["type"], "cards.changed")
self.assertEqual(event_payload["chat_id"], "test-chat")
await iterator.aclose()

118
tests/test_inbox_service.py Normal file
View file

@ -0,0 +1,118 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from typing import Any
from inbox_service import InboxService
NANOBOT_REPO_DIR = Path("/home/kacper/nanobot")
class InboxServiceTests(unittest.TestCase):
def setUp(self) -> None:
self._tmpdir = tempfile.TemporaryDirectory()
self.inbox_dir = Path(self._tmpdir.name) / "inbox"
self.service = InboxService(repo_dir=NANOBOT_REPO_DIR, inbox_dir=self.inbox_dir)
def tearDown(self) -> None:
self._tmpdir.cleanup()
def test_capture_list_and_dismiss_item(self) -> None:
captured = self.service.capture_item(
title="",
text="Pack chargers for Japan",
kind="task",
source="web-ui",
due="2026-04-10",
body="Remember the USB-C one too.",
session_id="web-123",
tags=["travel", "#Japan"],
confidence=0.75,
)
self.assertEqual(captured["title"], "Pack chargers for Japan")
self.assertEqual(captured["kind"], "task")
self.assertEqual(captured["source"], "web-ui")
self.assertEqual(captured["suggested_due"], "2026-04-10")
self.assertEqual(captured["metadata"]["source_session"], "web-123")
self.assertIn("## Raw Capture", captured["body"])
self.assertIn("#travel", captured["tags"])
self.assertIn("#japan", captured["tags"])
listed = self.service.list_items(
status_filter=None,
kind_filter=None,
include_closed=False,
limit=None,
tags=[],
)
self.assertEqual(len(listed), 1)
self.assertEqual(listed[0]["path"], captured["path"])
dismissed = self.service.dismiss_item(captured["path"])
self.assertEqual(dismissed["status"], "dismissed")
self.assertEqual(self.service.open_items(), [])
class InboxServiceAcceptTaskTests(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self._tmpdir = tempfile.TemporaryDirectory()
self.inbox_dir = Path(self._tmpdir.name) / "inbox"
self.service = InboxService(repo_dir=NANOBOT_REPO_DIR, inbox_dir=self.inbox_dir)
self.item = self.service.capture_item(
title="Find a good PWA install guide",
text="Find a good PWA install guide",
kind="task",
source="web-ui",
due="",
body="Look for something practical, not generic.",
session_id="web-456",
tags=["research"],
confidence=0.9,
)
def tearDown(self) -> None:
self._tmpdir.cleanup()
async def test_accept_item_builds_grooming_prompt_with_hints(self) -> None:
captured: dict[str, Any] = {}
async def fake_run_nanobot_turn(message: str, **kwargs: Any) -> dict[str, Any]:
captured["message"] = message
captured["kwargs"] = kwargs
return {"status": "ok", "reply": "accepted"}
def fake_notification_to_event(payload: dict[str, Any]) -> dict[str, Any] | None:
return payload
result = await self.service.accept_item_as_task(
item=self.item["path"],
lane="committed",
title="Install the PWA correctly",
due="2026-04-12",
body_text="Prefer something with screenshots.",
tags=["#pwa", "#research"],
run_nanobot_turn=fake_run_nanobot_turn,
notification_to_event=fake_notification_to_event,
)
self.assertEqual(result["status"], "ok")
message = captured["message"]
self.assertIn("Use the inbox_board tool and the task_helper_card tool.", message)
self.assertIn(f"Inbox item path: {self.item['path']}", message)
self.assertIn("Title hint from UI: Install the PWA correctly", message)
self.assertIn("Description hint from UI: Prefer something with screenshots.", message)
self.assertIn("Due hint from UI: 2026-04-12", message)
self.assertIn("Tag hint from UI: #pwa, #research", message)
self.assertIn("Preferred destination lane: committed", message)
kwargs = captured["kwargs"]
self.assertEqual(kwargs["chat_id"], "inbox-groomer")
self.assertEqual(kwargs["timeout_seconds"], 90.0)
self.assertEqual(kwargs["metadata"]["source"], "web-inbox-card")
self.assertEqual(kwargs["metadata"]["ui_action"], "accept_task")
self.assertEqual(kwargs["metadata"]["inbox_item"], self.item["path"])
self.assertIs(kwargs["notification_to_event"], fake_notification_to_event)

View file

@ -0,0 +1,131 @@
from __future__ import annotations
import asyncio
import unittest
from typing import Any
from tool_job_service import ToolJobService
async def wait_for_job_status(
service: ToolJobService,
job_id: str,
*,
status: str,
attempts: int = 50,
) -> dict[str, Any]:
for _ in range(attempts):
payload = await service.get_job(job_id)
if payload is not None and payload["status"] == status:
return payload
await asyncio.sleep(0.01)
raise AssertionError(f"job {job_id} never reached status {status}")
class ToolJobServiceTests(unittest.IsolatedAsyncioTestCase):
async def test_start_job_completes_and_returns_unwrapped_result(self) -> None:
async def send_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
self.assertEqual(method, "tool.call")
self.assertEqual(params["name"], "demo.tool")
return {
"tool_name": "demo.tool",
"content": '{"ok": true}',
"parsed": {"ok": True, "arguments": params["arguments"]},
"is_json": True,
}
service = ToolJobService(
send_request=send_request,
timeout_seconds=30.0,
retention_seconds=60.0,
)
job = await service.start_job("demo.tool", {"count": 3})
self.assertEqual(job["status"], "queued")
self.assertEqual(job["tool_name"], "demo.tool")
completed = await wait_for_job_status(service, job["job_id"], status="completed")
self.assertEqual(
completed["result"],
{
"tool_name": "demo.tool",
"content": '{"ok": true}',
"parsed": {"ok": True, "arguments": {"count": 3}},
"is_json": True,
},
)
self.assertIsNone(completed["error"])
async def test_shutdown_cancels_running_jobs(self) -> None:
gate = asyncio.Event()
async def send_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
await gate.wait()
return {"parsed": {"done": True}}
service = ToolJobService(
send_request=send_request,
timeout_seconds=30.0,
retention_seconds=60.0,
)
job = await service.start_job("slow.tool", {})
await asyncio.sleep(0.01)
await service.shutdown()
failed = await wait_for_job_status(service, job["job_id"], status="failed")
self.assertEqual(failed["error"], "tool job cancelled")
self.assertIsNone(failed["result"])
async def test_failed_job_exposes_error_code_when_present(self) -> None:
class ToolFailure(RuntimeError):
def __init__(self, message: str, code: int) -> None:
super().__init__(message)
self.code = code
async def send_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
raise ToolFailure("boom", 418)
service = ToolJobService(
send_request=send_request,
timeout_seconds=30.0,
retention_seconds=60.0,
)
job = await service.start_job("broken.tool", {})
failed = await wait_for_job_status(service, job["job_id"], status="failed")
self.assertEqual(failed["error"], "boom")
self.assertEqual(failed["error_code"], 418)
async def test_subscribers_receive_running_and_completed_updates(self) -> None:
gate = asyncio.Event()
async def send_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
gate.set()
return {
"tool_name": "demo.tool",
"content": '{"ok": true}',
"parsed": {"ok": True},
"is_json": True,
}
service = ToolJobService(
send_request=send_request,
timeout_seconds=30.0,
retention_seconds=60.0,
)
job = await service.start_job("demo.tool", {})
subscription = await service.subscribe_job(job["job_id"])
self.assertIsNotNone(subscription)
subscription_id, queue = subscription or ("", asyncio.Queue())
await gate.wait()
first_update = await asyncio.wait_for(queue.get(), timeout=0.2)
self.assertEqual(first_update["status"], "running")
second_update = await asyncio.wait_for(queue.get(), timeout=0.2)
self.assertEqual(second_update["status"], "completed")
self.assertEqual(second_update["result"]["tool_name"], "demo.tool")
await service.unsubscribe_job(job["job_id"], subscription_id)

View file

@ -0,0 +1,61 @@
from __future__ import annotations
import asyncio
import json
import unittest
from types import SimpleNamespace
from routes.tools import stream_tool_job
from tool_job_service import ToolJobService
class DummyRequest:
async def is_disconnected(self) -> bool:
return False
class ToolStreamRouteTests(unittest.IsolatedAsyncioTestCase):
async def test_stream_tool_job_emits_current_and_terminal_updates(self) -> None:
gate = asyncio.Event()
async def send_request(method: str, params: dict[str, object]) -> dict[str, object]:
gate.set()
return {
"tool_name": "demo.tool",
"content": '{"ok": true}',
"parsed": {"ok": True},
"is_json": True,
}
service = ToolJobService(
send_request=send_request,
timeout_seconds=30.0,
retention_seconds=60.0,
)
runtime = SimpleNamespace(tool_job_service=service)
job = await service.start_job("demo.tool", {})
await gate.wait()
response = await stream_tool_job(job["job_id"], DummyRequest(), runtime=runtime)
iterator = response.body_iterator
first_chunk = await anext(iterator)
self.assertEqual(first_chunk, ": stream-open\n\n")
second_chunk = await anext(iterator)
first_payload = json.loads(second_chunk.removeprefix("data: ").strip())
self.assertEqual(first_payload["type"], "tool.job")
self.assertEqual(first_payload["job"]["job_id"], job["job_id"])
if first_payload["job"]["status"] == "completed":
terminal_payload = first_payload
else:
third_chunk = await anext(iterator)
terminal_payload = json.loads(third_chunk.removeprefix("data: ").strip())
self.assertEqual(terminal_payload["job"]["status"], "completed")
self.assertEqual(terminal_payload["job"]["result"]["tool_name"], "demo.tool")
await iterator.aclose()
await service.shutdown()

View file

@ -0,0 +1,42 @@
from __future__ import annotations
import asyncio
import unittest
from ui_event_bus import UiEventBus
class UiEventBusTests(unittest.IsolatedAsyncioTestCase):
async def test_publish_targets_chat_or_broadcasts(self) -> None:
bus = UiEventBus()
first_subscription, first_queue = await bus.subscribe("chat-a")
second_subscription, second_queue = await bus.subscribe("chat-b")
self.addAsyncCleanup(bus.unsubscribe, first_subscription)
self.addAsyncCleanup(bus.unsubscribe, second_subscription)
await bus.publish({"type": "cards.changed", "chat_id": "chat-a"}, chat_id="chat-a")
self.assertEqual((await asyncio.wait_for(first_queue.get(), timeout=0.1))["type"], "cards.changed")
self.assertTrue(second_queue.empty())
await bus.publish({"type": "sessions.changed"}, broadcast=True)
self.assertEqual(
(await asyncio.wait_for(first_queue.get(), timeout=0.1))["type"],
"sessions.changed",
)
self.assertEqual(
(await asyncio.wait_for(second_queue.get(), timeout=0.1))["type"],
"sessions.changed",
)
async def test_publish_drops_oldest_entries_when_queue_is_full(self) -> None:
bus = UiEventBus()
subscription_id, queue = await bus.subscribe("chat-a")
self.addAsyncCleanup(bus.unsubscribe, subscription_id)
for seq in range(70):
await bus.publish({"seq": seq}, chat_id="chat-a")
self.assertEqual(queue.qsize(), 64)
self.assertEqual((await queue.get())["seq"], 6)