fix(acp): end denied and skipped tool calls as failed

A call denied by policy or plan mode, denied by the user at the permission prompt, or skipped by the repeat guard never ran, but its card closed with the same `completed` update as a successful call. It now closes `failed`, matching the cancelled-at-the-prompt path. A call denied at the prompt also gets its own title back, since the permission request replaced it with the full arguments. Tool errors returned in band by execute_tool still end `completed`; that needs execute_tool to report success and is left for later. Fixes #139 Co-Authored-By: siGit Code v1.6.4 <noreply@sigit.si> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 8, 2026 at 06:51 UTC f73e53c1724fd011ae1fd503c4ca2aa6284e2a7e
3 files changed +191 -18
CHANGELOG.md
+8
@@ -28,6 +28,14 @@
28 for ids no session ever had. The entry now goes with the last request that
29 used it.
30
31 +- **A tool call that did not run no longer shows as completed.** When policy
32 + or plan mode denied a call, when the user picked Deny at the permission
33 + prompt, or when the repeat guard skipped a call the model made three times,
34 + the editor's card still ended `completed`. Those cards now end `failed`, the
35 + same way a call cancelled at the prompt already did. A call denied at the
36 + prompt also gets its own title back instead of keeping the permission
37 + request's (#139).
38 +
39 ## 1.6.4
40
41 ### Added
src/main.rs
+29 -17
@@ -3202,20 +3202,25 @@ impl SiGitAgent {
3202 }
3203
3204 // Permission gate: read-only tools pass straight through; a
3205 - // mutating tool consults policy and may ask the client.
3206 - let output = match decision {
3207 - None => format!(
3208 - "The tool `{}` was not executed again because the model repeated \
3209 - the same call three times. Continue without this tool.",
3210 - tc.name
3205 + // mutating tool consults policy and may ask the client. The
3206 + // flag says whether the tool actually ran: a call that was
3207 + // denied or skipped ends `failed`, not `completed` (#139).
3208 + let (output, executed) = match decision {
3209 + None => (
3210 + format!(
3211 + "The tool `{}` was not executed again because the model repeated \
3212 + the same call three times. Continue without this tool.",
3213 + tc.name
3214 + ),
3215 + false,
3216 ),
3217 Some(decision) => match decision {
3218 permissions::Decision::Allow => {
3214 - tools::execute_tool(&tc.name, &tc.arguments).await
3219 + (tools::execute_tool(&tc.name, &tc.arguments).await, true)
3220 }
3221 permissions::Decision::Deny(reason) => {
3222 log::info!(" ✗ {} denied by policy", tc.name);
3218 - reason
3223 + (reason, false)
3224 }
3225 permissions::Decision::Ask => {
3226 // The answer can take as long as the user likes.
@@ -3266,11 +3271,11 @@ impl SiGitAgent {
3271 )
3272 .ok();
3273 }
3269 - tools::execute_tool(&tc.name, &tc.arguments).await
3274 + (tools::execute_tool(&tc.name, &tc.arguments).await, true)
3275 }
3276 PermissionVerdict::Denied(reason) => {
3277 log::info!(" ✗ {} denied by user", tc.name);
3273 - reason
3278 + (reason, false)
3279 }
3280 PermissionVerdict::TurnCancelled => {
3281 log::info!(
@@ -3322,16 +3327,23 @@ impl SiGitAgent {
3327 log::info!(" ← {} chars", output.len());
3328
3329 if !render_as_plan {
3330 + let mut fields = ToolCallUpdateFields::new()
3331 + .status(if executed {
3332 + ToolCallStatus::Completed
3333 + } else {
3334 + ToolCallStatus::Failed
3335 + })
3336 + .content(vec![tool_output_content(&output)])
3337 + .raw_output(serde_json::Value::String(output.clone()));
3338 + // A denial at the prompt leaves the card titled with the
3339 + // permission request's full arguments; put its own back.
3340 + if !executed && announced_status == ToolCallStatus::Pending {
3341 + fields = fields.title(chat::tool_title(&tc.name, &tc.arguments));
3342 + }
3343 self.send_tool_call_update(
3344 cx,
3345 session_id.clone(),
3328 - SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3329 - tc.id.clone(),
3330 - ToolCallUpdateFields::new()
3331 - .status(ToolCallStatus::Completed)
3332 - .content(vec![tool_output_content(&output)])
3333 - .raw_output(serde_json::Value::String(output.clone())),
3334 - )),
3346 + SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(tc.id.clone(), fields)),
3347 )
3348 .ok();
3349 }
tests/acp_permissions.rs
+154 -1
@@ -551,6 +551,82 @@ fn a_permission_request_is_about_the_tool_call_it_was_announced_as() {
551 let _ = std::fs::remove_dir_all(&scratch);
552 }
553
554 +/// A call the user denies at the prompt did not run, so it ends `failed`
555 +/// rather than `completed` (issue #139), and gets its own title back from
556 +/// the permission request's.
557 +#[test]
558 +fn a_call_denied_at_the_prompt_ends_failed() {
559 + let endpoint = start_fake_endpoint(vec![
560 + sse_tool_call(
561 + "call_1",
562 + "run_command",
563 + r#"{"command":"echo sigit-denied"}"#,
564 + ),
565 + sse_text("ok, skipped"),
566 + ]);
567 +
568 + let scratch = std::env::temp_dir().join(format!("sigit_acp_perm_deny_{}", std::process::id()));
569 + let config_dir = scratch.join("config");
570 + let cwd = scratch.join("cwd");
571 + std::fs::create_dir_all(&config_dir).unwrap();
572 + std::fs::create_dir_all(&cwd).unwrap();
573 +
574 + let mut agent = spawn_agent(endpoint.port, &config_dir);
575 +
576 + let id = agent.request(
577 + "initialize",
578 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
579 + );
580 + agent.wait_for_response(id);
581 +
582 + let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
583 + let session_id = agent.wait_for_response(id)["result"]["sessionId"]
584 + .as_str()
585 + .expect("session id")
586 + .to_string();
587 +
588 + let prompt_id = agent.request(
589 + "session/prompt",
590 + json!({
591 + "sessionId": session_id,
592 + "prompt": [{"type": "text", "text": "run the command"}],
593 + }),
594 + );
595 +
596 + let call_update = |message: &Value, kind: &str| {
597 + let update = &message["params"]["update"];
598 + message["method"] == "session/update"
599 + && update["sessionUpdate"] == kind
600 + && update["toolCallId"] == "call_1"
601 + };
602 +
603 + let announced = agent.wait_for("the tool call announcement", |message| {
604 + call_update(message, "tool_call")
605 + });
606 +
607 + let permission = agent.wait_for_agent_request("session/request_permission");
608 + agent.respond(
609 + permission["id"].clone(),
610 + json!({"outcome": {"outcome": "selected", "optionId": "reject_once"}}),
611 + );
612 +
613 + let finished = agent.wait_for("the denied call closing", |message| {
614 + call_update(message, "tool_call_update")
615 + });
616 + let finished = &finished["params"]["update"];
617 + assert_eq!(finished["status"], "failed", "{finished}");
618 + assert_eq!(
619 + finished["title"], announced["params"]["update"]["title"],
620 + "the card gets its own title back after the approval dialog: {finished}"
621 + );
622 +
623 + let response = agent.wait_for_response(prompt_id);
624 + assert_eq!(response["result"]["stopReason"], "end_turn");
625 +
626 + drop(agent);
627 + let _ = std::fs::remove_dir_all(&scratch);
628 +}
629 +
630 #[test]
631 fn successful_config_option_changes_are_rendered_as_system_status_cards() {
632 let endpoint = start_fake_endpoint(vec![]);
@@ -782,9 +858,21 @@ fn plan_permission_mode_denies_mutating_tools_without_asking() {
858 "prompt": [{"type": "text", "text": "run the command"}],
859 }),
860 );
785 - let (response, _updates) = agent.wait_for_response_with_updates(prompt_id);
861 + let (response, updates) = agent.wait_for_response_with_updates(prompt_id);
862 assert_eq!(response["result"]["stopReason"], "end_turn");
863
864 + // The tool never ran, so its card must not end as a success (issue #139).
865 + let closed = updates
866 + .iter()
867 + .rev()
868 + .find(|update| {
869 + update["sessionUpdate"] == "tool_call_update"
870 + && update["toolCallId"] == "call_1"
871 + && !update["status"].is_null()
872 + })
873 + .expect("the blocked call is closed out");
874 + assert_eq!(closed["status"], "failed", "{closed}");
875 +
876 let requests = endpoint.requests.lock().unwrap();
877 assert_eq!(
878 requests.len(),
@@ -1126,6 +1214,71 @@ fn an_unparseable_tool_call_is_hidden_and_retried() {
1214 let _ = std::fs::remove_dir_all(&scratch);
1215 }
1216
1217 +/// The third identical call is skipped by the repetition guard. It never ran,
1218 +/// so its card ends `failed` while the two that did run end `completed`
1219 +/// (issue #139).
1220 +#[test]
1221 +fn a_call_skipped_by_the_repeat_guard_ends_failed() {
1222 + let repeated_arguments = json!({"path": "notes.txt"}).to_string();
1223 + let endpoint = start_fake_endpoint(vec![
1224 + sse_tool_call("call_1", "read_file", &repeated_arguments),
1225 + sse_tool_call("call_2", "read_file", &repeated_arguments),
1226 + sse_tool_call("call_3", "read_file", &repeated_arguments),
1227 + sse_text("Stopping here."),
1228 + ]);
1229 +
1230 + let scratch =
1231 + std::env::temp_dir().join(format!("sigit_acp_repeat_failed_{}", std::process::id()));
1232 + let config_dir = scratch.join("config");
1233 + let cwd = scratch.join("cwd");
1234 + std::fs::create_dir_all(&config_dir).unwrap();
1235 + std::fs::create_dir_all(&cwd).unwrap();
1236 + std::fs::write(cwd.join("notes.txt"), "hello").unwrap();
1237 +
1238 + let mut agent = spawn_agent(endpoint.port, &config_dir);
1239 +
1240 + let id = agent.request(
1241 + "initialize",
1242 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
1243 + );
1244 + agent.wait_for_response(id);
1245 +
1246 + let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1247 + let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1248 + .as_str()
1249 + .expect("session id")
1250 + .to_string();
1251 +
1252 + let prompt_id = agent.request(
1253 + "session/prompt",
1254 + json!({
1255 + "sessionId": session_id,
1256 + "prompt": [{"type": "text", "text": "read the notes"}],
1257 + }),
1258 + );
1259 + let (response, updates) = agent.wait_for_response_with_updates(prompt_id);
1260 + assert_eq!(response["result"]["stopReason"], "end_turn");
1261 +
1262 + let final_status = |call_id: &str| {
1263 + updates
1264 + .iter()
1265 + .rev()
1266 + .find(|update| {
1267 + update["sessionUpdate"] == "tool_call_update"
1268 + && update["toolCallId"] == call_id
1269 + && !update["status"].is_null()
1270 + })
1271 + .unwrap_or_else(|| panic!("{call_id} is closed out: {updates:#?}"))["status"]
1272 + .clone()
1273 + };
1274 + assert_eq!(final_status("call_1"), "completed");
1275 + assert_eq!(final_status("call_2"), "completed");
1276 + assert_eq!(final_status("call_3"), "failed");
1277 +
1278 + drop(agent);
1279 + let _ = std::fs::remove_dir_all(&scratch);
1280 +}
1281 +
1282 /// Once the repetition guard removes tools from a request, a model may still
1283 /// emit its learned tool syntax. Known calls must be removed from the visible
1284 /// response without being executed or recorded as orphaned history entries.