@@ -311,6 +311,61 @@ async def stream_chat_raw(self, payload: dict[str, Any]):
311311 yield {"data" : "[DONE]" }
312312
313313
314+ class MultiToolOpenRouterClient (DummyOpenRouterClient ):
315+ def __init__ (self , tool_calls : list [dict [str , Any ]], final_message : str ) -> None :
316+ super ().__init__ ()
317+ self .tool_calls = tool_calls
318+ self .final_message = final_message
319+ self .call_index = 0
320+
321+ async def stream_chat_raw (self , payload : dict [str , Any ]):
322+ self .payloads .append (payload )
323+ if self .call_index < len (self .tool_calls ):
324+ call = self .tool_calls [self .call_index ]
325+ self .call_index += 1
326+ arguments = call .get ("arguments" , {})
327+ if isinstance (arguments , dict ):
328+ arguments_payload = json .dumps (arguments )
329+ else :
330+ arguments_payload = str (arguments )
331+ chunk = {
332+ "id" : f"gen-tool-{ self .call_index } " ,
333+ "choices" : [
334+ {
335+ "delta" : {
336+ "tool_calls" : [
337+ {
338+ "id" : f"call_{ self .call_index } " ,
339+ "type" : "function" ,
340+ "function" : {
341+ "name" : call .get ("name" , "calendar_lookup" ),
342+ "arguments" : arguments_payload ,
343+ },
344+ }
345+ ]
346+ },
347+ "finish_reason" : "tool_calls" ,
348+ }
349+ ],
350+ }
351+ yield {"data" : json .dumps (chunk )}
352+ yield {"data" : "[DONE]" }
353+ return
354+
355+ self .call_index += 1
356+ final_chunk = {
357+ "id" : f"gen-final-{ self .call_index } " ,
358+ "choices" : [
359+ {
360+ "delta" : {"content" : self .final_message },
361+ "finish_reason" : "stop" ,
362+ }
363+ ],
364+ }
365+ yield {"data" : json .dumps (final_chunk )}
366+ yield {"data" : "[DONE]" }
367+
368+
314369class ExpandingToolClient :
315370 def __init__ (self ) -> None :
316371 self .context_history : list [list [str ]] = []
@@ -585,6 +640,18 @@ async def test_streaming_expands_contexts_after_no_result() -> None:
585640 assert len (client .payloads ) == 2
586641 assert events [- 1 ]["data" ] == "[DONE]"
587642
643+ notice_events = [
644+ json .loads (event ["data" ])
645+ for event in events
646+ if event .get ("event" ) == "notice"
647+ ]
648+ assert notice_events , "Expected notice event after empty tool result"
649+ notice = notice_events [0 ]
650+ assert notice ["reason" ] == "no_results"
651+ assert notice ["tool" ] == "calendar_lookup"
652+ assert notice ["next_contexts" ] == ["tasks" ]
653+ assert notice ["confirmation_required" ] is True
654+
588655
589656@pytest .mark .anyio ("asyncio" )
590657async def test_structured_tool_choice_does_not_retry_without_tools () -> None :
@@ -625,3 +692,118 @@ async def test_structured_tool_choice_does_not_retry_without_tools() -> None:
625692 pass
626693
627694 assert client .calls == 1
695+
696+
697+ @pytest .mark .anyio ("asyncio" )
698+ async def test_streaming_emits_notice_for_missing_arguments () -> None :
699+ client = MultiToolOpenRouterClient (
700+ [{"name" : "calendar_lookup" , "arguments" : "" }],
701+ final_message = "Please share more details." ,
702+ )
703+ tool_client = ExpandingToolClient ()
704+ handler = StreamingHandler (
705+ client , # type: ignore[arg-type]
706+ DummyRepository (), # type: ignore[arg-type]
707+ tool_client , # type: ignore[arg-type]
708+ default_model = "openrouter/auto" ,
709+ )
710+
711+ request = ChatCompletionRequest (
712+ messages = [ChatMessage (role = "user" , content = "Check my calendar today" )],
713+ )
714+ conversation = [{"role" : "user" , "content" : "Check my calendar today" }]
715+ plan = ToolContextPlan (stages = [["calendar" ], ["tasks" ]], broad_search = True )
716+ initial_tools = tool_client .get_openai_tools_for_contexts (
717+ plan .contexts_for_attempt (0 )
718+ )
719+
720+ events : list [dict [str , Any ]] = []
721+ async for event in handler .stream_conversation (
722+ "session-missing-args" ,
723+ request ,
724+ conversation ,
725+ initial_tools ,
726+ None ,
727+ plan ,
728+ ):
729+ events .append (event )
730+
731+ notice_events = [
732+ json .loads (event ["data" ])
733+ for event in events
734+ if event .get ("event" ) == "notice"
735+ ]
736+
737+ assert notice_events , "Expected notice event when tool arguments are missing"
738+ notice = notice_events [0 ]
739+ assert notice ["reason" ] == "missing_arguments"
740+ assert notice ["tool" ] == "calendar_lookup"
741+ assert notice ["next_contexts" ] == []
742+ assert notice ["confirmation_required" ] is True
743+ assert tool_client .calls == 0
744+ assert len (client .payloads ) == 2
745+ assert events [- 1 ]["data" ] == "[DONE]"
746+
747+
748+ @pytest .mark .anyio ("asyncio" )
749+ async def test_streaming_handles_multi_stage_notices () -> None :
750+ client = MultiToolOpenRouterClient (
751+ [
752+ {"name" : "calendar_lookup" , "arguments" : {"query" : "habit review" }},
753+ {"name" : "tasks_lookup" , "arguments" : {"query" : "habit review" }},
754+ ],
755+ final_message = "Let's confirm the plan." ,
756+ )
757+ tool_client = ExpandingToolClient ()
758+ tool_client .results = [
759+ "No events found in that window." ,
760+ "No matching tasks were located." ,
761+ ]
762+ handler = StreamingHandler (
763+ client , # type: ignore[arg-type]
764+ DummyRepository (), # type: ignore[arg-type]
765+ tool_client , # type: ignore[arg-type]
766+ default_model = "openrouter/auto" ,
767+ )
768+
769+ request = ChatCompletionRequest (
770+ messages = [ChatMessage (role = "user" , content = "Help me build better habits" )],
771+ )
772+ conversation = [{"role" : "user" , "content" : "Help me build better habits" }]
773+ plan = ToolContextPlan (
774+ stages = [["calendar" ], ["tasks" ], ["notes" ]],
775+ broad_search = True ,
776+ )
777+ initial_tools = tool_client .get_openai_tools_for_contexts (
778+ plan .contexts_for_attempt (0 )
779+ )
780+
781+ events : list [dict [str , Any ]] = []
782+ async for event in handler .stream_conversation (
783+ "session-multi-stage" ,
784+ request ,
785+ conversation ,
786+ initial_tools ,
787+ None ,
788+ plan ,
789+ ):
790+ events .append (event )
791+
792+ notice_events = [
793+ json .loads (event ["data" ])
794+ for event in events
795+ if event .get ("event" ) == "notice"
796+ ]
797+
798+ assert len (notice_events ) == 2
799+ first_notice , second_notice = notice_events
800+ assert first_notice ["reason" ] == "no_results"
801+ assert first_notice ["next_contexts" ] == ["tasks" ]
802+ assert second_notice ["reason" ] == "no_results"
803+ assert second_notice ["next_contexts" ] == ["notes" ]
804+ assert tool_client .context_history [0 ] == ["calendar" ]
805+ assert ["calendar" , "tasks" ] in tool_client .context_history
806+ assert ["calendar" , "tasks" , "notes" ] in tool_client .context_history
807+ assert tool_client .calls == 2
808+ assert len (client .payloads ) == 3
809+ assert events [- 1 ]["data" ] == "[DONE]"
0 commit comments