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![]);