@@ -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
223352async def test_pre_resolve_mode_still_works (hub ):
224353 """Phase 4.2 pre-resolve path is untouched."""
0 commit comments