Spaces:
Sleeping
Sleeping
| """Integration tests for RealtimeProgressFeed. | |
| Spec: `specs/realtime-progress-via-gateway-ws.md` §8.4. | |
| Wires a real GatewayWsClient against the FakeGateway (reused from | |
| test_gateway_ws_client.py), a real translator, and a real ProgressTracker. | |
| Asserts the resulting ChatKit event stream matches the expected workflow | |
| timeline for a multi-tool Hub→subagent run. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| from collections.abc import AsyncIterator | |
| from contextlib import asynccontextmanager | |
| from typing import Any | |
| import pytest | |
| from chatkit.types import ( | |
| CustomTask, | |
| ThreadItemAddedEvent, | |
| ThreadItemUpdatedEvent, | |
| WorkflowItem, | |
| WorkflowTaskAdded, | |
| WorkflowTaskUpdated, | |
| ) | |
| from app.gateway_ws_client import GatewayWsClient | |
| from app.progress_workflow import ProgressTracker | |
| from app.realtime_progress_feed import RealtimeProgressFeed | |
| from tests.test_gateway_ws_client import FakeGateway | |
| pytestmark = pytest.mark.asyncio | |
| async def _feed( | |
| gw: FakeGateway, parent_key: str = "agent:main:abc" | |
| ) -> AsyncIterator[tuple[GatewayWsClient, ProgressTracker, RealtimeProgressFeed]]: | |
| # These tests exercise the WS path explicitly — the fake gateway is a | |
| # WebSocket server. Pass mode="ws" so the feed uses the supplied | |
| # ws_client instead of the new default `poll` (HTTP sessions_history). | |
| GatewayWsClient.reset_process_instance() | |
| c = GatewayWsClient(ws_url=gw.ws_url, token="test-token") | |
| tracker = ProgressTracker(thread_id="thread-1", enabled=True) | |
| feed = RealtimeProgressFeed( | |
| parent_session_key=parent_key, | |
| tracker=tracker, | |
| mode="ws", | |
| ws_client=c, | |
| ) | |
| try: | |
| yield c, tracker, feed | |
| finally: | |
| await feed.stop() | |
| await c.aclose() | |
| async def _drain(feed: RealtimeProgressFeed) -> list[Any]: | |
| out = [] | |
| async for ev in feed.drain(): | |
| out.append(ev) | |
| return out | |
| async def _wait_for_queue( | |
| feed: RealtimeProgressFeed, n: int, timeout: float = 2.0 | |
| ) -> None: | |
| """Spin until the feed's internal queue has at least `n` events ready, | |
| or timeout. Spec: feed runs fully async, so we need a settle period | |
| after broadcasting fake frames. | |
| """ | |
| deadline = asyncio.get_running_loop().time() + timeout | |
| while asyncio.get_running_loop().time() < deadline: | |
| if feed._event_queue.qsize() >= n: | |
| return | |
| await asyncio.sleep(0.01) | |
| # -------------------------------------------------------------------------- | |
| # Tests | |
| # -------------------------------------------------------------------------- | |
| async def test_tool_use_final_emits_workflow_added_then_task_added() -> None: | |
| async with FakeGateway() as gw: | |
| async with _feed(gw, parent_key="parent:1") as (_c, _t, feed): | |
| await feed.start() | |
| await gw.broadcast_chat( | |
| "parent:1", | |
| state="final", | |
| content=[ | |
| { | |
| "type": "tool_use", | |
| "id": "tu-a", | |
| "name": "Read", | |
| "input": {"file_path": "/x/y.py"}, | |
| } | |
| ], | |
| ) | |
| await _wait_for_queue(feed, 1) | |
| events = await _drain(feed) | |
| # Lazy open: first AddTask should emit the WorkflowItem ADDED. | |
| assert events, "no events drained" | |
| assert isinstance(events[0], ThreadItemAddedEvent) | |
| assert isinstance(events[0].item, WorkflowItem) | |
| workflow_id = events[0].item.id | |
| # Subsequent task adds (none here — only one task) would be Updated. | |
| assert len(events[0].item.workflow.tasks) == 1 | |
| task = events[0].item.workflow.tasks[0] | |
| assert isinstance(task, CustomTask) | |
| assert task.title == "Reading y.py" | |
| assert task.icon == "bolt" | |
| assert task.status_indicator == "loading" | |
| # workflow_id is stable for subsequent updates | |
| assert workflow_id.startswith("workflow_") | |
| async def test_tool_use_then_tool_result_marks_task_complete() -> None: | |
| async with FakeGateway() as gw: | |
| async with _feed(gw, parent_key="parent:1") as (_c, _t, feed): | |
| await feed.start() | |
| await gw.broadcast_chat( | |
| "parent:1", | |
| state="final", | |
| run_id="r", | |
| seq=1, | |
| content=[ | |
| { | |
| "type": "tool_use", | |
| "id": "tu-1", | |
| "name": "Bash", | |
| "input": {"command": "ls -la"}, | |
| } | |
| ], | |
| ) | |
| await _wait_for_queue(feed, 1) | |
| await gw.broadcast_chat( | |
| "parent:1", | |
| state="final", | |
| run_id="r", | |
| seq=2, | |
| content=[ | |
| {"type": "tool_result", "tool_use_id": "tu-1", "content": "ok"} | |
| ], | |
| ) | |
| await _wait_for_queue(feed, 2) | |
| events = await _drain(feed) | |
| # Sequence: ThreadItemAddedEvent (workflow with one loading task) | |
| # then ThreadItemUpdatedEvent with WorkflowTaskUpdated marking complete. | |
| assert len(events) >= 2 | |
| assert isinstance(events[0], ThreadItemAddedEvent) | |
| update_events = [e for e in events if isinstance(e, ThreadItemUpdatedEvent)] | |
| assert update_events, "expected an update event" | |
| # Look for the complete update. | |
| completes = [ | |
| e | |
| for e in update_events | |
| if isinstance(e.update, WorkflowTaskUpdated) | |
| and e.update.task.status_indicator == "complete" | |
| ] | |
| assert completes, "no complete-status update emitted" | |
| async def test_multi_tool_run_produces_ordered_task_timeline() -> None: | |
| """A realistic Hub→tool sequence: Read → Bash → Grep, all completed.""" | |
| async with FakeGateway() as gw: | |
| async with _feed(gw, parent_key="hub") as (_c, _t, feed): | |
| await feed.start() | |
| # Three sequential tool_use finals. | |
| for seq, (tid, name, inp) in enumerate( | |
| [ | |
| ("a", "Read", {"file_path": "x.py"}), | |
| ("b", "Bash", {"command": "ls"}), | |
| ("c", "Grep", {"pattern": "foo"}), | |
| ], | |
| start=1, | |
| ): | |
| await gw.broadcast_chat( | |
| "hub", | |
| state="final", | |
| run_id="r", | |
| seq=seq, | |
| content=[ | |
| {"type": "tool_use", "id": tid, "name": name, "input": inp} | |
| ], | |
| ) | |
| await _wait_for_queue(feed, 3) | |
| # Then their tool_results in order. | |
| for seq, tid in enumerate([("a"), ("b"), ("c")], start=4): | |
| await gw.broadcast_chat( | |
| "hub", | |
| state="final", | |
| run_id="r", | |
| seq=seq, | |
| content=[{"type": "tool_result", "tool_use_id": tid}], | |
| ) | |
| await _wait_for_queue(feed, 6) | |
| events = await _drain(feed) | |
| # First event is ThreadItemAdded (workflow + first task). | |
| assert isinstance(events[0], ThreadItemAddedEvent) | |
| wf = events[0].item | |
| # After all events applied, the workflow should have 3 tasks all complete. | |
| tasks = wf.workflow.tasks | |
| assert len(tasks) == 3 | |
| for t in tasks: | |
| assert isinstance(t, CustomTask) | |
| assert t.status_indicator == "complete" | |
| # Titles in order | |
| assert tasks[0].title == "Reading x.py" | |
| assert tasks[1].title == "Running: ls" | |
| assert tasks[2].title.startswith('Searching: "foo"') | |
| async def test_no_workflow_emitted_when_only_delta_arrives() -> None: | |
| """Delta-state envelopes must NOT trigger workflow creation.""" | |
| async with FakeGateway() as gw: | |
| async with _feed(gw, parent_key="parent:1") as (_c, tracker, feed): | |
| await feed.start() | |
| for seq in range(1, 5): | |
| await gw.broadcast_chat( | |
| "parent:1", | |
| state="delta", | |
| run_id="r", | |
| seq=seq, | |
| content=[{"type": "text", "text": "hi" * seq}], | |
| ) | |
| # Give the reader loop a chance. | |
| await asyncio.sleep(0.15) | |
| events = await _drain(feed) | |
| assert events == [] | |
| assert tracker.opened is False # never lazy-opened | |
| async def test_error_state_marks_all_open_complete_and_appends_terminal() -> None: | |
| async with FakeGateway() as gw: | |
| async with _feed(gw, parent_key="p") as (_c, tracker, feed): | |
| await feed.start() | |
| # Open two loading tasks. | |
| await gw.broadcast_chat( | |
| "p", | |
| state="final", | |
| run_id="r", | |
| seq=1, | |
| content=[ | |
| { | |
| "type": "tool_use", | |
| "id": "x", | |
| "name": "Read", | |
| "input": {"file_path": "a"}, | |
| } | |
| ], | |
| ) | |
| await gw.broadcast_chat( | |
| "p", | |
| state="final", | |
| run_id="r", | |
| seq=2, | |
| content=[ | |
| { | |
| "type": "tool_use", | |
| "id": "y", | |
| "name": "Bash", | |
| "input": {"command": "ls"}, | |
| } | |
| ], | |
| ) | |
| await _wait_for_queue(feed, 2) | |
| # Now an error. broadcast_chat doesn't carry errorMessage, so | |
| # emit a tailored frame directly via the fake gateway socket. | |
| import json as _json | |
| for ws in list(gw.subscriptions.get("p", [])): | |
| await ws.send( | |
| _json.dumps( | |
| { | |
| "type": "event", | |
| "event": "chat", | |
| "payload": { | |
| "runId": "r", | |
| "sessionKey": "p", | |
| "seq": 4, | |
| "state": "error", | |
| "errorMessage": "model timeout", | |
| }, | |
| "seq": 4, | |
| } | |
| ) | |
| ) | |
| await _wait_for_queue(feed, 3) | |
| events = await _drain(feed) | |
| # All open tasks should be marked complete + a "Run errored: ..." terminal | |
| # task should be appended (also complete). | |
| tasks = events[0].item.workflow.tasks | |
| assert any( | |
| isinstance(t, CustomTask) and t.title.startswith("Run errored:") | |
| for t in tasks | |
| ) | |
| for t in tasks: | |
| assert t.status_indicator == "complete", (t.title, t.status_indicator) | |
| assert feed.terminal is True | |
| async def test_child_subscription_opens_on_spawnedby() -> None: | |
| """When a frame for an unknown sessionKey arrives carrying | |
| spawnedBy=<parent>, the feed should open a child subscription.""" | |
| async with FakeGateway() as gw: | |
| async with _feed(gw, parent_key="hub") as (_c, _t, feed): | |
| await feed.start() | |
| # Simulate a parent-side `sessions_spawn` tool_use (so we know | |
| # the child exists conceptually). | |
| await gw.broadcast_chat( | |
| "hub", | |
| state="final", | |
| run_id="hub-r", | |
| seq=1, | |
| content=[ | |
| { | |
| "type": "tool_use", | |
| "id": "sp", | |
| "name": "sessions_spawn", | |
| "input": {"agentId": "andy", "task": "do work"}, | |
| } | |
| ], | |
| ) | |
| # Now the child broadcasts a frame BEFORE we explicitly subscribe. | |
| # Fake gateway only routes to explicit subscribers, so we send via | |
| # all connections (the child key is not subscribed yet by client). | |
| # We use a side-channel: broadcast through the parent's connection | |
| # with the child's sessionKey + spawnedBy field, so the WS client's | |
| # filter sees a chat event on a known subscription (the parent) — | |
| # then the feed detects the new sessionKey via spawnedBy. | |
| for ws in list(gw.subscriptions.get("hub", [])): | |
| import json | |
| await ws.send( | |
| json.dumps( | |
| { | |
| "type": "event", | |
| "event": "chat", | |
| "payload": { | |
| "runId": "child-r", | |
| "sessionKey": "andy-child", | |
| "spawnedBy": "hub", | |
| "seq": 1, | |
| "state": "final", | |
| "message": { | |
| "role": "assistant", | |
| "content": [ | |
| { | |
| "type": "tool_use", | |
| "id": "ct", | |
| "name": "Read", | |
| "input": {"file_path": "z.py"}, | |
| } | |
| ], | |
| }, | |
| }, | |
| "seq": 99, | |
| } | |
| ) | |
| ) | |
| # Wait for feed to open the child subscription. | |
| await asyncio.sleep(0.2) | |
| # The fake gateway should have recorded a subscribe call for the child key. | |
| keys_subscribed = [k for (k, _) in gw.subscribe_calls] | |
| assert "andy-child" in keys_subscribed | |
| async def test_replay_idempotent_via_translator() -> None: | |
| """A duplicate final envelope (post-reconnect replay) must not double-add.""" | |
| async with FakeGateway() as gw: | |
| async with _feed(gw, parent_key="p") as (_c, _t, feed): | |
| await feed.start() | |
| for _ in range(2): # second call simulates replay before client dedup | |
| await gw.broadcast_chat( | |
| "p", | |
| state="final", | |
| run_id="r", | |
| seq=1, | |
| content=[ | |
| { | |
| "type": "tool_use", | |
| "id": "z", | |
| "name": "Read", | |
| "input": {"file_path": "x"}, | |
| } | |
| ], | |
| ) | |
| await asyncio.sleep(0.15) | |
| events = await _drain(feed) | |
| # WS client itself drops the duplicate by (sessionKey, runId, seq) before | |
| # the feed sees it. We should have exactly one workflow-added event with | |
| # one task. | |
| added = [e for e in events if isinstance(e, ThreadItemAddedEvent)] | |
| assert len(added) == 1 | |
| assert len(added[0].item.workflow.tasks) == 1 | |
| async def test_stop_is_idempotent_and_closes_subscriptions() -> None: | |
| async with FakeGateway() as gw: | |
| async with _feed(gw, parent_key="p") as (_c, _t, feed): | |
| await feed.start() | |
| await feed.stop() | |
| await feed.stop() # idempotent | |
| assert feed.active is False | |
| async def test_drain_returns_empty_when_queue_empty() -> None: | |
| async with FakeGateway() as gw: | |
| async with _feed(gw, parent_key="p") as (_c, _t, feed): | |
| await feed.start() | |
| out = await _drain(feed) | |
| assert out == [] | |
| # ============================================================================ | |
| # Option 1 — poll mode integration (sessions_history HTTP polling). | |
| # Spec §7. Mocks the gateway HTTP via a fake httpx.AsyncClient transport. | |
| # ============================================================================ | |
| class _FakeAsyncHttp: | |
| """In-process httpx.AsyncClient shim. Returns a queue of canned | |
| sessions_history responses for sequential POST calls.""" | |
| def __init__(self, responses: list[list[dict]]) -> None: | |
| self._responses = list(responses) | |
| self.call_count = 0 | |
| async def post(self, url: str, json: dict, headers: dict): # noqa: A002 | |
| self.call_count += 1 | |
| if self._responses: | |
| msgs = self._responses.pop(0) | |
| else: | |
| msgs = [] | |
| body = { | |
| "ok": True, | |
| "result": { | |
| "content": [ | |
| { | |
| "type": "text", | |
| "text": __import__("json").dumps( | |
| {"sessionKey": "x", "messages": msgs} | |
| ), | |
| } | |
| ] | |
| }, | |
| } | |
| class _Resp: | |
| def __init__(self, data): | |
| self._data = data | |
| def raise_for_status(self): | |
| return None | |
| def json(self): | |
| return self._data | |
| return _Resp(body) | |
| async def aclose(self): | |
| return None | |
| async def test_poll_mode_polls_sessions_history_and_emits_tasks() -> None: | |
| """End-to-end: poll mode tails sessions_history, dedupes by | |
| __openclaw.seq, runs new toolcall + tool_result messages through the | |
| translator, and emits WorkflowItem + per-tool task events.""" | |
| # First poll: empty. | |
| # Second poll: assistant message carrying toolcall+tool_result both in | |
| # one content array, seq=5. | |
| # Third poll: same message again (dedup must skip). | |
| msg_with_tool = { | |
| "role": "assistant", | |
| "content": [ | |
| { | |
| "type": "toolcall", | |
| "id": "toolu_42", | |
| "name": "Bash", | |
| "arguments": {"command": "ls /tmp", "description": ""}, | |
| }, | |
| { | |
| "type": "tool_result", | |
| "tool_use_id": "toolu_42", | |
| "content": "a\nb", | |
| "name": "Bash", | |
| "is_error": False, | |
| }, | |
| ], | |
| "__openclaw": {"seq": 5, "id": "msg-1"}, | |
| } | |
| fake_http = _FakeAsyncHttp( | |
| responses=[ | |
| [], | |
| [msg_with_tool], | |
| [msg_with_tool], # replay — must be deduped | |
| ] | |
| ) | |
| tracker = ProgressTracker(thread_id="t-poll-1", enabled=True) | |
| feed = RealtimeProgressFeed( | |
| parent_session_key="thread_poll_session", | |
| tracker=tracker, | |
| mode="poll", | |
| http_client=fake_http, # type: ignore[arg-type] | |
| gateway_url="http://localhost:18789", | |
| gateway_token="ignored-in-fake", | |
| poll_interval_s=0.02, | |
| ) | |
| try: | |
| await feed.start() | |
| # Spin until we see at least 2 events (workflow ADDED + complete) AND | |
| # at least 3 polls have happened (so the dedupe replay was exercised). | |
| deadline = asyncio.get_running_loop().time() + 2.0 | |
| while asyncio.get_running_loop().time() < deadline: | |
| if feed._event_queue.qsize() >= 2 and fake_http.call_count >= 3: | |
| break | |
| await asyncio.sleep(0.02) | |
| events = await _drain(feed) | |
| finally: | |
| await feed.stop() | |
| assert events, "no events from poll feed" | |
| assert isinstance(events[0], ThreadItemAddedEvent) | |
| wf = events[0].item | |
| assert isinstance(wf, WorkflowItem) | |
| # The workflow should now have exactly ONE task — the Bash one — and it | |
| # should be marked complete (since result was co-located on same envelope). | |
| # Replay-dedupe proof: even though the same message arrives twice from | |
| # the fake http, only ONE task exists. | |
| tasks = wf.workflow.tasks | |
| assert len(tasks) == 1, [t.title for t in tasks] | |
| t = tasks[0] | |
| assert isinstance(t, CustomTask) | |
| assert t.title.startswith("Running: ls /tmp") | |
| assert t.status_indicator == "complete" | |
| assert fake_http.call_count >= 3 # all three poll-responses consumed | |
| async def test_poll_mode_skips_messages_without_seq() -> None: | |
| """Defensive: messages missing __openclaw.seq are skipped (not | |
| translated) — they can't be deduped reliably.""" | |
| fake_http = _FakeAsyncHttp( | |
| responses=[ | |
| [{"role": "assistant", "content": "no meta", "__openclaw": None}], | |
| [{"role": "assistant", "content": [{"type": "text", "text": "x"}]}], | |
| ] | |
| ) | |
| tracker = ProgressTracker(thread_id="t-poll-2", enabled=True) | |
| feed = RealtimeProgressFeed( | |
| parent_session_key="thread_x", | |
| tracker=tracker, | |
| mode="poll", | |
| http_client=fake_http, # type: ignore[arg-type] | |
| poll_interval_s=0.02, | |
| ) | |
| try: | |
| await feed.start() | |
| await asyncio.sleep(0.15) | |
| events = await _drain(feed) | |
| finally: | |
| await feed.stop() | |
| assert events == [] # no envelope had a usable seq -> nothing emitted | |
| async def test_poll_mode_stop_cancels_loop_and_closes_http() -> None: | |
| fake_http = _FakeAsyncHttp(responses=[[], []]) | |
| tracker = ProgressTracker(thread_id="t-poll-3", enabled=True) | |
| feed = RealtimeProgressFeed( | |
| parent_session_key="thread_y", | |
| tracker=tracker, | |
| mode="poll", | |
| http_client=fake_http, # type: ignore[arg-type] | |
| poll_interval_s=0.02, | |
| ) | |
| await feed.start() | |
| await asyncio.sleep(0.1) | |
| await feed.stop() | |
| # Internal task cleared. | |
| assert feed._poll_task is None | |
| # http_client was passed in (owns_http=False) so feed should NOT close it. | |
| # (Caller is responsible.) The fake's aclose is idempotent regardless. | |
| async def test_poll_mode_unknown_mode_raises_at_start() -> None: | |
| tracker = ProgressTracker(thread_id="t-bad", enabled=True) | |
| feed = RealtimeProgressFeed( | |
| parent_session_key="k", | |
| tracker=tracker, | |
| mode="bogus", # type: ignore[arg-type] | |
| ) | |
| with pytest.raises(ValueError): | |
| await feed.start() | |