1 //! End-to-end headless (`sigit run`, with legacy `sigit -p`) runs against the real binary.
2 //!
3 //! Same harness idea as `tests/acp_permissions.rs`: a scripted OpenAI-compatible
4 //! SSE endpoint stands in for the model via the `OPENAI_BASE_URL` override, and
5 //! the BUILT binary is driven like CI would drive it — one headless invocation, then
6 //! assertions on the exit code, stdout, and what the endpoint saw.
7 //!
8 //! 1. With `--allow-tool run_command`, the scripted tool call executes and its
9 //! output travels back to the endpoint as a `role: "tool"` result.
10 //! 2. Without `--allow-tool`, the ask-level tool is denied, the denial is fed
11 //! to the model, and the run still exits 0 (the turn completed).
12 //! 3. An unknown flag is a usage error: exit 2, nothing hits the endpoint.
13
14 use std::collections::VecDeque;
15 use std::io::{BufRead, BufReader, Read, Write};
16 use std::net::TcpListener;
17 use std::process::{Command, Output, Stdio};
18 use std::sync::{Arc, Mutex};
19
20 use serde_json::{Value, json};
21
22 // ── Scripted OpenAI-compatible endpoint ─────────────────────────────────────
23
24 fn sse_body(events: &[Value]) -> String {
25 let mut body = String::new();
26 for event in events {
27 body.push_str("data: ");
28 body.push_str(&event.to_string());
29 body.push_str("\n\n");
30 }
31 body.push_str("data: [DONE]\n\n");
32 body
33 }
34
35 fn sse_tool_call(id: &str, name: &str, arguments: &str) -> String {
36 sse_body(&[json!({
37 "choices": [{"delta": {"tool_calls": [{
38 "index": 0,
39 "id": id,
40 "function": {"name": name, "arguments": arguments},
41 }]}}]
42 })])
43 }
44
45 fn sse_text(text: &str) -> String {
46 sse_body(&[json!({"choices": [{"delta": {"content": text}}]})])
47 }
48
49 /// One round that says something and then asks for a tool — the shape that used
50 /// to leave the next round's text glued to this sentence.
51 fn sse_text_then_tool_call(text: &str, id: &str, name: &str, arguments: &str) -> String {
52 sse_body(&[
53 json!({"choices": [{"delta": {"content": text}}]}),
54 json!({
55 "choices": [{"delta": {"tool_calls": [{
56 "index": 0,
57 "id": id,
58 "function": {"name": name, "arguments": arguments},
59 }]}}]
60 }),
61 ])
62 }
63
64 /// Serves one scripted SSE response per request and records each request body.
65 struct FakeEndpoint {
66 port: u16,
67 requests: Arc<Mutex<Vec<Value>>>,
68 }
69
70 fn start_fake_endpoint(responses: Vec<String>) -> FakeEndpoint {
71 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
72 let port = listener.local_addr().unwrap().port();
73 let requests: Arc<Mutex<Vec<Value>>> = Arc::default();
74 let recorded = Arc::clone(&requests);
75 let queue = Mutex::new(VecDeque::from(responses));
76
77 std::thread::spawn(move || {
78 // `connection: close` below means one request per connection, so the
79 // serial accept loop matches the agent's serial completion requests.
80 for stream in listener.incoming() {
81 let Ok(mut stream) = stream else { continue };
82 let mut reader = BufReader::new(match stream.try_clone() {
83 Ok(clone) => clone,
84 Err(_) => continue,
85 });
86 let mut content_length = 0usize;
87 loop {
88 let mut line = String::new();
89 if reader.read_line(&mut line).unwrap_or(0) == 0 {
90 break;
91 }
92 let line = line.trim();
93 if line.is_empty() {
94 break;
95 }
96 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
97 content_length = length.trim().parse().unwrap_or(0);
98 }
99 }
100 let mut body = vec![0u8; content_length];
101 if reader.read_exact(&mut body).is_err() {
102 continue;
103 }
104 if let Ok(request) = serde_json::from_slice::<Value>(&body) {
105 recorded.lock().unwrap().push(request);
106 }
107 let payload = queue
108 .lock()
109 .unwrap()
110 .pop_front()
111 .unwrap_or_else(|| sse_text("out of scripted responses"));
112 let response = format!(
113 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
114 content-length: {}\r\nconnection: close\r\n\r\n{}",
115 payload.len(),
116 payload
117 );
118 let _ = stream.write_all(response.as_bytes());
119 }
120 });
121
122 FakeEndpoint { port, requests }
123 }
124
125 // ── Running the binary ──────────────────────────────────────────────────────
126
127 /// A scratch layout for one test: an isolated config dir and working dir.
128 struct Scratch {
129 root: std::path::PathBuf,
130 config_dir: std::path::PathBuf,
131 cwd: std::path::PathBuf,
132 }
133
134 fn scratch(tag: &str) -> Scratch {
135 let root = std::env::temp_dir().join(format!("sigit_headless_{tag}_{}", std::process::id()));
136 let config_dir = root.join("config");
137 let cwd = root.join("cwd");
138 std::fs::create_dir_all(&config_dir).unwrap();
139 std::fs::create_dir_all(&cwd).unwrap();
140 Scratch {
141 root,
142 config_dir,
143 cwd,
144 }
145 }
146
147 impl Drop for Scratch {
148 fn drop(&mut self) {
149 let _ = std::fs::remove_dir_all(&self.root);
150 }
151 }
152
153 /// Run `sigit` headless with `args` against the fake endpoint and collect the
154 /// full output. Stdin is null: headless mode must not depend on a TTY.
155 fn run_headless(port: u16, scratch: &Scratch, args: &[&str]) -> Output {
156 Command::new(env!("CARGO_BIN_EXE_sigit"))
157 .args(args)
158 .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
159 .env("OPENAI_API_KEY", "test-key")
160 .env("SIGIT_MODEL", "scripted-model")
161 .env("SIGIT_CONFIG_DIR", &scratch.config_dir)
162 .env("SIGIT_MCP", "off")
163 // A fresh config dir means the default permission mode, `ask` — make
164 // sure the environment can't turn the gate off underneath the test.
165 .env_remove("SIGIT_PERMISSIONS")
166 .env_remove("SIGIT_LOCAL_INFERENCE")
167 .stdin(Stdio::null())
168 .stdout(Stdio::piped())
169 .stderr(Stdio::piped())
170 .output()
171 .expect("run sigit -p")
172 }
173
174 fn stdout_of(output: &Output) -> String {
175 String::from_utf8_lossy(&output.stdout).to_string()
176 }
177
178 fn session_id_from(output: &Output) -> String {
179 String::from_utf8_lossy(&output.stderr)
180 .lines()
181 .find_map(|line| line.strip_prefix("Session: "))
182 .expect("headless text output should report its session id")
183 .to_string()
184 }
185
186 // ── The runs ────────────────────────────────────────────────────────────────
187
188 #[test]
189 fn allowed_tool_runs_and_result_reaches_the_endpoint() {
190 let endpoint = start_fake_endpoint(vec![
191 sse_tool_call("call_1", "run_command", r#"{"command":"echo headless-ok"}"#),
192 sse_text("finished: headless-final-answer"),
193 ]);
194 let scratch = scratch("allow");
195 let cwd = scratch.cwd.to_str().unwrap().to_string();
196
197 let output = run_headless(
198 endpoint.port,
199 &scratch,
200 &[
201 "-p",
202 "do the thing",
203 "--allow-tool",
204 "run_command",
205 "--cwd",
206 &cwd,
207 ],
208 );
209
210 assert_eq!(
211 output.status.code(),
212 Some(0),
213 "stderr: {}",
214 String::from_utf8_lossy(&output.stderr)
215 );
216 assert!(
217 stdout_of(&output).contains("finished: headless-final-answer"),
218 "final answer must reach stdout, got: {}",
219 stdout_of(&output)
220 );
221 // Tool progress goes to stderr, not stdout.
222 assert!(
223 String::from_utf8_lossy(&output.stderr).contains("run_command"),
224 "tool progress should be reported on stderr"
225 );
226
227 let requests = endpoint.requests.lock().unwrap();
228 assert_eq!(requests.len(), 2, "expected exactly two completions");
229
230 // The first request carries the user's prompt.
231 let messages = requests[0]["messages"].as_array().expect("messages");
232 assert!(
233 messages
234 .iter()
235 .any(|m| m["role"] == "user" && m["content"] == "do the thing"),
236 "prompt must reach the endpoint"
237 );
238
239 // The second request carries the executed tool's real output.
240 let messages = requests[1]["messages"].as_array().expect("messages");
241 let result = messages
242 .iter()
243 .find(|m| m["role"] == "tool" && m["tool_call_id"] == "call_1")
244 .expect("tool result for the approved call");
245 assert!(
246 result["content"]
247 .as_str()
248 .unwrap_or_default()
249 .contains("headless-ok"),
250 "the command's output should reach the endpoint: {result}"
251 );
252
253 // The conversation and ACP-importable metadata are saved under the run's
254 // unique session id.
255 let session_id = session_id_from(&output);
256 assert!(
257 scratch
258 .config_dir
259 .join("sessions")
260 .join(format!("{session_id}.jsonl"))
261 .is_file(),
262 "headless session must be persisted"
263 );
264 assert!(
265 scratch
266 .config_dir
267 .join("sessions")
268 .join(format!("{session_id}.meta.json"))
269 .is_file(),
270 "headless session metadata must be persisted"
271 );
272 }
273
274 #[test]
275 fn run_subcommand_emits_structured_jsonl_events() {
276 let endpoint = start_fake_endpoint(vec![
277 sse_tool_call("call_1", "run_command", r#"{"command":"echo structured"}"#),
278 sse_text("structured result"),
279 ]);
280 let scratch = scratch("jsonl");
281 let cwd = scratch.cwd.to_str().unwrap().to_string();
282
283 let output = run_headless(
284 endpoint.port,
285 &scratch,
286 &[
287 "run",
288 "exercise the event stream",
289 "--output",
290 "jsonl",
291 "--allow-tool",
292 "run_command",
293 "--cwd",
294 &cwd,
295 ],
296 );
297
298 assert_eq!(
299 output.status.code(),
300 Some(0),
301 "stderr: {}",
302 String::from_utf8_lossy(&output.stderr)
303 );
304 let events: Vec<Value> = stdout_of(&output)
305 .lines()
306 .map(|line| serde_json::from_str(line).expect("every stdout line must be JSON"))
307 .collect();
308 let session_id = events[0]["session_id"].as_str().expect("session id");
309 assert_eq!(events[0]["type"], "session");
310 assert!(events.iter().all(|event| event["session_id"] == session_id));
311 assert!(events.iter().any(|event| event["type"] == "tool_call"));
312 assert!(events.iter().any(|event| {
313 event["type"] == "tool_result"
314 && event["content"]
315 .as_str()
316 .is_some_and(|content| content.contains("structured"))
317 }));
318 assert!(
319 events
320 .iter()
321 .any(|event| { event["type"] == "result" && event["text"] == "structured result" })
322 );
323 }
324
325 #[test]
326 fn resume_continues_the_saved_conversation() {
327 let endpoint = start_fake_endpoint(vec![sse_text("first answer"), sse_text("second answer")]);
328 let scratch = scratch("resume");
329 let cwd = scratch.cwd.to_str().unwrap().to_string();
330
331 let first = run_headless(
332 endpoint.port,
333 &scratch,
334 &["run", "first question", "--cwd", &cwd],
335 );
336 assert_eq!(
337 first.status.code(),
338 Some(0),
339 "stderr: {}",
340 String::from_utf8_lossy(&first.stderr)
341 );
342 let session_id = session_id_from(&first);
343
344 let second = run_headless(
345 endpoint.port,
346 &scratch,
347 &[
348 "run",
349 "second question",
350 "--resume",
351 &session_id,
352 "--cwd",
353 &cwd,
354 ],
355 );
356 assert_eq!(
357 second.status.code(),
358 Some(0),
359 "stderr: {}",
360 String::from_utf8_lossy(&second.stderr)
361 );
362 assert_eq!(session_id_from(&second), session_id);
363 assert!(stdout_of(&second).contains("second answer"));
364
365 let requests = endpoint.requests.lock().unwrap();
366 assert_eq!(requests.len(), 2);
367 let resumed_messages = requests[1]["messages"].as_array().expect("messages");
368 assert!(
369 resumed_messages
370 .iter()
371 .any(|message| { message["role"] == "user" && message["content"] == "first question" })
372 );
373 assert!(
374 resumed_messages.iter().any(|message| {
375 message["role"] == "assistant" && message["content"] == "first answer"
376 })
377 );
378 assert!(
379 resumed_messages.iter().any(|message| {
380 message["role"] == "user" && message["content"] == "second question"
381 })
382 );
383 }
384
385 #[test]
386 fn missing_resume_session_is_a_structured_error() {
387 let endpoint = start_fake_endpoint(vec![]);
388 let scratch = scratch("missing-resume");
389 let cwd = scratch.cwd.to_str().unwrap().to_string();
390
391 let output = run_headless(
392 endpoint.port,
393 &scratch,
394 &[
395 "run",
396 "continue",
397 "--resume",
398 "missing-session",
399 "--output",
400 "jsonl",
401 "--cwd",
402 &cwd,
403 ],
404 );
405
406 assert_eq!(output.status.code(), Some(1));
407 let events: Vec<Value> = stdout_of(&output)
408 .lines()
409 .map(|line| serde_json::from_str(line).expect("error output must be JSONL"))
410 .collect();
411 assert_eq!(events.len(), 1);
412 assert_eq!(events[0]["type"], "error");
413 assert_eq!(events[0]["session_id"], "missing-session");
414 assert_eq!(events[0]["message"], "saved session not found");
415 assert!(endpoint.requests.lock().unwrap().is_empty());
416 }
417
418 #[test]
419 fn ask_level_tool_without_allow_flag_is_denied_but_run_completes() {
420 let endpoint = start_fake_endpoint(vec![
421 sse_tool_call(
422 "call_1",
423 "run_command",
424 r#"{"command":"echo must-not-run"}"#,
425 ),
426 sse_text("understood, stopping"),
427 ]);
428 let scratch = scratch("deny");
429 let cwd = scratch.cwd.to_str().unwrap().to_string();
430
431 let output = run_headless(
432 endpoint.port,
433 &scratch,
434 &["-p", "try the command", "--cwd", &cwd],
435 );
436
437 // A denial is fed to the model, not treated as a failure: the turn still
438 // completes, so the exit code is 0.
439 assert_eq!(
440 output.status.code(),
441 Some(0),
442 "stderr: {}",
443 String::from_utf8_lossy(&output.stderr)
444 );
445 assert!(
446 stdout_of(&output).contains("understood, stopping"),
447 "final answer must reach stdout, got: {}",
448 stdout_of(&output)
449 );
450
451 let requests = endpoint.requests.lock().unwrap();
452 assert_eq!(requests.len(), 2, "expected exactly two completions");
453
454 // The endpoint sees the denial as the tool result — including the
455 // --allow-tool hint — and never the command's real output.
456 let messages = requests[1]["messages"].as_array().expect("messages");
457 let result = messages
458 .iter()
459 .find(|m| m["role"] == "tool" && m["tool_call_id"] == "call_1")
460 .expect("tool result for the denied call");
461 let content = result["content"].as_str().unwrap_or_default();
462 assert!(
463 content.contains("was not executed") && content.contains("--allow-tool"),
464 "denial text should reach the model and mention --allow-tool: {content}"
465 );
466 assert!(
467 !content.contains("must-not-run"),
468 "the denied command must not have executed: {content}"
469 );
470 }
471
472 #[test]
473 fn text_from_two_tool_rounds_is_not_run_together() {
474 let endpoint = start_fake_endpoint(vec![
475 sse_text_then_tool_call(
476 "I'll search for this pattern.",
477 "call_1",
478 "run_command",
479 r#"{"command":"echo first"}"#,
480 ),
481 sse_text("Let me broaden the search:"),
482 ]);
483 let scratch = scratch("spacing");
484 let cwd = scratch.cwd.to_str().unwrap().to_string();
485
486 let output = run_headless(
487 endpoint.port,
488 &scratch,
489 &[
490 "-p",
491 "find it",
492 "--allow-tool",
493 "run_command",
494 "--cwd",
495 &cwd,
496 ],
497 );
498
499 assert_eq!(
500 output.status.code(),
501 Some(0),
502 "stderr: {}",
503 String::from_utf8_lossy(&output.stderr)
504 );
505 let stdout = stdout_of(&output);
506 assert!(
507 stdout.contains("pattern.\n\nLet me broaden"),
508 "the second round must open a new paragraph, got: {stdout:?}"
509 );
510 assert!(
511 !stdout.contains("pattern.Let me"),
512 "rounds must not run together into one sentence, got: {stdout:?}"
513 );
514 }
515
516 #[test]
517 fn unknown_flag_exits_2_with_usage() {
518 let endpoint = start_fake_endpoint(vec![]);
519 let scratch = scratch("usage");
520
521 let output = run_headless(endpoint.port, &scratch, &["-p", "x", "--frobnicate"]);
522
523 assert_eq!(output.status.code(), Some(2));
524 let stderr = String::from_utf8_lossy(&output.stderr);
525 assert!(
526 stderr.contains("--frobnicate") && stderr.contains("Usage:"),
527 "usage must be printed to stderr: {stderr}"
528 );
529 assert!(
530 endpoint.requests.lock().unwrap().is_empty(),
531 "a bad invocation must never reach the endpoint"
532 );
533 }