openclaw-bridge / tests /test_realtime_progress_feed.py
Andy
feat(saas-wrapper): P3-P11 production wrapper landing on HF
a382c74
Raw
History Blame Contribute Delete
22 kB
"""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
@asynccontextmanager
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()