Skip to content

Commit 0ea9f55

Browse files
committed
test(client): pin _handle_chat resolves coroutine before gen-detect
Four cases: async-def returning async-gen (accumulate + stream), async-def returning sync-gen (accumulate), and async-def returning a plain string (guard the common single-shot path against the fix eating it). Pre-fix mutation: the three gen cases fail with `<async_generator object …>` / `<generator object …>` in the response text.
1 parent c6a7a6c commit 0ea9f55

1 file changed

Lines changed: 124 additions & 0 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Regression: `_handle_chat` awaited a coroutine chat_handler once and then
2+
fell into the single-shot path — but an ``async def`` handler that itself
3+
returns an async (or sync) generator resolved to a *generator*, which then
4+
got stringified into ``"<async_generator object …>"`` as the chat-response
5+
text. The isasyncgen / isgenerator branches only ran against the raw
6+
handler result, before the coroutine was awaited.
7+
8+
Reachable on any publisher writing::
9+
10+
async def handler(messages, options):
11+
return _stream() # _stream is an async or sync generator
12+
13+
which is the natural pattern once the handler needs to do async setup
14+
(open an httpx client, load a key, look up state) before returning the
15+
stream. The fix is to await the coroutine FIRST, then run the same
16+
gen-detection against the awaited value.
17+
"""
18+
19+
import json
20+
21+
import pytest
22+
23+
from zhub.client import _handle_chat
24+
from zhub.protocol import Envelope
25+
26+
27+
class _FakeWS:
28+
def __init__(self) -> None:
29+
self.sent: list[dict] = []
30+
31+
async def send(self, text: str) -> None:
32+
self.sent.append(json.loads(text))
33+
34+
35+
class _Pub:
36+
def __init__(self, handler) -> None:
37+
self.chat_handler = handler
38+
39+
40+
@pytest.mark.asyncio
41+
async def test_async_def_returning_asyncgen_accumulates_correctly():
42+
"""Non-streaming: text parts from the returned async-gen must join into
43+
the chat-response text field (was ``"<async_generator …>"`` pre-fix)."""
44+
45+
async def handler(messages, options):
46+
async def _gen():
47+
yield "hi "
48+
yield "there"
49+
yield {"delta": "", "finish_reason": "length"}
50+
return _gen()
51+
52+
ws = _FakeWS()
53+
env = Envelope(type="chat-request", payload={"messages": []})
54+
await _handle_chat(_Pub(handler), ws, env)
55+
56+
assert len(ws.sent) == 1
57+
payload = ws.sent[0]["payload"]
58+
assert payload["text"] == "hi there"
59+
assert payload["finish_reason"] == "length"
60+
61+
62+
@pytest.mark.asyncio
63+
async def test_async_def_returning_asyncgen_streams():
64+
"""Streaming: the returned async-gen must emit chat-chunks per yield,
65+
then a terminator carrying the handler's finish_reason. Pre-fix the
66+
single-shot path emitted one chat-response with the stringified gen."""
67+
68+
async def handler(messages, options):
69+
async def _gen():
70+
yield "one "
71+
yield "two"
72+
return _gen()
73+
74+
ws = _FakeWS()
75+
env = Envelope(
76+
type="chat-request",
77+
payload={"messages": [], "stream": True},
78+
)
79+
await _handle_chat(_Pub(handler), ws, env)
80+
81+
chunks = [e for e in ws.sent if e["type"] == "chat-chunk"]
82+
assert [c["payload"].get("delta") for c in chunks if not c["payload"]["done"]] == ["one ", "two"]
83+
terminator = chunks[-1]
84+
assert terminator["payload"]["done"] is True
85+
assert terminator["payload"]["finish_reason"] == "stop"
86+
87+
88+
@pytest.mark.asyncio
89+
async def test_async_def_returning_syncgen_accumulates_correctly():
90+
"""Sync-generator variant of the same bug — an ``async def`` handler
91+
returning a plain generator function's iterator."""
92+
93+
async def handler(messages, options):
94+
def _gen():
95+
yield "a"
96+
yield "b"
97+
yield "c"
98+
return _gen()
99+
100+
ws = _FakeWS()
101+
env = Envelope(type="chat-request", payload={"messages": []})
102+
await _handle_chat(_Pub(handler), ws, env)
103+
104+
assert len(ws.sent) == 1
105+
assert ws.sent[0]["payload"]["text"] == "abc"
106+
107+
108+
@pytest.mark.asyncio
109+
async def test_async_def_returning_string_still_single_shot():
110+
"""Regression guard: the standard ``async def`` handler that returns a
111+
string must keep working as a single-shot chat-response — the fix must
112+
not eat the coroutine path for the common case."""
113+
114+
async def handler(messages, options):
115+
return "plain reply"
116+
117+
ws = _FakeWS()
118+
env = Envelope(type="chat-request", payload={"messages": []})
119+
await _handle_chat(_Pub(handler), ws, env)
120+
121+
assert len(ws.sent) == 1
122+
payload = ws.sent[0]["payload"]
123+
assert payload["text"] == "plain reply"
124+
assert payload["finish_reason"] == "stop"

0 commit comments

Comments
 (0)