@@ -121,6 +121,74 @@ async def test_stream_skips_empty_and_malformed_lines():
121121 assert out [- 1 ].done is True
122122
123123
124+ @pytest .mark .asyncio
125+ async def test_stream_forwards_tools_to_request_body ():
126+ """Ollama's /api/chat supports a top-level `tools` field. The adapter must
127+ forward declared tools (like its sibling adapters) — otherwise the model is
128+ never told they exist and can never emit a tool call."""
129+ tools = [{
130+ "type" : "function" ,
131+ "function" : {"name" : "get_weather" , "parameters" : {"type" : "object" }},
132+ }]
133+ fake = _FakeAsyncClient ([
134+ json .dumps ({"message" : {"content" : "hi" }, "done" : True , "done_reason" : "stop" }),
135+ ])
136+ adapter = OllamaAdapter (base_url = "http://x" , model = "llama3.2" , http = fake )
137+ async for _ in adapter .stream ([{"role" : "user" , "content" : "weather?" }], tools = tools ):
138+ pass
139+ assert fake .last_call ["json" ]["tools" ] == tools
140+
141+
142+ @pytest .mark .asyncio
143+ async def test_stream_omits_tools_when_none ():
144+ """No tools passed → no `tools` key in the body (don't send an empty field)."""
145+ fake = _FakeAsyncClient ([
146+ json .dumps ({"message" : {"content" : "hi" }, "done" : True , "done_reason" : "stop" }),
147+ ])
148+ adapter = OllamaAdapter (base_url = "http://x" , model = "llama3.2" , http = fake )
149+ async for _ in adapter .stream ([{"role" : "user" , "content" : "hi" }]):
150+ pass
151+ assert "tools" not in fake .last_call ["json" ]
152+
153+
154+ @pytest .mark .asyncio
155+ async def test_stream_surfaces_tool_calls_as_deltas ():
156+ """Ollama returns tool calls in `message.tool_calls` with a dict of
157+ arguments. The adapter must re-shape each into a hub-shaped tool_call_delta
158+ (string arguments) and report finish_reason='tool_calls' so the hub's
159+ auto-resolution fires — dropping them left an Ollama AI unable to call tools."""
160+ lines = [
161+ json .dumps ({"message" : {
162+ "role" : "assistant" ,
163+ "content" : "" ,
164+ "tool_calls" : [
165+ {"function" : {"name" : "get_weather" , "arguments" : {"city" : "SF" }}},
166+ {"function" : {"name" : "get_time" , "arguments" : {"tz" : "PST" }}},
167+ ],
168+ }, "done" : True , "done_reason" : "stop" }),
169+ ]
170+ fake = _FakeAsyncClient (lines )
171+ adapter = OllamaAdapter (base_url = "http://x" , model = "llama3.2" , http = fake )
172+ out : list [ChatChunk ] = []
173+ async for chunk in adapter .stream ([{"role" : "user" , "content" : "weather + time?" }]):
174+ out .append (chunk )
175+
176+ tcds = [c .tool_call_delta for c in out if c .tool_call_delta ]
177+ assert len (tcds ) == 2
178+ assert tcds [0 ]["index" ] == 0
179+ assert tcds [0 ]["type" ] == "function"
180+ assert tcds [0 ]["id" ] # synthesized, non-empty
181+ assert tcds [0 ]["function" ]["name" ] == "get_weather"
182+ # arguments must be a JSON string, not a dict (the hub concatenates them)
183+ assert tcds [0 ]["function" ]["arguments" ] == json .dumps ({"city" : "SF" })
184+ assert tcds [1 ]["index" ] == 1
185+ assert tcds [1 ]["function" ]["name" ] == "get_time"
186+
187+ # the turn ended in a tool call → finish_reason normalized to tool_calls
188+ assert out [- 1 ].done is True
189+ assert out [- 1 ].finish_reason == "tool_calls"
190+
191+
124192@pytest .mark .asyncio
125193async def test_stream_raises_on_upstream_error ():
126194 """A non-2xx response (e.g. 404 unknown model) returns a JSON error body,
0 commit comments