Skip to content

Commit e06dc03

Browse files
committed
test(client): cover chat_stream chunk termination ordering
Drives chat_stream against a fake websocket so exact chunk envelopes can be scripted. Covers the combined delta+done final chunk (regression), the framework-default separate empty-done terminator, and an explicit None sentinel. The combined-chunk case fails against the pre-fix loop.
1 parent 42a8288 commit e06dc03

1 file changed

Lines changed: 89 additions & 0 deletions

File tree

tests/test_client_chat_stream.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Unit coverage for ZhubConnection.chat_stream chunk handling.
2+
3+
Drives chat_stream directly against a fake websocket so we can feed exact
4+
chunk envelopes — in particular a *combined* final chunk that carries both
5+
`delta` text and `done=True` in one envelope. The HTTP SSE consumer emits
6+
text before honoring the finish flag; the Python client must do the same or
7+
it silently drops a publisher's last words.
8+
"""
9+
10+
import json
11+
12+
import pytest
13+
14+
from zhub.client import ZhubConnection
15+
from zhub.manifest import Manifest
16+
17+
18+
class _FakeWS:
19+
"""Captures the outgoing chat-request and replays a scripted chunk
20+
sequence onto the connection's stream queue for that request_id."""
21+
22+
def __init__(self, conn: ZhubConnection, script: list[dict]) -> None:
23+
self._conn = conn
24+
self._script = script
25+
26+
async def send(self, text: str) -> None:
27+
env = json.loads(text)
28+
rid = env["request_id"]
29+
queue = self._conn._streams[rid]
30+
for chunk in self._script:
31+
queue.put_nowait(chunk)
32+
33+
34+
def _make_conn(script: list[dict]) -> ZhubConnection:
35+
conn = ZhubConnection(
36+
ai_name="ai",
37+
api_key="zk_test",
38+
hub_url="ws://localhost",
39+
client_manifest=Manifest(name="ai-client"),
40+
capabilities={},
41+
)
42+
conn._ws = _FakeWS(conn, script)
43+
return conn
44+
45+
46+
async def _drain(conn: ZhubConnection) -> list[str]:
47+
out = []
48+
async for chunk in conn.chat_stream(
49+
messages=[{"role": "user", "content": "hi"}],
50+
timeout_per_chunk=1.0,
51+
):
52+
out.append(chunk)
53+
return out
54+
55+
56+
@pytest.mark.asyncio
57+
async def test_combined_final_chunk_keeps_its_text():
58+
# Final chunk flags done=True AND carries content in the same envelope.
59+
conn = _make_conn([
60+
{"delta": "hello ", "done": False},
61+
{"delta": "world", "done": True},
62+
])
63+
chunks = await _drain(conn)
64+
assert "".join(chunks) == "hello world"
65+
assert "world" in chunks
66+
67+
68+
@pytest.mark.asyncio
69+
async def test_separate_empty_done_chunk_terminates_cleanly():
70+
# Framework-default shape: content chunks, then a separate empty done.
71+
conn = _make_conn([
72+
{"delta": "a", "done": False},
73+
{"delta": "b", "done": False},
74+
{"delta": "", "done": True},
75+
])
76+
chunks = await _drain(conn)
77+
assert "".join(chunks) == "ab"
78+
# The empty terminator must not surface as a yielded chunk.
79+
assert "" not in chunks
80+
81+
82+
@pytest.mark.asyncio
83+
async def test_explicit_none_terminates_without_yielding():
84+
conn = _make_conn([
85+
{"delta": "x", "done": False},
86+
None,
87+
])
88+
chunks = await _drain(conn)
89+
assert chunks == ["x"]

0 commit comments

Comments
 (0)