use super::{App, TurnState};
use crate::chat::openai_compat::OpenAiCompatProvider;
use crate::chat::provider::ChatProvider;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
struct CwdGuard {
original: PathBuf,
}
impl CwdGuard {
fn enter(new_dir: &std::path::Path) -> Self {
let original = std::env::current_dir().expect("read current dir");
std::env::set_current_dir(new_dir).expect("set current dir to temp repo");
Self { original }
}
}
impl Drop for CwdGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.original);
}
}
fn read_request_method(stream: &TcpStream) -> String {
let mut reader = BufReader::new(stream);
let mut request_line = String::new();
reader
.read_line(&mut request_line)
.expect("read request line");
let method = request_line
.split_whitespace()
.next()
.unwrap_or("")
.to_string();
let mut content_length = 0usize;
loop {
let mut line = String::new();
reader.read_line(&mut line).expect("read header line");
if line == "\r\n" || line.is_empty() {
break;
}
if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
content_length = value.trim().parse().unwrap_or(0);
}
}
let mut body = vec![0u8; content_length];
if content_length > 0 {
reader.read_exact(&mut body).expect("read request body");
}
method
}
fn respond_models_list(mut stream: TcpStream) {
let body = serde_json::json!({"data": [{"id": "golden-mock-model"}]}).to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.expect("write models-list response");
stream.flush().ok();
}
fn respond_sse(mut stream: TcpStream, sse_body: &str) {
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n{sse_body}"
)
.expect("write SSE response");
stream.flush().ok();
}
fn sse_event(json: &serde_json::Value) -> String {
format!("data: {json}\n\n")
}
fn tool_call_sse_body() -> String {
[
sse_event(&serde_json::json!({
"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": null}]
})),
sse_event(&serde_json::json!({
"choices": [{"index": 0, "delta": {"tool_calls": [{
"index": 0, "id": "call_golden_1", "type": "function",
"function": {"name": "read_file", "arguments": ""}
}]}, "finish_reason": null}]
})),
sse_event(&serde_json::json!({
"choices": [{"index": 0, "delta": {"tool_calls": [{
"index": 0, "function": {"arguments": "{\"path\": \""}
}]}, "finish_reason": null}]
})),
sse_event(&serde_json::json!({
"choices": [{"index": 0, "delta": {"tool_calls": [{
"index": 0, "function": {"arguments": "golden.txt\"}"}
}]}, "finish_reason": null}]
})),
sse_event(&serde_json::json!({
"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
"usage": {"prompt_tokens": 42, "completion_tokens": 7}
})),
"data: [DONE]\n\n".to_string(),
]
.concat()
}
fn final_answer_sse_body(expected_final_answer: &str) -> String {
[
sse_event(&serde_json::json!({
"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": null}]
})),
sse_event(&serde_json::json!({
"choices": [{"index": 0, "delta": {"content": expected_final_answer}, "finish_reason": null}]
})),
sse_event(&serde_json::json!({
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 58, "completion_tokens": 5}
})),
"data: [DONE]\n\n".to_string(),
]
.concat()
}
fn spawn_mock_local_provider(
expected_final_answer: &'static str,
) -> (u16, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock provider listener");
let port = listener.local_addr().expect("local addr").port();
let handle = std::thread::spawn(move || {
let mut chat_posts_served = 0u32;
for incoming in listener.incoming() {
let stream = incoming.expect("accept connection");
let method = read_request_method(&stream);
if method != "POST" {
respond_models_list(stream);
continue;
}
chat_posts_served += 1;
if chat_posts_served == 1 {
respond_sse(stream, &tool_call_sse_body());
} else {
respond_sse(stream, &final_answer_sse_body(expected_final_answer));
break;
}
}
});
(port, handle)
}
fn mock_provider(port: u16) -> OpenAiCompatProvider {
let url: &'static str =
Box::leak(format!("http://127.0.0.1:{port}/v1/chat/completions").into_boxed_str());
OpenAiCompatProvider {
provider_name: "golden-e2e-mock",
url,
default_model: "golden-mock-model",
keyless: true,
env_var: "",
}
}
fn pump_until_idle(app: &mut App, timeout: Duration) {
let deadline = Instant::now() + timeout;
loop {
super::drain_stream_events(app);
if matches!(app.turn, TurnState::Idle) {
return;
}
if Instant::now() > deadline {
panic!(
"turn did not reach Idle within {timeout:?} — status: {}",
app.status
);
}
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn golden_e2e_local_model_tool_call_round_trip() {
let repo = std::env::temp_dir().join(format!("yana-golden-e2e-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&repo).expect("create temp repo");
let file_content = "golden e2e content, read for real";
std::fs::write(repo.join("golden.txt"), file_content).expect("write golden.txt");
let _cwd_guard = CwdGuard::enter(&repo);
let expected_final_answer = "The file contains: golden e2e content, read for real";
let (port, server_handle) = spawn_mock_local_provider(expected_final_answer);
let provider: Arc<dyn ChatProvider> = Arc::new(mock_provider(port));
let session_id = format!("golden-e2e-{}", uuid::Uuid::new_v4());
let initial_history = vec![crate::chat::provider::ChatMessage::text(
crate::chat::provider::Role::User,
"What's in golden.txt?",
)];
let mut app = App::new(
Arc::clone(&provider),
"golden-mock-model".to_string(),
None,
None,
session_id.clone(),
initial_history,
false, true, false, );
app.spawn_turn();
pump_until_idle(&mut app, Duration::from_secs(10));
server_handle
.join()
.expect("mock provider server thread panicked");
let history = &app.history;
let tool_call = history
.iter()
.find_map(|m| m.tool_call.as_ref())
.expect("history must contain the model's tool_call turn");
assert_eq!(tool_call.name, "read_file");
assert_eq!(tool_call.arguments_json, r#"{"path": "golden.txt"}"#);
let tool_result = history
.iter()
.find_map(|m| m.tool_result.as_ref())
.expect("history must contain the tool_result turn");
assert!(
!tool_result.is_error,
"read_file must succeed against a real file"
);
assert_eq!(
tool_result.output, file_content,
"tool result must be the real bytes capability::read_file_observation read from disk, not a stub"
);
let final_answer = history
.iter()
.rev()
.find(|m| m.tool_call.is_none() && m.tool_result.is_none() && !m.content.is_empty())
.expect("history must contain a final plain-text assistant turn");
assert_eq!(final_answer.content, expected_final_answer);
let restored = crate::chat::history::load(&session_id).expect("reload session from disk");
assert!(
restored
.iter()
.any(|m| m.tool_call.as_ref().map(|c| c.name.as_str()) == Some("read_file")),
"persisted history must contain the tool_call turn"
);
assert!(
restored
.iter()
.any(|m| m.tool_result.as_ref().map(|r| r.output.as_str()) == Some(file_content)),
"persisted history must contain the real tool_result content"
);
assert!(
restored.iter().any(|m| m.content == expected_final_answer),
"persisted history must contain the final answer"
);
std::fs::remove_dir_all(&repo).ok();
}