1 //! End-to-end `sigit run` (headless mode) against the real binary.
2 //!
3 //! Spawns `sigit run` wired to a scripted OpenAI-compatible endpoint via the
4 //! `OPENAI_BASE_URL` override and asserts on the JSONL event stream. Headless
5 //! runs use non-streaming completions (no token sink), so the endpoint serves
6 //! plain JSON chat-completion bodies, not SSE.
7
8 use std::io::{BufRead, BufReader, Read, Write};
9 use std::net::TcpListener;
10 use std::path::Path;
11 use std::process::{Command, Stdio};
12 use std::sync::{Arc, Mutex};
13 use std::time::Duration;
14
15 use serde_json::{Value, json};
16
17 /// One scripted JSON completion body.
18 fn completion_text(text: &str) -> String {
19 json!({
20 "choices": [{"message": {"role": "assistant", "content": text}}]
21 })
22 .to_string()
23 }
24
25 fn completion_tool_call(id: &str, name: &str, arguments: &str) -> String {
26 json!({
27 "choices": [{"message": {
28 "role": "assistant",
29 "content": null,
30 "tool_calls": [{
31 "id": id,
32 "type": "function",
33 "function": {"name": name, "arguments": arguments},
34 }],
35 }}]
36 })
37 .to_string()
38 }
39
40 /// Serves one scripted JSON response per request and records request bodies.
41 struct FakeEndpoint {
42 port: u16,
43 requests: Arc<Mutex<Vec<Value>>>,
44 }
45
46 fn start_fake_endpoint(responses: Vec<String>) -> FakeEndpoint {
47 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
48 let port = listener.local_addr().unwrap().port();
49 let requests: Arc<Mutex<Vec<Value>>> = Arc::default();
50 let recorded = Arc::clone(&requests);
51 let queue = Mutex::new(std::collections::VecDeque::from(responses));
52
53 std::thread::spawn(move || {
54 for stream in listener.incoming() {
55 let Ok(mut stream) = stream else { continue };
56 let mut reader = BufReader::new(match stream.try_clone() {
57 Ok(clone) => clone,
58 Err(_) => continue,
59 });
60 let mut content_length = 0usize;
61 loop {
62 let mut line = String::new();
63 if reader.read_line(&mut line).unwrap_or(0) == 0 {
64 break;
65 }
66 let line = line.trim();
67 if line.is_empty() {
68 break;
69 }
70 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
71 content_length = length.trim().parse().unwrap_or(0);
72 }
73 }
74 let mut body = vec![0u8; content_length];
75 if reader.read_exact(&mut body).is_err() {
76 continue;
77 }
78 if let Ok(request) = serde_json::from_slice::<Value>(&body) {
79 recorded.lock().unwrap().push(request);
80 }
81 let payload = queue
82 .lock()
83 .unwrap()
84 .pop_front()
85 .unwrap_or_else(|| completion_text("out of scripted responses"));
86 let response = format!(
87 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
88 content-length: {}\r\nconnection: close\r\n\r\n{}",
89 payload.len(),
90 payload
91 );
92 let _ = stream.write_all(response.as_bytes());
93 }
94 });
95
96 FakeEndpoint { port, requests }
97 }
98
99 /// Run `sigit run` to completion against the endpoint; returns (exit code,
100 /// parsed JSONL events). A watchdog kills the child if it wedges.
101 fn run_headless(
102 endpoint: &FakeEndpoint,
103 workdir: &Path,
104 extra_env: &[(&str, &str)],
105 ) -> (i32, Vec<Value>) {
106 let config_dir = workdir.join("config");
107 std::fs::create_dir_all(&config_dir).unwrap();
108
109 let mut command = Command::new(env!("CARGO_BIN_EXE_sigit"));
110 command
111 .arg("run")
112 .arg("--prompt")
113 .arg("do the task")
114 .arg("--cwd")
115 .arg(workdir)
116 .arg("--output")
117 .arg("jsonl")
118 .env_remove("SIGIT_PERMISSIONS")
119 .env_remove("SIGIT_MODEL")
120 .env(
121 "OPENAI_BASE_URL",
122 format!("http://127.0.0.1:{}", endpoint.port),
123 )
124 .env("OPENAI_API_KEY", "test-key")
125 .env("SIGIT_MCP", "off")
126 .env("SIGIT_CONFIG_DIR", &config_dir)
127 .env("HOME", workdir)
128 .stdin(Stdio::null())
129 .stdout(Stdio::piped())
130 .stderr(Stdio::piped());
131 for (key, value) in extra_env {
132 command.env(key, value);
133 }
134
135 let child = command.spawn().expect("spawn sigit run");
136
137 // Watchdog: a wedged run must fail the test, not hang CI.
138 let pid = child.id();
139 let watchdog = std::thread::spawn(move || {
140 std::thread::sleep(Duration::from_secs(120));
141 // Best-effort; on the happy path the process is long gone.
142 #[cfg(unix)]
143 unsafe {
144 libc_kill(pid as i32);
145 }
146 let _ = pid;
147 });
148
149 let output = child.wait_with_output().expect("wait for sigit run");
150 drop(watchdog); // detached; happy path never joins it
151
152 let stdout = String::from_utf8_lossy(&output.stdout);
153 let stderr = String::from_utf8_lossy(&output.stderr);
154 let events: Vec<Value> = stdout
155 .lines()
156 .filter(|line| !line.trim().is_empty())
157 .map(|line| {
158 serde_json::from_str(line).unwrap_or_else(|error| {
159 panic!("non-JSONL stdout line {line:?}: {error}\nstderr: {stderr}")
160 })
161 })
162 .collect();
163 (output.status.code().unwrap_or(-1), events)
164 }
165
166 #[cfg(unix)]
167 unsafe fn libc_kill(pid: i32) {
168 unsafe extern "C" {
169 fn kill(pid: i32, sig: i32) -> i32;
170 }
171 unsafe {
172 kill(pid, 9);
173 }
174 }
175
176 fn event_types(events: &[Value]) -> Vec<&str> {
177 events
178 .iter()
179 .filter_map(|event| event["type"].as_str())
180 .collect()
181 }
182
183 #[test]
184 fn completes_a_tool_free_run() {
185 let endpoint = start_fake_endpoint(vec![completion_text("All done: nothing to change.")]);
186 let workdir = std::env::temp_dir().join(format!("sigit-headless-a-{}", std::process::id()));
187 std::fs::create_dir_all(&workdir).unwrap();
188
189 let (code, events) = run_headless(&endpoint, &workdir, &[]);
190
191 assert_eq!(code, 0, "events: {events:?}");
192 let types = event_types(&events);
193 assert_eq!(types.first(), Some(&"run_started"), "events: {events:?}");
194 assert!(types.contains(&"turn_text"), "events: {events:?}");
195
196 let result = events.last().expect("has a result line");
197 assert_eq!(result["type"], "result");
198 assert_eq!(result["status"], "completed");
199 assert_eq!(result["rounds"], 0);
200 assert_eq!(result["summary"], "All done: nothing to change.");
201
202 // The request carried the task and offered tools.
203 let requests = endpoint.requests.lock().unwrap();
204 let first = &requests[0];
205 assert_eq!(first["stream"], false);
206 assert!(
207 first["tools"]
208 .as_array()
209 .is_some_and(|tools| !tools.is_empty())
210 );
211 let messages = first["messages"].as_array().unwrap();
212 assert!(
213 messages
214 .iter()
215 .any(|m| m["role"] == "user" && m["content"] == "do the task")
216 );
217
218 std::fs::remove_dir_all(&workdir).ok();
219 }
220
221 #[test]
222 fn declines_mutating_tools_without_permission_override() {
223 // Round 1: the model asks to run a mutating tool. With SIGIT_PERMISSIONS
224 // unset the policy is `ask`, and headless mode cannot prompt — the call
225 // must be declined (denied: true) and the refusal fed back to the model.
226 let endpoint = start_fake_endpoint(vec![
227 completion_tool_call("call_1", "run_command", "{\"command\":\"echo hi\"}"),
228 completion_text("Understood, stopping."),
229 ]);
230 let workdir = std::env::temp_dir().join(format!("sigit-headless-b-{}", std::process::id()));
231 std::fs::create_dir_all(&workdir).unwrap();
232
233 let (code, events) = run_headless(&endpoint, &workdir, &[]);
234
235 assert_eq!(code, 0, "events: {events:?}");
236 let tool_result = events
237 .iter()
238 .find(|event| event["type"] == "tool_result")
239 .expect("tool_result event");
240 assert_eq!(tool_result["name"], "run_command");
241 assert_eq!(tool_result["denied"], true);
242 assert!(
243 tool_result["output"]
244 .as_str()
245 .unwrap()
246 .contains("SIGIT_PERMISSIONS=allow")
247 );
248
249 // The refusal went back as the tool result of round 1's call.
250 let requests = endpoint.requests.lock().unwrap();
251 let second = &requests[1];
252 let messages = second["messages"].as_array().unwrap();
253 assert!(messages.iter().any(|m| {
254 m["role"] == "tool"
255 && m["content"]
256 .as_str()
257 .is_some_and(|content| content.contains("was not executed"))
258 }));
259
260 let result = events.last().unwrap();
261 assert_eq!(result["status"], "completed");
262 assert_eq!(result["rounds"], 1);
263
264 std::fs::remove_dir_all(&workdir).ok();
265 }
266
267 #[test]
268 fn executes_allowed_tools_with_permission_override() {
269 let endpoint = start_fake_endpoint(vec![
270 completion_tool_call(
271 "call_1",
272 "run_command",
273 "{\"command\":\"echo headless-ok\"}",
274 ),
275 completion_text("Command ran."),
276 ]);
277 let workdir = std::env::temp_dir().join(format!("sigit-headless-c-{}", std::process::id()));
278 std::fs::create_dir_all(&workdir).unwrap();
279
280 let (code, events) = run_headless(&endpoint, &workdir, &[("SIGIT_PERMISSIONS", "allow")]);
281
282 assert_eq!(code, 0, "events: {events:?}");
283 let tool_result = events
284 .iter()
285 .find(|event| event["type"] == "tool_result")
286 .expect("tool_result event");
287 assert_eq!(tool_result["denied"], false);
288 assert!(
289 tool_result["output"]
290 .as_str()
291 .unwrap()
292 .contains("headless-ok"),
293 "tool output should carry the command's stdout: {tool_result}"
294 );
295
296 std::fs::remove_dir_all(&workdir).ok();
297 }