Skip to content

Commit a8d611d

Browse files
committed
test(replay): add fixture-driven session replay harness
Adds a tiny OpenAI-compatible fixture server and a replay test that drives a full AgentRuntime session through a tool call.
1 parent eed720a commit a8d611d

4 files changed

Lines changed: 204 additions & 0 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": "fs_write",
15+
"arguments": "{\"path\":\"hello.txt\",\"content\":\"hello\"}"
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: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
use std::collections::BTreeMap;
2+
3+
use rexos::config::{ProviderConfig, ProviderKind, RexosConfig, RouteConfig, RouterConfig};
4+
use rexos::paths::RexosPaths;
5+
use rexos::router::TaskKind;
6+
use rexos::security::SecurityConfig;
7+
8+
mod support;
9+
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();
18+
let paths = RexosPaths {
19+
base_dir: tmp.path().join(".loopforge"),
20+
};
21+
paths.ensure_dirs().unwrap();
22+
23+
let workspace_root = tmp.path().join("workspace");
24+
std::fs::create_dir_all(&workspace_root).unwrap();
25+
26+
let mut providers = BTreeMap::new();
27+
providers.insert(
28+
"fixture".to_string(),
29+
ProviderConfig {
30+
kind: ProviderKind::OpenAiCompatible,
31+
base_url: server.base_url.clone(),
32+
api_key_env: String::new(),
33+
default_model: "fixture-model".to_string(),
34+
aws_bedrock: None,
35+
},
36+
);
37+
38+
let security = SecurityConfig::default();
39+
let cfg = RexosConfig {
40+
llm: Default::default(),
41+
providers,
42+
router: RouterConfig {
43+
planning: RouteConfig {
44+
provider: "fixture".to_string(),
45+
model: "fixture-model".to_string(),
46+
},
47+
coding: RouteConfig {
48+
provider: "fixture".to_string(),
49+
model: "fixture-model".to_string(),
50+
},
51+
summary: RouteConfig {
52+
provider: "fixture".to_string(),
53+
model: "fixture-model".to_string(),
54+
},
55+
},
56+
security: security.clone(),
57+
};
58+
59+
let llms = rexos::llm::registry::LlmRegistry::from_config(&cfg).unwrap();
60+
let router = rexos::router::ModelRouter::new(cfg.router);
61+
let memory = rexos::memory::MemoryStore::open_or_create(&paths).unwrap();
62+
let agent =
63+
rexos::agent::AgentRuntime::new_with_security_config(memory, llms, router, security);
64+
65+
let session_id = "s-replay";
66+
agent
67+
.set_session_allowed_tools(session_id, vec!["fs_write".to_string()])
68+
.unwrap();
69+
70+
let out = agent
71+
.run_session(
72+
workspace_root.clone(),
73+
session_id,
74+
None,
75+
"write hello.txt",
76+
TaskKind::Coding,
77+
)
78+
.await
79+
.unwrap();
80+
81+
assert_eq!(out, "done");
82+
assert_eq!(
83+
std::fs::read_to_string(workspace_root.join("hello.txt")).unwrap(),
84+
"hello"
85+
);
86+
87+
let requests = server.requests.lock().unwrap().clone();
88+
assert_eq!(requests.len(), 2, "expected two chat completions calls");
89+
assert_eq!(requests[0]["messages"][0]["role"], "user");
90+
assert_eq!(requests[1]["messages"][2]["role"], "tool");
91+
assert_eq!(requests[1]["messages"][2]["name"], "fs_write");
92+
assert_eq!(requests[1]["messages"][2]["tool_call_id"], "call_1");
93+
assert_eq!(requests[1]["messages"][2]["content"], "ok");
94+
95+
server.abort();
96+
}

crates/rexos/tests/support/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pub mod openai_compat_fixture;
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
use std::collections::VecDeque;
2+
use std::sync::{Arc, Mutex};
3+
4+
use axum::extract::State;
5+
use axum::http::StatusCode;
6+
use axum::response::IntoResponse;
7+
use axum::routing::post;
8+
use axum::{Json, Router};
9+
use serde_json::{json, Value};
10+
11+
#[derive(Clone)]
12+
struct FixtureState {
13+
responses: Arc<Mutex<VecDeque<Value>>>,
14+
requests: Arc<Mutex<Vec<Value>>>,
15+
}
16+
17+
pub struct FixtureServer {
18+
pub base_url: String,
19+
pub requests: Arc<Mutex<Vec<Value>>>,
20+
handle: tokio::task::JoinHandle<()>,
21+
}
22+
23+
impl FixtureServer {
24+
pub async fn spawn(responses: Vec<Value>) -> Self {
25+
async fn handler(
26+
State(state): State<FixtureState>,
27+
Json(payload): Json<Value>,
28+
) -> impl IntoResponse {
29+
state.requests.lock().unwrap().push(payload);
30+
let next = state.responses.lock().unwrap().pop_front();
31+
match next {
32+
Some(value) => (StatusCode::OK, Json(value)).into_response(),
33+
None => (
34+
StatusCode::INTERNAL_SERVER_ERROR,
35+
Json(json!({ "error": "fixture exhausted" })),
36+
)
37+
.into_response(),
38+
}
39+
}
40+
41+
let state = FixtureState {
42+
responses: Arc::new(Mutex::new(responses.into())),
43+
requests: Arc::new(Mutex::new(Vec::new())),
44+
};
45+
46+
let app = Router::new()
47+
.route("/v1/chat/completions", post(handler))
48+
.with_state(state.clone());
49+
50+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
51+
let addr = listener.local_addr().unwrap();
52+
let handle = tokio::spawn(async move {
53+
axum::serve(listener, app).await.unwrap();
54+
});
55+
56+
Self {
57+
base_url: format!("http://{addr}/v1"),
58+
requests: state.requests,
59+
handle,
60+
}
61+
}
62+
63+
pub fn abort(self) {
64+
self.handle.abort();
65+
}
66+
}
67+
68+
pub fn load_json_array(raw: &str) -> Vec<Value> {
69+
serde_json::from_str(raw).expect("fixture JSON array")
70+
}

0 commit comments

Comments
 (0)