Handle model refusal in ACP threads (#37383)

Richard Feldman created

If the model refuses a prompt, we now:
* Show an error if it was a user prompt (and truncate it out of the
history)
* Respond with a failed tool call if the refusal was for a tool call

<img width="607" height="260" alt="Screenshot 2025-09-02 at 5 11 45 PM"
src="https://github.com/user-attachments/assets/070b5ee7-6ad6-4a63-8395-f9a5093cc40e"
/>
<img width="607" height="265" alt="Screenshot 2025-09-02 at 5 11 38 PM"
src="https://github.com/user-attachments/assets/98862586-390b-494e-b1f8-71d8341c8d9d"
/>



Release Notes:

- Improve handling of model refusals in ACP threads

Change summary

crates/acp_thread/src/acp_thread.rs               | 223 ++++++++++++++++
crates/agent_ui/src/acp/model_selector_popover.rs |   8 
crates/agent_ui/src/acp/thread_view.rs            | 137 ++++++++++
crates/agent_ui/src/active_thread.rs              |  16 +
crates/agent_ui/src/agent_diff.rs                 |   5 
crates/agent_ui/src/agent_panel.rs                |   9 
6 files changed, 387 insertions(+), 11 deletions(-)

Detailed changes

crates/acp_thread/src/acp_thread.rs 🔗

@@ -804,6 +804,7 @@ pub enum AcpThreadEvent {
     Error,
     LoadError(LoadError),
     PromptCapabilitiesUpdated,
+    Refusal,
 }
 
 impl EventEmitter<AcpThreadEvent> for AcpThread {}
@@ -1569,15 +1570,42 @@ impl AcpThread {
                             this.send_task.take();
                         }
 
-                        // Truncate entries if the last prompt was refused.
+                        // Handle refusal - distinguish between user prompt and tool call refusals
                         if let Ok(Ok(acp::PromptResponse {
                             stop_reason: acp::StopReason::Refusal,
                         })) = result
-                            && let Some((ix, _)) = this.last_user_message()
                         {
-                            let range = ix..this.entries.len();
-                            this.entries.truncate(ix);
-                            cx.emit(AcpThreadEvent::EntriesRemoved(range));
+                            if let Some((user_msg_ix, _)) = this.last_user_message() {
+                                // Check if there's a completed tool call with results after the last user message
+                                // This indicates the refusal is in response to tool output, not the user's prompt
+                                let has_completed_tool_call_after_user_msg =
+                                    this.entries.iter().skip(user_msg_ix + 1).any(|entry| {
+                                        if let AgentThreadEntry::ToolCall(tool_call) = entry {
+                                            // Check if the tool call has completed and has output
+                                            matches!(tool_call.status, ToolCallStatus::Completed)
+                                                && tool_call.raw_output.is_some()
+                                        } else {
+                                            false
+                                        }
+                                    });
+
+                                if has_completed_tool_call_after_user_msg {
+                                    // Refusal is due to tool output - don't truncate, just notify
+                                    // The model refused based on what the tool returned
+                                    cx.emit(AcpThreadEvent::Refusal);
+                                } else {
+                                    // User prompt was refused - truncate back to before the user message
+                                    let range = user_msg_ix..this.entries.len();
+                                    if range.start < range.end {
+                                        this.entries.truncate(user_msg_ix);
+                                        cx.emit(AcpThreadEvent::EntriesRemoved(range));
+                                    }
+                                    cx.emit(AcpThreadEvent::Refusal);
+                                }
+                            } else {
+                                // No user message found, treat as general refusal
+                                cx.emit(AcpThreadEvent::Refusal);
+                            }
                         }
 
                         cx.emit(AcpThreadEvent::Stopped);
@@ -2681,6 +2709,187 @@ mod tests {
         assert_eq!(fs.files(), vec![Path::new(path!("/test/file-0"))]);
     }
 
+    #[gpui::test]
+    async fn test_tool_result_refusal(cx: &mut TestAppContext) {
+        use std::sync::atomic::AtomicUsize;
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, None, cx).await;
+
+        // Create a connection that simulates refusal after tool result
+        let prompt_count = Arc::new(AtomicUsize::new(0));
+        let connection = Rc::new(FakeAgentConnection::new().on_user_message({
+            let prompt_count = prompt_count.clone();
+            move |_request, thread, mut cx| {
+                let count = prompt_count.fetch_add(1, SeqCst);
+                async move {
+                    if count == 0 {
+                        // First prompt: Generate a tool call with result
+                        thread.update(&mut cx, |thread, cx| {
+                            thread
+                                .handle_session_update(
+                                    acp::SessionUpdate::ToolCall(acp::ToolCall {
+                                        id: acp::ToolCallId("tool1".into()),
+                                        title: "Test Tool".into(),
+                                        kind: acp::ToolKind::Fetch,
+                                        status: acp::ToolCallStatus::Completed,
+                                        content: vec![],
+                                        locations: vec![],
+                                        raw_input: Some(serde_json::json!({"query": "test"})),
+                                        raw_output: Some(
+                                            serde_json::json!({"result": "inappropriate content"}),
+                                        ),
+                                    }),
+                                    cx,
+                                )
+                                .unwrap();
+                        })?;
+
+                        // Now return refusal because of the tool result
+                        Ok(acp::PromptResponse {
+                            stop_reason: acp::StopReason::Refusal,
+                        })
+                    } else {
+                        Ok(acp::PromptResponse {
+                            stop_reason: acp::StopReason::EndTurn,
+                        })
+                    }
+                }
+                .boxed_local()
+            }
+        }));
+
+        let thread = cx
+            .update(|cx| connection.new_thread(project, Path::new("/test"), cx))
+            .await
+            .unwrap();
+
+        // Track if we see a Refusal event
+        let saw_refusal_event = Arc::new(std::sync::Mutex::new(false));
+        let saw_refusal_event_captured = saw_refusal_event.clone();
+        thread.update(cx, |_thread, cx| {
+            cx.subscribe(
+                &thread,
+                move |_thread, _event_thread, event: &AcpThreadEvent, _cx| {
+                    if matches!(event, AcpThreadEvent::Refusal) {
+                        *saw_refusal_event_captured.lock().unwrap() = true;
+                    }
+                },
+            )
+            .detach();
+        });
+
+        // Send a user message - this will trigger tool call and then refusal
+        let send_task = thread.update(cx, |thread, cx| {
+            thread.send(
+                vec![acp::ContentBlock::Text(acp::TextContent {
+                    text: "Hello".into(),
+                    annotations: None,
+                })],
+                cx,
+            )
+        });
+        cx.background_executor.spawn(send_task).detach();
+        cx.run_until_parked();
+
+        // Verify that:
+        // 1. A Refusal event WAS emitted (because it's a tool result refusal, not user prompt)
+        // 2. The user message was NOT truncated
+        assert!(
+            *saw_refusal_event.lock().unwrap(),
+            "Refusal event should be emitted for tool result refusals"
+        );
+
+        thread.read_with(cx, |thread, _| {
+            let entries = thread.entries();
+            assert!(entries.len() >= 2, "Should have user message and tool call");
+
+            // Verify user message is still there
+            assert!(
+                matches!(entries[0], AgentThreadEntry::UserMessage(_)),
+                "User message should not be truncated"
+            );
+
+            // Verify tool call is there with result
+            if let AgentThreadEntry::ToolCall(tool_call) = &entries[1] {
+                assert!(
+                    tool_call.raw_output.is_some(),
+                    "Tool call should have output"
+                );
+            } else {
+                panic!("Expected tool call at index 1");
+            }
+        });
+    }
+
+    #[gpui::test]
+    async fn test_user_prompt_refusal_emits_event(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, None, cx).await;
+
+        let refuse_next = Arc::new(AtomicBool::new(false));
+        let connection = Rc::new(FakeAgentConnection::new().on_user_message({
+            let refuse_next = refuse_next.clone();
+            move |_request, _thread, _cx| {
+                if refuse_next.load(SeqCst) {
+                    async move {
+                        Ok(acp::PromptResponse {
+                            stop_reason: acp::StopReason::Refusal,
+                        })
+                    }
+                    .boxed_local()
+                } else {
+                    async move {
+                        Ok(acp::PromptResponse {
+                            stop_reason: acp::StopReason::EndTurn,
+                        })
+                    }
+                    .boxed_local()
+                }
+            }
+        }));
+
+        let thread = cx
+            .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+            .await
+            .unwrap();
+
+        // Track if we see a Refusal event
+        let saw_refusal_event = Arc::new(std::sync::Mutex::new(false));
+        let saw_refusal_event_captured = saw_refusal_event.clone();
+        thread.update(cx, |_thread, cx| {
+            cx.subscribe(
+                &thread,
+                move |_thread, _event_thread, event: &AcpThreadEvent, _cx| {
+                    if matches!(event, AcpThreadEvent::Refusal) {
+                        *saw_refusal_event_captured.lock().unwrap() = true;
+                    }
+                },
+            )
+            .detach();
+        });
+
+        // Send a message that will be refused
+        refuse_next.store(true, SeqCst);
+        cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx)))
+            .await
+            .unwrap();
+
+        // Verify that a Refusal event WAS emitted for user prompt refusal
+        assert!(
+            *saw_refusal_event.lock().unwrap(),
+            "Refusal event should be emitted for user prompt refusals"
+        );
+
+        // Verify the message was truncated (user prompt refusal)
+        thread.read_with(cx, |thread, cx| {
+            assert_eq!(thread.to_markdown(cx), "");
+        });
+    }
+
     #[gpui::test]
     async fn test_refusal(cx: &mut TestAppContext) {
         init_test(cx);
@@ -2744,8 +2953,8 @@ mod tests {
             );
         });
 
