Skip to content

Commit 2750ae8

Browse files
committed
test(openai-compat): add request shape snapshots
1 parent 46c7a5c commit 2750ae8

1 file changed

Lines changed: 250 additions & 14 deletions

File tree

crates/rexos/tests/session_replay.rs

Lines changed: 250 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use rexos::config::{ProviderConfig, ProviderKind, RexosConfig, RouteConfig, Rout
44
use rexos::paths::RexosPaths;
55
use rexos::router::TaskKind;
66
use rexos::security::SecurityConfig;
7+
use serde_json::{json, Value};
78

89
mod 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]
65187
async 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

Comments
 (0)