Skip to content

Commit 98fce0a

Browse files
committed
test(brains): cover tool-call survival through the publisher hand-off
A tool-call turn (text preamble + tool_call opener + arg fragment + tool_calls finish, all with empty text deltas) must keep its tool_call deltas and finish_reason through stream_for_publish, and round-trip via both the streaming serializer and the non-streaming accumulator into a response carrying tool_calls; also guards tools reaching the brain. Reverting the fix to yield chunk.delta fails 3 of the 4.
1 parent dc1aee6 commit 98fce0a

1 file changed

Lines changed: 134 additions & 0 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""The brain → publisher hand-off must preserve tool calls.
2+
3+
`stream_for_publish` is what both publisher entry points (`zhub up` and
4+
examples/multi_brain_publisher.py) use to turn a brain adapter's stream
5+
into the chunks a zhub chat_handler yields. Every bespoke adapter was
6+
taught to surface OpenAI-shape tool_call deltas + a
7+
``finish_reason="tool_calls"`` terminator, but a handler that yields only
8+
``chunk.delta`` throws all of that away — so a tool-call turn reaches the
9+
hub as empty text with finish_reason "stop" and auto-resolution never
10+
fires. These tests pin the hand-off on both the streaming and the
11+
non-streaming publisher paths.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import json
17+
18+
import pytest
19+
20+
from zhub.brains import stream_for_publish
21+
from zhub.brains.base import BrainAdapter, ChatChunk
22+
from zhub.client import (
23+
_accumulate_tool_call,
24+
_chunk_fields,
25+
_finalize_accumulated,
26+
_serialize_stream_chunk,
27+
)
28+
29+
30+
class _ToolCallBrain(BrainAdapter):
31+
"""A brain that answers a turn with a single tool call: one text
32+
preamble delta, a tool_call opener (empty delta), an argument
33+
fragment (empty delta), then a final empty chunk carrying
34+
finish_reason="tool_calls". Records the tools it was handed."""
35+
36+
name = "toolfake"
37+
label = "tool-call fake brain"
38+
39+
def __init__(self) -> None:
40+
self.seen_tools = None
41+
42+
@classmethod
43+
def try_init(cls):
44+
return cls()
45+
46+
async def stream(self, messages, *, system=None, temperature=0.7,
47+
max_tokens=2048, tools=None):
48+
self.seen_tools = tools
49+
yield ChatChunk(delta="let me check ")
50+
yield ChatChunk(delta="", tool_call_delta={
51+
"index": 0, "id": "call_1", "type": "function",
52+
"function": {"name": "get_weather"},
53+
})
54+
yield ChatChunk(delta="", tool_call_delta={
55+
"index": 0, "function": {"arguments": '{"city":"Paris"}'},
56+
})
57+
yield ChatChunk(delta="", done=True, finish_reason="tool_calls")
58+
59+
60+
async def _collect(brain, **kw):
61+
return [c async for c in stream_for_publish(brain, [{"role": "user", "content": "hi"}], **kw)]
62+
63+
64+
@pytest.mark.asyncio
65+
async def test_tool_call_chunks_are_not_dropped():
66+
brain = _ToolCallBrain()
67+
chunks = await _collect(brain)
68+
69+
# The text delta survives.
70+
assert any(c.delta == "let me check " for c in chunks)
71+
# Both tool_call deltas survive (the bug dropped these — empty delta).
72+
tcds = [c.tool_call_delta for c in chunks if c.tool_call_delta]
73+
assert len(tcds) == 2
74+
assert tcds[0]["function"]["name"] == "get_weather"
75+
assert tcds[1]["function"]["arguments"] == '{"city":"Paris"}'
76+
# The tool_calls finish_reason survives.
77+
assert any(c.finish_reason == "tool_calls" for c in chunks)
78+
79+
80+
@pytest.mark.asyncio
81+
async def test_tools_are_forwarded_to_the_brain():
82+
brain = _ToolCallBrain()
83+
tools = [{"type": "function", "function": {"name": "get_weather"}}]
84+
await _collect(brain, tools=tools)
85+
assert brain.seen_tools == tools
86+
87+
88+
@pytest.mark.asyncio
89+
async def test_streaming_serialization_carries_tool_calls_to_hub():
90+
"""Serialize each yielded chunk the way the publisher's streaming
91+
path does, then run the envelopes through the hub's streaming
92+
accumulator logic — a tool call + tool_calls finish must come out."""
93+
brain = _ToolCallBrain()
94+
chunks = await _collect(brain)
95+
96+
accumulated: dict[int, dict] = {}
97+
final_finish = "stop"
98+
for chunk in chunks:
99+
payload = json.loads(_serialize_stream_chunk(chunk, "req"))["payload"]
100+
tcd = payload.get("tool_call_delta")
101+
if tcd:
102+
_accumulate_tool_call(accumulated, tcd)
103+
if payload.get("done"):
104+
final_finish = payload.get("finish_reason") or "stop"
105+
106+
assert final_finish == "tool_calls"
107+
assert accumulated[0]["function"]["name"] == "get_weather"
108+
assert accumulated[0]["function"]["arguments"] == '{"city":"Paris"}'
109+
110+
111+
@pytest.mark.asyncio
112+
async def test_nonstreaming_accumulation_carries_tool_calls_to_hub():
113+
"""The non-streaming publisher path folds the same chunks into one
114+
chat-response; tool_calls and the real finish_reason must be on it."""
115+
brain = _ToolCallBrain()
116+
chunks = await _collect(brain)
117+
118+
text_parts: list[str] = []
119+
slots: dict[int, dict] = {}
120+
finish = None
121+
for chunk in chunks:
122+
text, tcd, fin = _chunk_fields(chunk)
123+
if text:
124+
text_parts.append(text)
125+
if tcd:
126+
_accumulate_tool_call(slots, tcd)
127+
if fin:
128+
finish = fin
129+
payload = _finalize_accumulated(text_parts, slots, finish)
130+
131+
assert payload["finish_reason"] == "tool_calls"
132+
assert payload["tool_calls"][0]["function"]["name"] == "get_weather"
133+
assert payload["tool_calls"][0]["function"]["arguments"] == '{"city":"Paris"}'
134+
assert payload["text"] == "let me check "

0 commit comments

Comments
 (0)