-        // Simulate refusing the second message, ensuring the conversation gets
-        // truncated to before sending it.
+        // Simulate refusing the second message. The message should be truncated
+        // when a user prompt is refused.
         refuse_next.store(true, SeqCst);
         cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["world".into()], cx)))
             .await

crates/agent_ui/src/acp/model_selector_popover.rs 🔗

@@ -36,6 +36,14 @@ impl AcpModelSelectorPopover {
     pub fn toggle(&self, window: &mut Window, cx: &mut Context<Self>) {
         self.menu_handle.toggle(window, cx);
     }
+
+    pub fn active_model_name(&self, cx: &App) -> Option<SharedString> {
+        self.selector
+            .read(cx)
+            .delegate
+            .active_model()
+            .map(|model| model.name.clone())
+    }
 }
 
 impl Render for AcpModelSelectorPopover {

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -78,10 +78,12 @@ enum ThreadFeedback {
     Negative,
 }
 
+#[derive(Debug)]
 enum ThreadError {
     PaymentRequired,
     ModelRequestLimitReached(cloud_llm_client::Plan),
     ToolUseLimitReached,
+    Refusal,
     AuthenticationRequired(SharedString),
     Other(SharedString),
 }
@@ -1255,6 +1257,14 @@ impl AcpThreadView {
                     cx,
                 );
             }
