@@ -886,3 +886,128 @@ async fn run_tool_call_loop_applies_per_tool_max_result_size_cap() {
886886 tool_results. content. len( )
887887 ) ;
888888}
889+
890+ // ── TAURI-RUST-4 regression guard ────────────────────────────────────
891+ //
892+ // Some providers (Anthropic, OpenHuman cloud after the uniqueness-
893+ // enforcement rollout) reject chat requests whose `tools` list contains
894+ // two specs with the same `name` — HTTP 400 "Tool names must be unique."
895+ // `run_tool_call_loop` chains the persistent `tools_registry` with the
896+ // per-turn synthesised `extra_tools`; if any name collides across the
897+ // two lists, both would have made it to the provider before the fix.
898+ //
899+ // This test wires a capturing provider, builds a colliding tool list
900+ // (one `EchoTool` in the registry + a second `EchoTool` clone in
901+ // `extra_tools`), and asserts the names the provider sees contain
902+ // `"echo"` exactly once.
903+
904+ /// Provider that records the tool-spec names of every `chat()` request
905+ /// it sees, then returns the next scripted response.
906+ struct CapturingProvider {
907+ /// One entry per `chat()` call — the tool-name list extracted from
908+ /// `ChatRequest.tools`. `None` if `tools` was `None`.
909+ captured : Mutex < Vec < Option < Vec < String > > > > ,
910+ responses : Mutex < Vec < anyhow:: Result < ChatResponse > > > ,
911+ native_tools : bool ,
912+ }
913+
914+ #[ async_trait]
915+ impl Provider for CapturingProvider {
916+ async fn chat_with_system (
917+ & self ,
918+ _system_prompt : Option < & str > ,
919+ _message : & str ,
920+ _model : & str ,
921+ _temperature : f64 ,
922+ ) -> Result < String > {
923+ Ok ( "fallback" . into ( ) )
924+ }
925+
926+ async fn chat (
927+ & self ,
928+ request : ChatRequest < ' _ > ,
929+ _model : & str ,
930+ _temperature : f64 ,
931+ ) -> Result < ChatResponse > {
932+ let names = request
933+ . tools
934+ . map ( |specs| specs. iter ( ) . map ( |s| s. name . clone ( ) ) . collect :: < Vec < _ > > ( ) ) ;
935+ self . captured . lock ( ) . push ( names) ;
936+ let mut guard = self . responses . lock ( ) ;
937+ guard. remove ( 0 )
938+ }
939+
940+ fn capabilities ( & self ) -> ProviderCapabilities {
941+ ProviderCapabilities {
942+ native_tool_calling : self . native_tools ,
943+ vision : false ,
944+ ..ProviderCapabilities :: default ( )
945+ }
946+ }
947+ }
948+
949+ #[ tokio:: test]
950+ async fn run_tool_call_loop_dedups_duplicate_tool_names_before_provider_call ( ) {
951+ // Provider returns a single final text response — no tool calls —
952+ // so the loop terminates after exactly one `chat()` invocation,
953+ // and the captured tool list reflects what the fix is supposed to
954+ // guard against (no duplicate names reaching the wire).
955+ let provider = CapturingProvider {
956+ captured : Mutex :: new ( Vec :: new ( ) ) ,
957+ responses : Mutex :: new ( vec ! [ Ok ( ChatResponse {
958+ text: Some ( "done" . into( ) ) ,
959+ tool_calls: vec![ ] ,
960+ usage: None ,
961+ } ) ] ) ,
962+ // Native tool-calling on: only when the provider supports native
963+ // tools does `run_tool_call_loop` populate `ChatRequest.tools`.
964+ native_tools : true ,
965+ } ;
966+
967+ // Registry has `EchoTool` (name = "echo"). `extra_tools` adds a
968+ // second tool also named "echo" — the exact collision pattern from
969+ // the bug report (a synthesised delegation tool whose
970+ // `delegate_name` shadows a same-named skill tool).
971+ let registry: Vec < Box < dyn Tool > > = vec ! [ Box :: new( EchoTool ) ] ;
972+ let extra: Vec < Box < dyn Tool > > = vec ! [ Box :: new( EchoTool ) ] ;
973+
974+ let mut history = vec ! [ ChatMessage :: user( "hi" ) ] ;
975+ let result = run_tool_call_loop (
976+ & provider,
977+ & mut history,
978+ & registry,
979+ "test-provider" ,
980+ "model" ,
981+ 0.0 ,
982+ true ,
983+ None ,
984+ "channel" ,
985+ & crate :: openhuman:: config:: MultimodalConfig :: default ( ) ,
986+ 2 ,
987+ None ,
988+ None ,
989+ & extra,
990+ None ,
991+ None ,
992+ )
993+ . await
994+ . expect ( "loop should succeed with deduplicated tool list" ) ;
995+ assert_eq ! ( result, "done" ) ;
996+
997+ let captured = provider. captured . lock ( ) ;
998+ assert_eq ! (
999+ captured. len( ) ,
1000+ 1 ,
1001+ "exactly one chat() call expected for a final-only response"
1002+ ) ;
1003+ let names = captured[ 0 ]
1004+ . as_ref ( )
1005+ . expect ( "native_tools=true should populate ChatRequest.tools" ) ;
1006+ let echo_count = names. iter ( ) . filter ( |n| n. as_str ( ) == "echo" ) . count ( ) ;
1007+ assert_eq ! (
1008+ echo_count, 1 ,
1009+ "duplicate tool names must be dropped before the provider call \
1010+ (TAURI-RUST-4) — got names={:?}",
1011+ names
1012+ ) ;
1013+ }
0 commit comments