Skip to content

Commit acdcaa8

Browse files
committed
test(server): cover combined tool_call+done streaming chunk
Two tests: default mode must surface finish_reason tool_calls, and auto mode must resolve the capability, when the tool_call delta and its done/finish ride in one chunk. Both fail before the consumer fix.
1 parent d361777 commit acdcaa8

1 file changed

Lines changed: 129 additions & 0 deletions

File tree

tests/test_tool_call_streaming.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,135 @@ def thing_handler(args):
219219
assert finish == "stop"
220220

221221

222+
@pytest.mark.asyncio
223+
async def test_combined_tool_call_done_chunk_keeps_finish_reason(hub):
224+
"""Regression: a publisher may flag its last tool_call delta with done +
225+
finish_reason in ONE chunk (a shape _serialize_stream_chunk supports and
226+
the hub relays verbatim), instead of a separate trailing finish chunk. The
227+
SSE consumer must still surface finish_reason: tool_calls — not silently
228+
drop it because the tool_call branch short-circuits the done check."""
229+
230+
async def chat_handler(messages, options):
231+
# delta + done + finish_reason all in a single envelope
232+
yield {
233+
"tool_call_delta": {
234+
"index": 0,
235+
"id": "call_combined",
236+
"type": "function",
237+
"function": {"name": "do_thing", "arguments": "{\"a\":1}"},
238+
},
239+
"done": True,
240+
"finish_reason": "tool_calls",
241+
}
242+
243+
pub = publish(
244+
name="combined-tc-bot",
245+
description="combined tool_call+done chunk",
246+
chat_handler=chat_handler,
247+
hub_url=f"ws://127.0.0.1:{hub}",
248+
)
249+
for _ in range(50):
250+
if pub.api_key:
251+
break
252+
await asyncio.sleep(0.1)
253+
254+
async with httpx.AsyncClient(timeout=5.0) as c:
255+
resp = await c.post(
256+
f"http://127.0.0.1:{hub}/{pub.name}/v1/chat/completions",
257+
json={"messages": [{"role": "user", "content": "go"}],
258+
"stream": True},
259+
headers={"Authorization": f"Bearer {pub.api_key}"},
260+
)
261+
assert resp.status_code == 200
262+
chunks = _parse_sse(resp.text)
263+
assert chunks, f"empty SSE: {resp.text!r}"
264+
265+
tcs = [c for c in chunks
266+
if c.get("choices", [{}])[0].get("delta", {}).get("tool_calls")]
267+
assert tcs, f"no tool_call delta SSE chunk found: {chunks!r}"
268+
269+
finish = next(
270+
(c["choices"][0].get("finish_reason") for c in reversed(chunks)
271+
if c["choices"][0].get("finish_reason")),
272+
None,
273+
)
274+
assert finish == "tool_calls", (
275+
f"combined tool_call+done chunk lost finish_reason: {chunks!r}"
276+
)
277+
278+
279+
@pytest.mark.asyncio
280+
async def test_auto_mode_resolves_combined_tool_call_done_chunk(hub):
281+
"""Auto mode must auto-resolve even when the tool_call delta and its
282+
done/finish:tool_calls ride in a single combined chunk. Before the fix the
283+
tool_call branch `continue`d past the done check, final_finish stayed
284+
'stop', and the capability never fired."""
285+
invoke_n = {"n": 0}
286+
call_n = {"n": 0}
287+
288+
async def chat_handler(messages, options):
289+
call_n["n"] += 1
290+
if call_n["n"] == 1:
291+
yield {
292+
"tool_call_delta": {
293+
"index": 0,
294+
"id": "call_ac",
295+
"type": "function",
296+
"function": {"name": "auto_combined",
297+
"arguments": json.dumps({"x": 1})},
298+
},
299+
"done": True,
300+
"finish_reason": "tool_calls",
301+
}
302+
else:
303+
for piece in ("ok ", "done"):
304+
yield piece
305+
306+
def thing_handler(args):
307+
invoke_n["n"] += 1
308+
return {"got": args, "result": "fired"}
309+
310+
pub = publish(
311+
name="auto-combined-bot",
312+
description="auto mode combined chunk",
313+
chat_handler=chat_handler,
314+
hub_url=f"ws://127.0.0.1:{hub}",
315+
)
316+
for _ in range(50):
317+
if pub.api_key:
318+
break
319+
await asyncio.sleep(0.1)
320+
321+
conn = connect(
322+
ai_name=pub.name, api_key=pub.api_key,
323+
hub_url=f"ws://127.0.0.1:{hub}",
324+
capabilities={"auto_combined": ({"type": "object"}, thing_handler)},
325+
)
326+
await asyncio.sleep(0.6)
327+
328+
async with httpx.AsyncClient(timeout=10.0) as c:
329+
resp = await c.post(
330+
f"http://127.0.0.1:{hub}/{pub.name}/v1/chat/completions",
331+
json={"messages": [{"role": "user", "content": "go"}],
332+
"stream": True},
333+
headers={
334+
"Authorization": f"Bearer {pub.api_key}",
335+
"X-Zhub-Stream-Tools": "auto",
336+
},
337+
)
338+
assert resp.status_code == 200
339+
chunks = _parse_sse(resp.text)
340+
assert chunks
341+
342+
text = "".join(
343+
c["choices"][0].get("delta", {}).get("content", "") or ""
344+
for c in chunks
345+
)
346+
assert "ok" in text and "done" in text, f"missing follow-up text: {text!r}"
347+
assert invoke_n["n"] == 1, "capability did not fire on combined chunk"
348+
assert call_n["n"] == 2, "publisher follow-up not requested"
349+
350+
222351
@pytest.mark.asyncio
223352
async def test_pre_resolve_mode_still_works(hub):
224353
"""Phase 4.2 pre-resolve path is untouched."""

0 commit comments

Comments
 (0)