Tell the user why a turn stopped when the last round is silent

In the stalled v1.5.11 release thread, every stop looked the same: the repeat guard blocked a call, the forced no-tools round came back empty, and since earlier text had already streamed, sigit sent nothing more. The turn just ended with no answer and no question. When the last round of a tool-using turn produces no visible text, sigit now sends a short closing message. It names the repeated tool when the guard was the cause and asks the user to reply "continue" or redirect. The non-streaming path used to say "the edits didn't go through" here, which was wrong whenever no edits were involved, so it uses the same message now. Co-Authored-By: siGit Code v1.5.11 <noreply@sigit.si> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Seto Elkahfi committed Sep 30, 2026 at 07:16 UTC 37351ad1df6ae733fc13d535f534e094931aab3e
2 files changed +96 -1
src/main.rs
+42 -1
@@ -266,6 +266,21 @@ pub(crate) fn system_prompt_for_model(tool_calling: bool) -> String {
266 /// context window, so the cap can afford to be generous
267 const MAX_TOOL_ROUNDS: usize = 24;
268
269 +/// What the user sees when a turn that ran tools ends with no closing text.
270 +fn silent_stop_message(repeated_tool: Option<&str>, rounds: usize) -> String {
271 + match repeated_tool {
272 + Some(tool) => format!(
273 + "I stopped because I kept making the same `{tool}` call without getting \
274 + anywhere, and I had nothing to add without it. Reply \"continue\" to pick \
275 + it back up, or tell me what to try instead."
276 + ),
277 + None => format!(
278 + "I stopped after {rounds} tool round(s) without writing a final answer. \
279 + Reply \"continue\" to pick it back up, or tell me what to try instead."
280 + ),
281 + }
282 +}
283 +
284 /// Separator between two stretches of assistant prose that a tool round split
285 /// apart. ACP clients concatenate consecutive agent-message chunks into a
286 /// single block, so a plain newline (or nothing at all) leaves the rounds
@@ -2231,6 +2246,11 @@ impl SiGitAgent {
2246 };
2247
2248 let mut round = 0;
2249 + // Set when the repetition guard blocked a call; names the tool.
2250 + let mut stopped_repeating: Option<String> = None;
2251 + // Visible text already sent before the latest inference round, so the
2252 + // end of the turn can tell whether that round said anything at all.
2253 + let mut sent_before_last_round = 0;
2254
2255 while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
2256 if cancellation.cancelled.load(Ordering::Acquire) {
@@ -2339,6 +2359,7 @@ impl SiGitAgent {
2359 let repeated = *repeat_count >= 3 && tc.name != "command_output";
2360 if repeated {
2361 force_text = true;
2362 + stopped_repeating = Some(tc.name.clone());
2363 log::warn!(
2364 "prompt({}) stopping repeated tool call `{}` after {} attempts",
2365 session_id,
@@ -2472,6 +2493,7 @@ impl SiGitAgent {
2493 // Whatever this round says starts a new paragraph rather than
2494 // continuing the sentence the tool calls interrupted.
2495 reply.interrupt();
2496 + sent_before_last_round = reply.sent.len();
2497
2498 let cancelled_results = tool_results.clone();
2499 result = match self
@@ -2514,7 +2536,7 @@ impl SiGitAgent {
2536 session_id,
2537 round
2538 );
2517 - "Something went wrong — the edits didn't go through. Try rephrasing what you need, or point me at the specific lines.".to_string()
2539 + silent_stop_message(stopped_repeating.as_deref(), round)
2540 } else {
2541 log::warn!(
2542 "prompt({}) — model returned empty reply (no tool rounds)",
@@ -2532,6 +2554,25 @@ impl SiGitAgent {
2554 self.send_assistant_message(cx, session_id.clone(), final_text)
2555 .ok();
2556 }
2557 + } else if round > 0 && reply.sent.len() == sent_before_last_round {
2558 + // Earlier rounds streamed text, but the last one (typically the
2559 + // forced no-tools round) said nothing. Without a closing message
2560 + // the turn just stops, and the user can't tell whether it ended,
2561 + // stalled, or is waiting on them.
2562 + log::warn!(
2563 + "prompt({}) — final round after {} tool round(s) produced no text",
2564 + session_id,
2565 + round
2566 + );
2567 + self.send_assistant_message(
2568 + cx,
2569 + session_id.clone(),
2570 + format!(
2571 + "{PARAGRAPH_BREAK}{}",
2572 + silent_stop_message(stopped_repeating.as_deref(), round)
2573 + ),
2574 + )
2575 + .ok();
2576 }
2577
2578 // Persist the completed turn so a restart (or session/load) can pick
tests/acp_permissions.rs
+54
@@ -1227,6 +1227,60 @@ fn a_kimi_k3_tool_call_emitted_as_text_is_executed_rather_than_rendered() {
1227 let _ = std::fs::remove_dir_all(&scratch);
1228 }
1229
1230 +#[test]
1231 +fn a_silent_forced_text_round_still_ends_with_a_message() {
1232 + let repeated_arguments = json!({"path": "missing.txt"}).to_string();
1233 + let endpoint = start_fake_endpoint(vec![
1234 + sse_text_then_tool_call(
1235 + "Checking the file.",
1236 + "call_1",
1237 + "read_file",
1238 + &repeated_arguments,
1239 + ),
1240 + sse_tool_call("call_2", "read_file", &repeated_arguments),
1241 + sse_tool_call("call_3", "read_file", &repeated_arguments),
1242 + // The forced no-tools round says nothing, as in the stalled thread.
1243 + sse_body(&[]),
1244 + ]);
1245 +
1246 + let scratch =
1247 + std::env::temp_dir().join(format!("sigit_acp_silent_stop_{}", std::process::id()));
1248 + let config_dir = scratch.join("config");
1249 + let cwd = scratch.join("cwd");
1250 + std::fs::create_dir_all(&config_dir).unwrap();
1251 + std::fs::create_dir_all(&cwd).unwrap();
1252 +
1253 + let mut agent = spawn_agent(endpoint.port, &config_dir);
1254 + let id = agent.request(
1255 + "initialize",
1256 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
1257 + );
1258 + agent.wait_for_response(id);
1259 + let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1260 + let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1261 + .as_str()
1262 + .expect("session id")
1263 + .to_string();
1264 +
1265 + let prompt_id = agent.request(
1266 + "session/prompt",
1267 + json!({
1268 + "sessionId": session_id,
1269 + "prompt": [{"type": "text", "text": "read it"}],
1270 + }),
1271 + );
1272 + let (response, rendered) = agent.wait_for_prompt(prompt_id);
1273 + assert_eq!(response["result"]["stopReason"], "end_turn");
1274 + assert!(rendered.contains("Checking the file."), "{rendered:?}");
1275 + assert!(
1276 + rendered.contains("same `read_file` call") && rendered.contains("continue"),
1277 + "the turn ended without telling the user why: {rendered:?}"
1278 + );
1279 +
1280 + drop(agent);
1281 + let _ = std::fs::remove_dir_all(&scratch);
1282 +}
1283 +
1284 #[test]
1285 fn repeated_command_output_calls_remain_available() {
1286 let repeated_arguments = json!({"task_id": 999}).to_string();