Skip to content

Commit 023f5d2

Browse files
committed
test(replay): expand fixtures for deny, failure, and MCP
1 parent cd3df13 commit 023f5d2

6 files changed

Lines changed: 336 additions & 10 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
[
2+
{
3+
"choices": [
4+
{
5+
"index": 0,
6+
"message": {
7+
"role": "assistant",
8+
"content": null,
9+
"tool_calls": [
10+
{
11+
"id": "call_1",
12+
"type": "function",
13+
"function": {
14+
"name": "mcp_stub__echo",
15+
"arguments": "{\"text\":\"yo\"}"
16+
}
17+
}
18+
]
19+
},
20+
"finish_reason": "tool_calls"
21+
}
22+
]
23+
},
24+
{
25+
"choices": [
26+
{
27+
"index": 0,
28+
"message": {
29+
"role": "assistant",
30+
"content": "done"
31+
},
32+
"finish_reason": "stop"
33+
}
34+
]
35+
}
36+
]
37+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
[
2+
{
3+
"choices": [
4+
{
5+
"index": 0,
6+
"message": {
7+
"role": "assistant",
8+
"content": null,
9+
"tool_calls": [
10+
{
11+
"id": "call_1",
12+
"type": "function",
13+
"function": {
14+
"name": "fs_write",
15+
"arguments": "{\"path\":\"../hello.txt\",\"content\":\"hello\"}"
16+
}
17+
}
18+
]
19+
},
20+
"finish_reason": "tool_calls"
21+
}
22+
]
23+
}
24+
]
25+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
[
2+
{
3+
"choices": [
4+
{
5+
"index": 0,
6+
"message": {
7+
"role": "assistant",
8+
"content": null,
9+
"tool_calls": [
10+
{
11+
"id": "call_1",
12+
"type": "function",
13+
"function": {
14+
"name": "fs_write",
15+
"arguments": "{\"path\":\"hello.txt\",\"content\":\"hello\"}"
16+
}
17+
}
18+
]
19+
},
20+
"finish_reason": "tool_calls"
21+
}
22+
]
23+
}
24+
]
25+

crates/rexos/tests/session_replay.rs

Lines changed: 156 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,11 @@ use rexos::security::SecurityConfig;
77

88
mod support;
99

