@@ -4,6 +4,7 @@ use rexos::config::{ProviderConfig, ProviderKind, RexosConfig, RouteConfig, Rout
44use rexos:: paths:: RexosPaths ;
55use rexos:: router:: TaskKind ;
66use rexos:: security:: SecurityConfig ;
7+ use serde_json:: { json, Value } ;
78
89mod support;
910
@@ -61,6 +62,127 @@ fn fixture_agent(
6162 ( agent, paths, workspace_root)
6263}
6364
65+ fn compact_request ( req : & Value ) -> Value {
66+ fn sorted_string_array ( value : & Value ) -> Vec < String > {
67+ let mut out: Vec < String > = value
68+ . as_array ( )
69+ . into_iter ( )
70+ . flatten ( )
71+ . filter_map ( |v| v. as_str ( ) . map ( |s| s. to_string ( ) ) )
72+ . collect ( ) ;
73+ out. sort ( ) ;
74+ out
75+ }
76+
77+ fn sorted_object_keys ( value : & Value ) -> Vec < String > {
78+ let mut out: Vec < String > = value
79+ . as_object ( )
80+ . into_iter ( )
81+ . flatten ( )
82+ . map ( |( k, _) | k. to_string ( ) )
83+ . collect ( ) ;
84+ out. sort ( ) ;
85+ out
86+ }
87+
88+ fn tool_schema_snapshot ( tool : & Value ) -> Value {
89+ let name = tool
90+ . get ( "function" )
91+ . and_then ( |f| f. get ( "name" ) )
92+ . and_then ( |v| v. as_str ( ) )
93+ . unwrap_or ( "<missing>" ) ;
94+ let params = tool
95+ . get ( "function" )
96+ . and_then ( |f| f. get ( "parameters" ) )
97+ . unwrap_or ( & Value :: Null ) ;
98+
99+ json ! ( {
100+ "name" : name,
101+ "type" : tool. get( "type" ) . and_then( |v| v. as_str( ) ) . unwrap_or( "<missing>" ) ,
102+ "param_type" : params. get( "type" ) . and_then( |v| v. as_str( ) ) . unwrap_or( "<missing>" ) ,
103+ "required" : sorted_string_array( params. get( "required" ) . unwrap_or( & Value :: Null ) ) ,
104+ "properties" : sorted_object_keys( params. get( "properties" ) . unwrap_or( & Value :: Null ) ) ,
105+ "additional_properties" : params. get( "additionalProperties" ) . cloned( ) . unwrap_or( Value :: Null ) ,
106+ } )
107+ }
108+
109+ let tools: Vec < Value > = req
110+ . get ( "tools" )
111+ . and_then ( |v| v. as_array ( ) )
112+ . into_iter ( )
113+ . flatten ( )
114+ . map ( tool_schema_snapshot)
115+ . collect ( ) ;
116+ let mut tools = tools;
117+ tools. sort_by ( |a, b| {
118+ a[ "name" ]
119+ . as_str ( )
120+ . unwrap_or ( "" )
121+ . cmp ( b[ "name" ] . as_str ( ) . unwrap_or ( "" ) )
122+ } ) ;
123+
124+ let messages: Vec < & Value > = req
125+ . get ( "messages" )
126+ . and_then ( |v| v. as_array ( ) )
127+ . into_iter ( )
128+ . flatten ( )
129+ . collect ( ) ;
130+
131+ let message_roles: Vec < String > = messages
132+ . iter ( )
133+ . filter_map ( |m| {
134+ m. get ( "role" )
135+ . and_then ( |r| r. as_str ( ) )
136+ . map ( |s| s. to_string ( ) )
137+ } )
138+ . collect ( ) ;
139+
140+ let mut assistant_tool_calls: Vec < Value > = Vec :: new ( ) ;
141+ let mut tool_messages: Vec < Value > = Vec :: new ( ) ;
142+
143+ for msg in messages {
144+ let role = msg. get ( "role" ) . and_then ( |r| r. as_str ( ) ) . unwrap_or ( "" ) ;
145+ if role == "assistant" {
146+ let calls = msg. get ( "tool_calls" ) . and_then ( |v| v. as_array ( ) ) ;
147+ for call in calls. into_iter ( ) . flatten ( ) {
148+ let args_raw = call
149+ . get ( "function" )
150+ . and_then ( |f| f. get ( "arguments" ) )
151+ . and_then ( |v| v. as_str ( ) )
152+ . unwrap_or ( "" ) ;
153+ let args = serde_json:: from_str :: < Value > ( args_raw)
154+ . unwrap_or_else ( |_| Value :: String ( args_raw. to_string ( ) ) ) ;
155+
156+ assistant_tool_calls. push ( json ! ( {
157+ "id" : call. get( "id" ) . cloned( ) . unwrap_or( Value :: Null ) ,
158+ "name" : call. get( "function" ) . and_then( |f| f. get( "name" ) ) . cloned( ) . unwrap_or( Value :: Null ) ,
159+ "arguments" : args,
160+ } ) ) ;
161+ }
162+ }
163+
164+ if role == "tool" {
165+ let content_raw = msg. get ( "content" ) . and_then ( |v| v. as_str ( ) ) . unwrap_or ( "" ) ;
166+ let content = serde_json:: from_str :: < Value > ( content_raw)
167+ . unwrap_or_else ( |_| Value :: String ( content_raw. to_string ( ) ) ) ;
168+ tool_messages. push ( json ! ( {
169+ "name" : msg. get( "name" ) . cloned( ) . unwrap_or( Value :: Null ) ,
170+ "tool_call_id" : msg. get( "tool_call_id" ) . cloned( ) . unwrap_or( Value :: Null ) ,
171+ "content" : content,
172+ } ) ) ;
173+ }
174+ }
175+
176+ json ! ( {
177+ "model" : req. get( "model" ) . cloned( ) . unwrap_or( Value :: Null ) ,
178+ "temperature" : req. get( "temperature" ) . and_then( |v| v. as_f64( ) ) ,
179+ "tools" : tools,
180+ "message_roles" : message_roles,
181+ "assistant_tool_calls" : assistant_tool_calls,
182+ "tool_messages" : tool_messages,
183+ } )
184+ }
185+
64186#[ tokio:: test]
65187async fn replay_fixture_drives_session_and_tool_calls ( ) {
66188 let fixture = support:: openai_compat_fixture:: load_json_array ( include_str ! (
@@ -99,11 +221,50 @@ async fn replay_fixture_drives_session_and_tool_calls() {
99221
100222 let requests = server. requests . lock ( ) . unwrap ( ) . clone ( ) ;
101223 assert_eq ! ( requests. len( ) , 2 , "expected two chat completions calls" ) ;
102- assert_eq ! ( requests[ 0 ] [ "messages" ] [ 0 ] [ "role" ] , "user" ) ;
103- assert_eq ! ( requests[ 1 ] [ "messages" ] [ 2 ] [ "role" ] , "tool" ) ;
104- assert_eq ! ( requests[ 1 ] [ "messages" ] [ 2 ] [ "name" ] , "fs_write" ) ;
105- assert_eq ! ( requests[ 1 ] [ "messages" ] [ 2 ] [ "tool_call_id" ] , "call_1" ) ;
106- assert_eq ! ( requests[ 1 ] [ "messages" ] [ 2 ] [ "content" ] , "ok" ) ;
224+ assert_eq ! (
225+ compact_request( & requests[ 0 ] ) ,
226+ json!( {
227+ "model" : "fixture-model" ,
228+ "temperature" : 0.0 ,
229+ "tools" : [ {
230+ "name" : "fs_write" ,
231+ "type" : "function" ,
232+ "param_type" : "object" ,
233+ "required" : [ "content" , "path" ] ,
234+ "properties" : [ "content" , "path" ] ,
235+ "additional_properties" : false ,
236+ } ] ,
237+ "message_roles" : [ "user" ] ,
238+ "assistant_tool_calls" : [ ] ,
239+ "tool_messages" : [ ] ,
240+ } )
241+ ) ;
242+ assert_eq ! (
243+ compact_request( & requests[ 1 ] ) ,
244+ json!( {
245+ "model" : "fixture-model" ,
246+ "temperature" : 0.0 ,
247+ "tools" : [ {
248+ "name" : "fs_write" ,
249+ "type" : "function" ,
250+ "param_type" : "object" ,
251+ "required" : [ "content" , "path" ] ,
252+ "properties" : [ "content" , "path" ] ,
253+ "additional_properties" : false ,
254+ } ] ,
255+ "message_roles" : [ "user" , "assistant" , "tool" ] ,
256+ "assistant_tool_calls" : [ {
257+ "id" : "call_1" ,
258+ "name" : "fs_write" ,
259+ "arguments" : { "path" : "hello.txt" , "content" : "hello" } ,
260+ } ] ,
261+ "tool_messages" : [ {
262+ "name" : "fs_write" ,
263+ "tool_call_id" : "call_1" ,
264+ "content" : "ok" ,
265+ } ] ,
266+ } )
267+ ) ;
107268
108269 server. abort ( ) ;
109270}
@@ -145,6 +306,24 @@ async fn replay_fixture_blocks_tool_not_in_allowed_tools() {
145306
146307 let requests = server. requests . lock ( ) . unwrap ( ) . clone ( ) ;
147308 assert_eq ! ( requests. len( ) , 1 , "expected one chat completions call" ) ;
309+ assert_eq ! (
310+ compact_request( & requests[ 0 ] ) ,
311+ json!( {
312+ "model" : "fixture-model" ,
313+ "temperature" : 0.0 ,
314+ "tools" : [ {
315+ "name" : "fs_read" ,
316+ "type" : "function" ,
317+ "param_type" : "object" ,
318+ "required" : [ "path" ] ,
319+ "properties" : [ "path" ] ,
320+ "additional_properties" : false ,
321+ } ] ,
322+ "message_roles" : [ "user" ] ,
323+ "assistant_tool_calls" : [ ] ,
324+ "tool_messages" : [ ] ,
325+ } )
326+ ) ;
148327
149328 server. abort ( ) ;
150329}
@@ -183,9 +362,31 @@ async fn replay_fixture_surfaces_tool_failure_errors() {
183362 err_text. contains( "parent traversal" ) ,
184363 "expected relative-path validation error, got: {err_text}"
185364 ) ;
365+ assert ! (
366+ err_text. contains( "fs_write" ) ,
367+ "expected tool name in error, got: {err_text}"
368+ ) ;
186369
187370 let requests = server. requests . lock ( ) . unwrap ( ) . clone ( ) ;
188371 assert_eq ! ( requests. len( ) , 1 , "expected one chat completions call" ) ;
372+ assert_eq ! (
373+ compact_request( & requests[ 0 ] ) ,
374+ json!( {
375+ "model" : "fixture-model" ,
376+ "temperature" : 0.0 ,
377+ "tools" : [ {
378+ "name" : "fs_write" ,
379+ "type" : "function" ,
380+ "param_type" : "object" ,
381+ "required" : [ "content" , "path" ] ,
382+ "properties" : [ "content" , "path" ] ,
383+ "additional_properties" : false ,
384+ } ] ,
385+ "message_roles" : [ "user" ] ,
386+ "assistant_tool_calls" : [ ] ,
387+ "tool_messages" : [ ] ,
388+ } )
389+ ) ;
189390
190391 server. abort ( ) ;
191392}
@@ -228,15 +429,50 @@ async fn replay_fixture_executes_mcp_tool_calls() {
228429
229430 let requests = server. requests . lock ( ) . unwrap ( ) . clone ( ) ;
230431 assert_eq ! ( requests. len( ) , 2 , "expected two chat completions calls" ) ;
231- assert_eq ! ( requests[ 1 ] [ "messages" ] [ 2 ] [ "role" ] , "tool" ) ;
232- assert_eq ! ( requests[ 1 ] [ "messages" ] [ 2 ] [ "name" ] , "mcp_stub__echo" ) ;
233- assert_eq ! ( requests[ 1 ] [ "messages" ] [ 2 ] [ "tool_call_id" ] , "call_1" ) ;
234-
235- let tool_content = requests[ 1 ] [ "messages" ] [ 2 ] [ "content" ]
236- . as_str ( )
237- . expect ( "tool message content" ) ;
238- let tool_json: serde_json:: Value = serde_json:: from_str ( tool_content) . expect ( "tool JSON" ) ;
239- assert_eq ! ( tool_json[ "content" ] [ 0 ] [ "text" ] , "yo" ) ;
432+ assert_eq ! (
433+ compact_request( & requests[ 0 ] ) ,
434+ json!( {
435+ "model" : "fixture-model" ,
436+ "temperature" : 0.0 ,
437+ "tools" : [ {
438+ "name" : "mcp_stub__echo" ,
439+ "type" : "function" ,
440+ "param_type" : "object" ,
441+ "required" : [ "text" ] ,
442+ "properties" : [ "text" ] ,
443+ "additional_properties" : false ,
444+ } ] ,
445+ "message_roles" : [ "user" ] ,
446+ "assistant_tool_calls" : [ ] ,
447+ "tool_messages" : [ ] ,
448+ } )
449+ ) ;
450+ assert_eq ! (
451+ compact_request( & requests[ 1 ] ) ,
452+ json!( {
453+ "model" : "fixture-model" ,
454+ "temperature" : 0.0 ,
455+ "tools" : [ {
456+ "name" : "mcp_stub__echo" ,
457+ "type" : "function" ,
458+ "param_type" : "object" ,
459+ "required" : [ "text" ] ,
460+ "properties" : [ "text" ] ,
461+ "additional_properties" : false ,
462+ } ] ,
463+ "message_roles" : [ "user" , "assistant" , "tool" ] ,
464+ "assistant_tool_calls" : [ {
465+ "id" : "call_1" ,
466+ "name" : "mcp_stub__echo" ,
467+ "arguments" : { "text" : "yo" } ,
468+ } ] ,
469+ "tool_messages" : [ {
470+ "name" : "mcp_stub__echo" ,
471+ "tool_call_id" : "call_1" ,
472+ "content" : { "content" : [ { "type" : "text" , "text" : "yo" } ] } ,
473+ } ] ,
474+ } )
475+ ) ;
240476
241477 server. abort ( ) ;
242478}
0 commit comments