+            AcpThreadEvent::Refusal => {
+                self.thread_retry_status.take();
+                self.thread_error = Some(ThreadError::Refusal);
+                let model_or_agent_name = self.get_current_model_name(cx);
+                let notification_message =
+                    format!("{} refused to respond to this request", model_or_agent_name);
+                self.notify_with_sound(&notification_message, IconName::Warning, window, cx);
+            }
             AcpThreadEvent::Error => {
                 self.thread_retry_status.take();
                 self.notify_with_sound(
@@ -4740,6 +4750,7 @@ impl AcpThreadView {
     fn render_thread_error(&self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
         let content = match self.thread_error.as_ref()? {
             ThreadError::Other(error) => self.render_any_thread_error(error.clone(), cx),
+            ThreadError::Refusal => self.render_refusal_error(cx),
             ThreadError::AuthenticationRequired(error) => {
                 self.render_authentication_required_error(error.clone(), cx)
             }
@@ -4755,6 +4766,43 @@ impl AcpThreadView {
         Some(div().child(content))
     }
 
+    fn get_current_model_name(&self, cx: &App) -> SharedString {
+        // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
+        // For ACP agents, use the agent name (e.g., "Claude Code", "Gemini CLI")
+        // This provides better clarity about what refused the request
+        if self
+            .agent
+            .clone()
+            .downcast::<agent2::NativeAgentServer>()
+            .is_some()
+        {
+            // Native agent - use the model name
+            self.model_selector
+                .as_ref()
+                .and_then(|selector| selector.read(cx).active_model_name(cx))
+                .unwrap_or_else(|| SharedString::from("The model"))
+        } else {
+            // ACP agent - use the agent name (e.g., "Claude Code", "Gemini CLI")
+            self.agent.name()
+        }
+    }
+
+    fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout {
+        let model_or_agent_name = self.get_current_model_name(cx);
+        let refusal_message = format!(
+            "{} refused to respond to this prompt. This can happen when a model believes the prompt violates its content policy or safety guidelines, so rephrasing it can sometimes address the issue.",
+            model_or_agent_name
+        );
+
+        Callout::new()
+            .severity(Severity::Error)
+            .title("Request Refused")
+            .icon(IconName::XCircle)
+            .description(refusal_message.clone())
+            .actions_slot(self.create_copy_button(&refusal_message))
+            .dismiss_action(self.dismiss_error_button(cx))
+    }
+
     fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout {
         let can_resume = self
             .thread()
@@ -5382,6 +5430,33 @@ pub(crate) mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_refusal_handling(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let (thread_view, cx) =
+            setup_thread_view(StubAgentServer::new(RefusalAgentConnection), cx).await;
+
+        let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
+        message_editor.update_in(cx, |editor, window, cx| {
+            editor.set_text("Do something harmful", window, cx);
+        });
+
+        thread_view.update_in(cx, |thread_view, window, cx| {
+            thread_view.send(window, cx);
+        });
+
+        cx.run_until_parked();
+
+        // Check that the refusal error is set
+        thread_view.read_with(cx, |thread_view, _cx| {
+            assert!(
+                matches!(thread_view.thread_error, Some(ThreadError::Refusal)),
+                "Expected refusal error to be set"
+            );
+        });
+    }
+
     #[gpui::test]
     async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
         init_test(cx);
@@ -5617,6 +5692,68 @@ pub(crate) mod tests {
         }
     }
 
+    /// Simulates a model which always returns a refusal response
+    #[derive(Clone)]
+    struct RefusalAgentConnection;
+
+    impl AgentConnection for RefusalAgentConnection {
+        fn new_thread(
+            self: Rc<Self>,
+            project: Entity<Project>,
+            _cwd: &Path,
+            cx: &mut gpui::App,
+        ) -> Task<gpui::Result<Entity<AcpThread>>> {
+            Task::ready(Ok(cx.new(|cx| {
+                let action_log = cx.new(|_| ActionLog::new(project.clone()));
+                AcpThread::new(
+                    "RefusalAgentConnection",
+                    self,
+                    project,
+                    action_log,
+                    SessionId("test".into()),
+                    watch::Receiver::constant(acp::PromptCapabilities {
+                        image: true,
+                        audio: true,
+                        embedded_context: true,
+                    }),
+                    Vec::new(),
+                    cx,
+                )
+            })))
+        }
+
+        fn auth_methods(&self) -> &[acp::AuthMethod] {
+            &[]
+        }
+
+        fn authenticate(
+            &self,
+            _method_id: acp::AuthMethodId,
+            _cx: &mut App,
+        ) -> Task<gpui::Result<()>> {
+            unimplemented!()
+        }
+
+        fn prompt(
+            &self,
+            _id: Option<acp_thread::UserMessageId>,
+            _params: acp::PromptRequest,
+            _cx: &mut App,
+        ) -> Task<gpui::Result<acp::PromptResponse>> {
+            Task::ready(Ok(acp::PromptResponse {
+                stop_reason: acp::StopReason::Refusal,
+            }))
+        }
+
+        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
+            unimplemented!()
+        }
+
+        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+            self
+        }
+    }
+
     pub(crate) fn init_test(cx: &mut TestAppContext) {
         cx.update(|cx| {
             let settings_store = SettingsStore::test(cx);

crates/agent_ui/src/active_thread.rs 🔗

@@ -1001,8 +1001,22 @@ impl ActiveThread {
                         // Don't notify for intermediate tool use
                     }
                     Ok(StopReason::Refusal) => {
+                        let model_name = self
+                            .thread
+                            .read(cx)
+                            .configured_model()
+                            .map(|configured| configured.model.name().0.to_string())
+                            .unwrap_or_else(|| "The model".to_string());
+                        let refusal_message = format!(
+                            "{} refused to respond to this prompt. This can happen when a model believes the prompt violates its content policy or safety guidelines, so rephrasing it can sometimes address the issue.",
+                            model_name
+                        );
+                        self.last_error = Some(ThreadError::Message {
+                            header: SharedString::from("Request Refused"),
+                            message: SharedString::from(refusal_message),
+                        });
                         self.notify_with_sound(
-                            "Language model refused to respond",
+                            format!("{} refused to respond", model_name),
                             IconName::Warning,
                             window,
                             cx,

crates/agent_ui/src/agent_diff.rs 🔗

@@ -1517,7 +1517,10 @@ impl AgentDiff {
                     self.update_reviewing_editors(workspace, window, cx);
                 }
             }
-            AcpThreadEvent::Stopped | AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) => {
+            AcpThreadEvent::Stopped
+            | AcpThreadEvent::Error
+            | AcpThreadEvent::LoadError(_)
+            | AcpThreadEvent::Refusal => {
                 self.update_reviewing_editors(workspace, window, cx);
             }
             AcpThreadEvent::TitleUpdated

crates/agent_ui/src/agent_panel.rs 🔗

@@ -3532,6 +3532,11 @@ impl AgentPanel {
     ) -> AnyElement {
         let message_with_header = format!("{}\n{}", header, message);
 
+        // Don't show Retry button for refusals
+        let is_refusal = header == "Request Refused";
+        let retry_button = self.render_retry_button(thread);
+        let copy_button = self.create_copy_button(message_with_header);
+
         Callout::new()
             .severity(Severity::Error)
             .icon(IconName::XCircle)
@@ -3540,8 +3545,8 @@ impl AgentPanel {
             .actions_slot(
                 h_flex()
                     .gap_0p5()
-                    .child(self.render_retry_button(thread))
-                    .child(self.create_copy_button(message_with_header)),
+                    .when(!is_refusal, |this| this.child(retry_button))
+                    .child(copy_button),
             )
             .dismiss_action(self.dismiss_error_button(thread, cx))
             .into_any_element()