10-
#[tokio::test]
11-
async fn replay_fixture_drives_session_and_tool_calls() {
12-
let fixture = support::openai_compat_fixture::load_json_array(include_str!(
13-
"fixtures/replay/session_write_file.json"
14-
));
15-
let server = support::openai_compat_fixture::FixtureServer::spawn(fixture).await;
16-
17-
let tmp = tempfile::tempdir().unwrap();
10+
fn fixture_agent(
11+
tmp: &tempfile::TempDir,
12+
fixture_base_url: String,
13+
security: SecurityConfig,
14+
) -> (rexos::agent::AgentRuntime, RexosPaths, std::path::PathBuf) {
1815
let paths = RexosPaths {
1916
base_dir: tmp.path().join(".loopforge"),
2017
};
@@ -28,14 +25,13 @@ async fn replay_fixture_drives_session_and_tool_calls() {
2825
"fixture".to_string(),
2926
ProviderConfig {
3027
kind: ProviderKind::OpenAiCompatible,
31-
base_url: server.base_url.clone(),
28+
base_url: fixture_base_url,
3229
api_key_env: String::new(),
3330
default_model: "fixture-model".to_string(),
3431
aws_bedrock: None,
3532
},
3633
);
3734

38-
let security = SecurityConfig::default();
3935
let cfg = RexosConfig {
4036
llm: Default::default(),
4137
providers,
@@ -62,6 +58,23 @@ async fn replay_fixture_drives_session_and_tool_calls() {
6258
let agent =
6359
rexos::agent::AgentRuntime::new_with_security_config(memory, llms, router, security);
6460

61+
(agent, paths, workspace_root)
62+
}
63+
64+
#[tokio::test]
65+
async fn replay_fixture_drives_session_and_tool_calls() {
66+
let fixture = support::openai_compat_fixture::load_json_array(include_str!(
67+
"fixtures/replay/session_write_file.json"
68+
));
69+
let server = support::openai_compat_fixture::FixtureServer::spawn(fixture).await;
70+
71+
let tmp = tempfile::tempdir().unwrap();
72+
let (agent, _paths, workspace_root) = fixture_agent(
73+
&tmp,
74+
server.base_url.clone(),
75+
rexos::security::SecurityConfig::default(),
76+
);
77+
6578
let session_id = "s-replay";
6679
agent
6780
.set_session_allowed_tools(session_id, vec!["fs_write".to_string()])
@@ -94,3 +107,136 @@ async fn replay_fixture_drives_session_and_tool_calls() {
94107

95108
server.abort();
96109
}
110+
111+
#[tokio::test]
112+
async fn replay_fixture_blocks_tool_not_in_allowed_tools() {
113+
let fixture = support::openai_compat_fixture::load_json_array(include_str!(
114+
"fixtures/replay/session_tool_not_allowed.json"
115+
));
116+
let server = support::openai_compat_fixture::FixtureServer::spawn(fixture).await;
117+
118+
let tmp = tempfile::tempdir().unwrap();
119+
let (agent, _paths, workspace_root) = fixture_agent(
120+
&tmp,
121+
server.base_url.clone(),
122+
rexos::security::SecurityConfig::default(),
123+
);
124+
125+
let session_id = "s-replay-deny";
126+
agent
127+
.set_session_allowed_tools(session_id, vec!["fs_read".to_string()])
128+
.unwrap();
129+
130+
let err = agent
131+
.run_session(
132+
workspace_root,
133+
session_id,
134+
None,
135+
"try write",
136+
TaskKind::Coding,
137+
)
138+
.await
139+
.unwrap_err();
140+
let err_text = err.to_string();
141+
assert!(
142+
err_text.contains("tool not allowed"),
143+
"expected deny error, got: {err_text}"
144+
);
145+
146+
let requests = server.requests.lock().unwrap().clone();
147+
assert_eq!(requests.len(), 1, "expected one chat completions call");
148+
149+
server.abort();
150+
}
151+
152+
#[tokio::test]
153+
async fn replay_fixture_surfaces_tool_failure_errors() {
154+
let fixture = support::openai_compat_fixture::load_json_array(include_str!(
155+
"fixtures/replay/session_tool_failed_invalid_path.json"
156+
));
157+
let server = support::openai_compat_fixture::FixtureServer::spawn(fixture).await;
158+
159+
let tmp = tempfile::tempdir().unwrap();
160+
let (agent, _paths, workspace_root) = fixture_agent(
161+
&tmp,
162+
server.base_url.clone(),
163+
rexos::security::SecurityConfig::default(),
164+
);
165+
166+
let session_id = "s-replay-tool-failed";
167+
agent
168+
.set_session_allowed_tools(session_id, vec!["fs_write".to_string()])
169+
.unwrap();
170+
171+
let err = agent
172+
.run_session(
173+
workspace_root,
174+
session_id,
175+
None,
176+
"write outside workspace",
177+
TaskKind::Coding,
178+
)
179+
.await
180+
.unwrap_err();
181+
let err_text = err.to_string();
182+
assert!(
183+
err_text.contains("parent traversal"),
184+
"expected relative-path validation error, got: {err_text}"
185+
);
186+
187+
let requests = server.requests.lock().unwrap().clone();
188+
assert_eq!(requests.len(), 1, "expected one chat completions call");
189+
190+
server.abort();
191+
}
192+
193+
#[tokio::test]
194+
async fn replay_fixture_executes_mcp_tool_calls() {
195+
let fixture = support::openai_compat_fixture::load_json_array(include_str!(
196+
"fixtures/replay/session_mcp_echo.json"
197+
));
198+
let server = support::openai_compat_fixture::FixtureServer::spawn(fixture).await;
199+
200+
let tmp = tempfile::tempdir().unwrap();
201+
let (agent, _paths, workspace_root) = fixture_agent(
202+
&tmp,
203+
server.base_url.clone(),
204+
rexos::security::SecurityConfig::default(),
205+
);
206+
207+
let mcp_stub = support::mcp_stub::write_mcp_stub(&workspace_root);
208+
209+
let session_id = "s-replay-mcp";
210+
agent
211+
.set_session_mcp_config(session_id, support::mcp_stub::mcp_config_json(&mcp_stub))
212+
.unwrap();
213+
agent
214+
.set_session_allowed_tools(session_id, vec!["mcp_stub__echo".to_string()])
215+
.unwrap();
216+
217+
let out = agent
218+
.run_session(
219+
workspace_root,
220+
session_id,
221+
None,
222+
"call mcp",
223+
TaskKind::Coding,
224+
)
225+
.await
226+
.unwrap();
227+
assert_eq!(out, "done");
228+
229+
let requests = server.requests.lock().unwrap().clone();
230+
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");
240+
241+
server.abort();
242+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
use std::path::{Path, PathBuf};
2+
3+
const MCP_STUB_PY: &str = r#"
4+
import json
5+
import sys
6+
7+
def send(obj):
8+
sys.stdout.write(json.dumps(obj) + "\n")
9+
sys.stdout.flush()
10+
11+
for line in sys.stdin:
12+
line = line.strip()
13+
if not line:
14+
continue
15+
try:
16+
msg = json.loads(line)
17+
except Exception:
18+
continue
19+
20+
method = msg.get("method")
21+
if not method:
22+
continue
23+
24+
# Notifications have no id; ignore them.
25+
if "id" not in msg:
26+
continue
27+
28+
msg_id = msg.get("id")
29+
params = msg.get("params") or {}
30+
31+
if method == "initialize":
32+
send({"jsonrpc": "2.0", "id": msg_id, "result": {}})
33+
elif method == "tools/list":
34+
send({
35+
"jsonrpc": "2.0",
36+
"id": msg_id,
37+
"result": {
38+
"tools": [
39+
{
40+
"name": "echo",
41+
"description": "Echo input text",
42+
"inputSchema": {
43+
"type": "object",
44+
"properties": {"text": {"type": "string"}},
45+
"required": ["text"],
46+
"additionalProperties": False
47+
}
48+
}
49+
]
50+
}
51+
})
52+
elif method == "tools/call":
53+
name = params.get("name")
54+
arguments = params.get("arguments") or {}
55+
if name == "echo":
56+
send({
57+
"jsonrpc": "2.0",
58+
"id": msg_id,
59+
"result": {"content": [{"type": "text", "text": arguments.get("text", "")}]}
60+
})
61+
else:
62+
send({"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32601, "message": "unknown tool"}})
63+
else:
64+
send({"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32601, "message": "unknown method"}})
65+
"#;
66+
67+
fn mcp_python_exe() -> &'static str {
68+
if cfg!(windows) {
69+
"python"
70+
} else {
71+
"python3"
72+
}
73+
}
74+
75+
pub fn write_mcp_stub(workspace: &Path) -> PathBuf {
76+
let path = workspace.join("mcp_stub.py");
77+
std::fs::write(&path, MCP_STUB_PY).expect("write mcp stub script");
78+
path
79+
}
80+
81+
pub fn mcp_config_json(script: &Path) -> String {
82+
serde_json::json!({
83+
"servers": {
84+
"stub": {
85+
"command": mcp_python_exe(),
86+
"args": ["-u", script.to_string_lossy()],
87+
"cwd": ".",
88+
}
89+
}
90+
})
91+
.to_string()
92+
}

crates/rexos/tests/support/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
pub mod mcp_stub;
12
pub mod openai_compat_fixture;

0 commit comments

Comments
 (0)