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 /// A non-streaming completion that asks for one tool call. `--quiet` runs do
65 /// not stream, so their scripted responses are plain JSON.
66 fn json_tool_call(id: &str, name: &str, arguments: &str) -> String {
67 json!({"choices": [{"message": {"content": null, "tool_calls": [{
68 "id": id,
69 "type": "function",
70 "function": {"name": name, "arguments": arguments},
71 }]}}]})
72 .to_string()
73 }
74
75 /// A non-streaming completion that is only text.
76 fn json_text(text: &str) -> String {
77 json!({"choices": [{"message": {"content": text}}]}).to_string()
78 }
79
80 /// Serves one scripted SSE response per request and records each request body.
81 struct FakeEndpoint {
82 port: u16,
83 requests: Arc<Mutex<Vec<Value>>>,
84 }
85
86 fn start_fake_endpoint(responses: Vec<String>) -> FakeEndpoint {
87 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
88 let port = listener.local_addr().unwrap().port();
89 let requests: Arc<Mutex<Vec<Value>>> = Arc::default();
90 let recorded = Arc::clone(&requests);
91 let queue = Mutex::new(VecDeque::from(responses));
92
93 std::thread::spawn(move || {
94 // `connection: close` below means one request per connection, so the
95 // serial accept loop matches the agent's serial completion requests.
96 for stream in listener.incoming() {
97 let Ok(mut stream) = stream else { continue };
98 let mut reader = BufReader::new(match stream.try_clone() {
99 Ok(clone) => clone,
100 Err(_) => continue,
101 });
102 let mut content_length = 0usize;
103 loop {
104 let mut line = String::new();
105 if reader.read_line(&mut line).unwrap_or(0) == 0 {
106 break;
107 }
108 let line = line.trim();
109 if line.is_empty() {
110 break;
111 }
112 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
113 content_length = length.trim().parse().unwrap_or(0);
114 }
115 }
116 let mut body = vec![0u8; content_length];
117 if reader.read_exact(&mut body).is_err() {
118 continue;
119 }
120 if let Ok(request) = serde_json::from_slice::<Value>(&body) {
121 recorded.lock().unwrap().push(request);
122 }
123 let payload = queue
124 .lock()
125 .unwrap()
126 .pop_front()
127 .unwrap_or_else(|| sse_text("out of scripted responses"));
128 let response = format!(
129 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
130 content-length: {}\r\nconnection: close\r\n\r\n{}",
131 payload.len(),
132 payload
133 );
134 let _ = stream.write_all(response.as_bytes());
135 }
136 });
137
138 FakeEndpoint { port, requests }
139 }
140
141 // ── Running the binary ──────────────────────────────────────────────────────
142
143 /// A scratch layout for one test: an isolated config dir and working dir.
144 struct Scratch {
145 root: std::path::PathBuf,
146 config_dir: std::path::PathBuf,
147 cwd: std::path::PathBuf,
148 }
149
150 fn scratch(tag: &str) -> Scratch {
151 let root = std::env::temp_dir().join(format!("sigit_headless_{tag}_{}", std::process::id()));
152 let config_dir = root.join("config");
153 let cwd = root.join("cwd");
154 std::fs::create_dir_all(&config_dir).unwrap();
155 std::fs::create_dir_all(&cwd).unwrap();
156 Scratch {
157 root,
158 config_dir,
159 cwd,
160 }
161 }
162
163 impl Drop for Scratch {
164 fn drop(&mut self) {
165 let _ = std::fs::remove_dir_all(&self.root);
166 }
167 }
168
169 /// Run `sigit` headless with `args` against the fake endpoint and collect the
170 /// full output. Stdin is null: headless mode must not depend on a TTY.
171 fn run_headless(port: u16, scratch: &Scratch, args: &[&str]) -> Output {
172 Command::new(env!("CARGO_BIN_EXE_sigit"))
173 .args(args)
174 .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
175 .env("OPENAI_API_KEY", "test-key")
176 .env("SIGIT_MODEL", "scripted-model")
177 .env("SIGIT_CONFIG_DIR", &scratch.config_dir)
178 .env("SIGIT_MCP", "off")
179 // A fresh config dir means the default permission mode, `ask` — make
180 // sure the environment can't turn the gate off underneath the test.
181 .env_remove("SIGIT_PERMISSIONS")
182 .env_remove("SIGIT_MAX_TOOL_ROUNDS")
183 .env_remove("SIGIT_LOCAL_INFERENCE")
184 .stdin(Stdio::null())
185 .stdout(Stdio::piped())
186 .stderr(Stdio::piped())
187 .output()
188 .expect("run sigit -p")
189 }
190
191 fn stdout_of(output: &Output) -> String {
192 String::from_utf8_lossy(&output.stdout).to_string()
193 }
194
195 fn session_id_from(output: &Output) -> String {
196 String::from_utf8_lossy(&output.stderr)
197 .lines()
198 .find_map(|line| line.strip_prefix("Session: "))
199 .expect("headless text output should report its session id")
200 .to_string()
201 }
202
203 // ── The runs ────────────────────────────────────────────────────────────────
204
205 #[test]
206 fn allowed_tool_runs_and_result_reaches_the_endpoint() {
207 let endpoint = start_fake_endpoint(vec![
208 sse_tool_call("call_1", "run_command", r#"{"command":"echo headless-ok"}"#),
209 sse_text("finished: headless-final-answer"),
210 ]);
211 let scratch = scratch("allow");
212 let cwd = scratch.cwd.to_str().unwrap().to_string();
213
214 let output = run_headless(
215 endpoint.port,
216 &scratch,
217 &[
218 "-p",
219 "do the thing",
220 "--allow-tool",
221 "run_command",
222 "--cwd",
223 &cwd,
224 ],
225 );
226
227 assert_eq!(
228 output.status.code(),
229 Some(0),
230 "stderr: {}",
231 String::from_utf8_lossy(&output.stderr)
232 );
233 assert!(
234 stdout_of(&output).contains("finished: headless-final-answer"),
235 "final answer must reach stdout, got: {}",
236 stdout_of(&output)
237 );
238 // Tool progress goes to stderr, not stdout.
239 assert!(
240 String::from_utf8_lossy(&output.stderr).contains("run_command"),
241 "tool progress should be reported on stderr"
242 );
243
244 let requests = endpoint.requests.lock().unwrap();
245 assert_eq!(requests.len(), 2, "expected exactly two completions");
246
247 // The first request carries the user's prompt.
248 let messages = requests[0]["messages"].as_array().expect("messages");
249 assert!(
250 messages
251 .iter()
252 .any(|m| m["role"] == "user" && m["content"] == "do the thing"),
253 "prompt must reach the endpoint"
254 );
255
256 // The second request carries the executed tool's real output.
257 let messages = requests[1]["messages"].as_array().expect("messages");
258 let result = messages
259 .iter()
260 .find(|m| m["role"] == "tool" && m["tool_call_id"] == "call_1")
261 .expect("tool result for the approved call");
262 assert!(
263 result["content"]
264 .as_str()
265 .unwrap_or_default()
266 .contains("headless-ok"),
267 "the command's output should reach the endpoint: {result}"
268 );
269
270 // The conversation and ACP-importable metadata are saved under the run's
271 // unique session id.
272 let session_id = session_id_from(&output);
273 assert!(
274 scratch
275 .config_dir
276 .join("sessions")
277 .join(format!("{session_id}.jsonl"))
278 .is_file(),
279 "headless session must be persisted"
280 );
281 assert!(
282 scratch
283 .config_dir
284 .join("sessions")
285 .join(format!("{session_id}.meta.json"))
286 .is_file(),
287 "headless session metadata must be persisted"
288 );
289 }
290
291 #[test]
292 fn run_subcommand_emits_structured_jsonl_events() {
293 let endpoint = start_fake_endpoint(vec![
294 sse_tool_call("call_1", "run_command", r#"{"command":"echo structured"}"#),
295 sse_text("structured result"),
296 ]);
297 let scratch = scratch("jsonl");
298 let cwd = scratch.cwd.to_str().unwrap().to_string();
299
300 let output = run_headless(
301 endpoint.port,
302 &scratch,
303 &[
304 "run",
305 "exercise the event stream",
306 "--output",
307 "jsonl",
308 "--allow-tool",
309 "run_command",
310 "--cwd",
311 &cwd,
312 ],
313 );
314
315 assert_eq!(
316 output.status.code(),
317 Some(0),
318 "stderr: {}",
319 String::from_utf8_lossy(&output.stderr)
320 );
321 let events: Vec<Value> = stdout_of(&output)
322 .lines()
323 .map(|line| serde_json::from_str(line).expect("every stdout line must be JSON"))
324 .collect();
325 let session_id = events[0]["session_id"].as_str().expect("session id");
326 assert_eq!(events[0]["type"], "session");
327 assert!(events.iter().all(|event| event["session_id"] == session_id));
328 assert!(events.iter().any(|event| event["type"] == "tool_call"));
329 assert!(events.iter().any(|event| {
330 event["type"] == "tool_result"
331 && event["content"]
332 .as_str()
333 .is_some_and(|content| content.contains("structured"))
334 }));
335 assert!(
336 events
337 .iter()
338 .any(|event| { event["type"] == "result" && event["text"] == "structured result" })
339 );
340 }
341
342 #[test]
343 fn resume_continues_the_saved_conversation() {
344 let endpoint = start_fake_endpoint(vec![sse_text("first answer"), sse_text("second answer")]);
345 let scratch = scratch("resume");
346 let cwd = scratch.cwd.to_str().unwrap().to_string();
347
348 let first = run_headless(
349 endpoint.port,
350 &scratch,
351 &["run", "first question", "--cwd", &cwd],
352 );
353 assert_eq!(
354 first.status.code(),
355 Some(0),
356 "stderr: {}",
357 String::from_utf8_lossy(&first.stderr)
358 );
359 let session_id = session_id_from(&first);
360
361 let second = run_headless(
362 endpoint.port,
363 &scratch,
364 &[
365 "run",
366 "second question",
367 "--resume",
368 &session_id,
369 "--cwd",
370 &cwd,
371 ],
372 );
373 assert_eq!(
374 second.status.code(),
375 Some(0),
376 "stderr: {}",
377 String::from_utf8_lossy(&second.stderr)
378 );
379 assert_eq!(session_id_from(&second), session_id);
380 assert!(stdout_of(&second).contains("second answer"));
381
382 let requests = endpoint.requests.lock().unwrap();
383 assert_eq!(requests.len(), 2);
384 let resumed_messages = requests[1]["messages"].as_array().expect("messages");
385 assert!(
386 resumed_messages
387 .iter()
388 .any(|message| { message["role"] == "user" && message["content"] == "first question" })
389 );
390 assert!(
391 resumed_messages.iter().any(|message| {
392 message["role"] == "assistant" && message["content"] == "first answer"
393 })
394 );
395 assert!(
396 resumed_messages.iter().any(|message| {
397 message["role"] == "user" && message["content"] == "second question"
398 })
399 );
400 }
401
402 #[test]
403 fn missing_resume_session_is_a_structured_error() {
404 let endpoint = start_fake_endpoint(vec![]);
405 let scratch = scratch("missing-resume");
406 let cwd = scratch.cwd.to_str().unwrap().to_string();
407
408 let output = run_headless(
409 endpoint.port,
410 &scratch,
411 &[
412 "run",
413 "continue",
414 "--resume",
415 "missing-session",
416 "--output",
417 "jsonl",
418 "--cwd",
419 &cwd,
420 ],
421 );
422
423 assert_eq!(output.status.code(), Some(1));
424 let events: Vec<Value> = stdout_of(&output)
425 .lines()
426 .map(|line| serde_json::from_str(line).expect("error output must be JSONL"))
427 .collect();
428 assert_eq!(events.len(), 1);
429 assert_eq!(events[0]["type"], "error");
430 assert_eq!(events[0]["session_id"], "missing-session");
431 assert_eq!(events[0]["message"], "saved session not found");
432 assert!(endpoint.requests.lock().unwrap().is_empty());
433 }
434
435 #[test]
436 fn ask_level_tool_without_allow_flag_is_denied_but_run_completes() {
437 let endpoint = start_fake_endpoint(vec![
438 sse_tool_call(
439 "call_1",
440 "run_command",
441 r#"{"command":"echo must-not-run"}"#,
442 ),
443 sse_text("understood, stopping"),
444 ]);
445 let scratch = scratch("deny");
446 let cwd = scratch.cwd.to_str().unwrap().to_string();
447
448 let output = run_headless(
449 endpoint.port,
450 &scratch,
451 &["-p", "try the command", "--cwd", &cwd],
452 );
453
454 // A denial is fed to the model, not treated as a failure: the turn still
455 // completes, so the exit code is 0.
456 assert_eq!(
457 output.status.code(),
458 Some(0),
459 "stderr: {}",
460 String::from_utf8_lossy(&output.stderr)
461 );
462 assert!(
463 stdout_of(&output).contains("understood, stopping"),
464 "final answer must reach stdout, got: {}",
465 stdout_of(&output)
466 );
467
468 let requests = endpoint.requests.lock().unwrap();
469 assert_eq!(requests.len(), 2, "expected exactly two completions");
470
471 // The endpoint sees the denial as the tool result — including the
472 // --allow-tool hint — and never the command's real output.
473 let messages = requests[1]["messages"].as_array().expect("messages");
474 let result = messages
475 .iter()
476 .find(|m| m["role"] == "tool" && m["tool_call_id"] == "call_1")
477 .expect("tool result for the denied call");
478 let content = result["content"].as_str().unwrap_or_default();
479 assert!(
480 content.contains("was not executed") && content.contains("--allow-tool"),
481 "denial text should reach the model and mention --allow-tool: {content}"
482 );
483 assert!(
484 !content.contains("must-not-run"),
485 "the denied command must not have executed: {content}"
486 );
487 }
488
489 #[test]
490 fn text_from_two_tool_rounds_is_not_run_together() {
491 let endpoint = start_fake_endpoint(vec![
492 sse_text_then_tool_call(
493 "I'll search for this pattern.",
494 "call_1",
495 "run_command",
496 r#"{"command":"echo first"}"#,
497 ),
498 sse_text("Let me broaden the search:"),
499 ]);
500 let scratch = scratch("spacing");
501 let cwd = scratch.cwd.to_str().unwrap().to_string();
502
503 let output = run_headless(
504 endpoint.port,
505 &scratch,
506 &[
507 "-p",
508 "find it",
509 "--allow-tool",
510 "run_command",
511 "--cwd",
512 &cwd,
513 ],
514 );
515
516 assert_eq!(
517 output.status.code(),
518 Some(0),
519 "stderr: {}",
520 String::from_utf8_lossy(&output.stderr)
521 );
522 let stdout = stdout_of(&output);
523 assert!(
524 stdout.contains("pattern.\n\nLet me broaden"),
525 "the second round must open a new paragraph, got: {stdout:?}"
526 );
527 assert!(
528 !stdout.contains("pattern.Let me"),
529 "rounds must not run together into one sentence, got: {stdout:?}"
530 );
531 }
532
533 /// A run cut off at the tool-round cap used to print nothing and exit 0. It
534 /// must ask for a final message once, without offering tools, and print that.
535 #[test]
536 fn run_cut_off_at_the_round_cap_still_ends_with_a_final_message() {
537 let endpoint = start_fake_endpoint(vec![
538 json_tool_call("call_1", "run_command", r#"{"command":"echo one"}"#),
539 json_tool_call("call_2", "run_command", r#"{"command":"echo two"}"#),
540 // The last allowed round is forced to answer in text and says nothing.
541 json_text(""),
542 json_text("Ran two commands; the search is unfinished."),
543 ]);
544 let scratch = scratch("round_cap");
545 let cwd = scratch.cwd.to_str().unwrap().to_string();
546
547 let output = run_headless(
548 endpoint.port,
549 &scratch,
550 &[
551 "-p",
552 "look into it",
553 "--quiet",
554 "--max-tool-rounds",
555 "2",
556 "--allow-tool",
557 "run_command",
558 "--cwd",
559 &cwd,
560 ],
561 );
562
563 assert_eq!(
564 output.status.code(),
565 Some(0),
566 "stderr: {}",
567 String::from_utf8_lossy(&output.stderr)
568 );
569 assert_eq!(
570 stdout_of(&output),
571 "Ran two commands; the search is unfinished.\n"
572 );
573
574 let requests = endpoint.requests.lock().unwrap();
575 assert_eq!(requests.len(), 4, "prompt, two tool rounds, the wrap-up");
576 assert!(
577 requests[1].get("tools").is_some(),
578 "the first tool round may still call tools"
579 );
580 assert!(
581 requests[2].get("tools").is_none(),
582 "the round at the cap is forced to answer in text"
583 );
584 let wrap_up = &requests[3];
585 assert!(
586 wrap_up.get("tools").is_none(),
587 "no tools are offered for the final message"
588 );
589 let last = wrap_up["messages"].as_array().unwrap().last().unwrap();
590 assert_eq!(last["role"], "user");
591 let asked = last["content"].as_str().unwrap();
592 assert!(asked.contains("all 2 tool rounds"), "got: {asked}");
593 assert!(asked.contains("final message"), "got: {asked}");
594 }
595
596 /// A run that ends with a real final message is left alone: no extra request.
597 #[test]
598 fn run_with_a_final_message_makes_no_wrap_up_request() {
599 let endpoint = start_fake_endpoint(vec![
600 json_tool_call("call_1", "run_command", r#"{"command":"echo one"}"#),
601 json_text("Done: it prints one."),
602 ]);
603 let scratch = scratch("no_wrap_up");
604 let cwd = scratch.cwd.to_str().unwrap().to_string();
605
606 let output = run_headless(
607 endpoint.port,
608 &scratch,
609 &[
610 "-p",
611 "look into it",
612 "--quiet",
613 "--allow-tool",
614 "run_command",
615 "--cwd",
616 &cwd,
617 ],
618 );
619
620 assert_eq!(output.status.code(), Some(0));
621 assert_eq!(stdout_of(&output), "Done: it prints one.\n");
622 assert_eq!(endpoint.requests.lock().unwrap().len(), 2);
623 }
624
625 #[test]
626 fn unknown_flag_exits_2_with_usage() {
627 let endpoint = start_fake_endpoint(vec![]);
628 let scratch = scratch("usage");
629
630 let output = run_headless(endpoint.port, &scratch, &["-p", "x", "--frobnicate"]);
631
632 assert_eq!(output.status.code(), Some(2));
633 let stderr = String::from_utf8_lossy(&output.stderr);
634 assert!(
635 stderr.contains("--frobnicate") && stderr.contains("Usage:"),
636 "usage must be printed to stderr: {stderr}"
637 );
638 assert!(
639 endpoint.requests.lock().unwrap().is_empty(),
640 "a bad invocation must never reach the endpoint"
641 );
642 }