test(headless): cover a run cut off at the tool-round cap
Drives the binary against the scripted endpoint with --max-tool-rounds 2 and an empty forced-text round, and checks that one more request goes out with no tools, that it names the cap, and that its reply is what stdout carries. A second test checks that a run with a real final message makes no extra request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seto Elkahfi committed
Oct 5, 2026 at 08:45 UTC
855cb4ce6ffa0bc9331a252c50abc0f246bd8452
1 file changed
+109
tests/headless_mode.rs
+109
@@ -61,6 +61,22 @@ fn sse_text_then_tool_call(text: &str, id: &str, name: &str, arguments: &str) ->
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,
@@ -163,6 +179,7 @@ fn run_headless(port: u16, scratch: &Scratch, args: &[&str]) -> Output {
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())
@@ -513,6 +530,98 @@ fn text_from_two_tool_rounds_is_not_run_together() {
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![]);