feat: unify card runtime and event-driven web ui
This commit is contained in:
parent
0edf8c3fef
commit
4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions
44
tests/test_events_route.py
Normal file
44
tests/test_events_route.py
Normal 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
118
tests/test_inbox_service.py
Normal 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)
|
||||
131
tests/test_tool_job_service.py
Normal file
131
tests/test_tool_job_service.py
Normal 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)
|
||||
61
tests/test_tool_stream_route.py
Normal file
61
tests/test_tool_stream_route.py
Normal 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()
|
||||
42
tests/test_ui_event_bus.py
Normal file
42
tests/test_ui_event_bus.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue