diff --git a/crates/forge_app/src/dto/anthropic/response.rs b/crates/forge_app/src/dto/anthropic/response.rs index 8837a25604..ef0cd78ac6 100644 --- a/crates/forge_app/src/dto/anthropic/response.rs +++ b/crates/forge_app/src/dto/anthropic/response.rs @@ -205,6 +205,7 @@ pub enum StopReason { MaxTokens, StopSequence, ToolUse, + Refusal, } impl From for forge_domain::FinishReason { @@ -214,6 +215,7 @@ impl From for forge_domain::FinishReason { StopReason::MaxTokens => forge_domain::FinishReason::Length, StopReason::StopSequence => forge_domain::FinishReason::Stop, StopReason::ToolUse => forge_domain::FinishReason::ToolCalls, + StopReason::Refusal => forge_domain::FinishReason::ContentFilter, } } } @@ -844,4 +846,36 @@ mod tests { assert_eq!(actual.usage, None); } + + #[test] + fn test_message_delta_refusal_stop_reason() { + // Fable returns HTTP 200 with stop_reason "refusal" when safety + // classifiers decline a request. This must parse as a known event, + // not fall through to EventData::Unknown (which silently drops the + // finish reason and triggers an empty-completion retry loop). + let fixture = r#"{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null},"usage":{"output_tokens":0}}"#; + + let actual = serde_json::from_str::(fixture).unwrap(); + + let expected = EventData::KnownEvent(Event::MessageDelta { + delta: MessageDelta { stop_reason: StopReason::Refusal, stop_sequence: None }, + usage: Usage { + input_tokens: None, + output_tokens: Some(0), + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + }, + }); + assert_eq!(actual, expected); + } + + #[test] + fn test_refusal_maps_to_content_filter() { + let fixture = StopReason::Refusal; + + let actual = forge_domain::FinishReason::from(fixture); + + let expected = forge_domain::FinishReason::ContentFilter; + assert_eq!(actual, expected); + } } diff --git a/crates/forge_domain/src/error.rs b/crates/forge_domain/src/error.rs index 02d8f60529..31db4e4203 100644 --- a/crates/forge_domain/src/error.rs +++ b/crates/forge_domain/src/error.rs @@ -74,6 +74,13 @@ pub enum Error { #[error("Empty completion received - no content, tool calls, or valid finish reason")] EmptyCompletion, + #[error( + "The model refused to generate a response (safety/content filter). \ + Retrying the same request will produce the same refusal - rephrase \ + the request or switch to a different model." + )] + Refusal, + #[error(transparent)] Retryable(anyhow::Error), diff --git a/crates/forge_domain/src/result_stream_ext.rs b/crates/forge_domain/src/result_stream_ext.rs index 9250ff0adc..46e08e7452 100644 --- a/crates/forge_domain/src/result_stream_ext.rs +++ b/crates/forge_domain/src/result_stream_ext.rs @@ -4,7 +4,7 @@ use tokio_stream::StreamExt; use crate::reasoning::{Reasoning, ReasoningFull}; use crate::{ ArcSender, ChatCompletionMessage, ChatCompletionMessageFull, ChatResponse, ChatResponseContent, - ToolCallFull, ToolCallPart, Usage, + FinishReason, ToolCallFull, ToolCallPart, Usage, }; /// Extension trait for ResultStream to provide additional functionality @@ -259,6 +259,14 @@ impl ResultStreamExt for crate::BoxStream = + Box::pin(tokio_stream::iter(messages)); + + let actual = fixture.into_full(false).await.unwrap_err(); + + let domain_error = actual.downcast_ref::().unwrap(); + assert!(matches!(domain_error, crate::Error::Refusal)); + } + + #[tokio::test] + async fn test_into_full_refusal_with_partial_content_is_error() { + // Mid-stream refusals can arrive after partial output. The partial + // text has already been streamed to the UI; the turn still must end + // with the refusal error rather than looping. + let messages = vec![ + Ok(ChatCompletionMessage::assistant(Content::part( + "I was about to say", + ))), + Ok(ChatCompletionMessage::assistant(Content::part("")) + .finish_reason(FinishReason::ContentFilter)), + ]; + let fixture: BoxStream = + Box::pin(tokio_stream::iter(messages)); + + let actual = fixture.into_full(false).await.unwrap_err(); + + let domain_error = actual.downcast_ref::().unwrap(); + assert!(matches!(domain_error, crate::Error::Refusal)); + } + #[tokio::test] async fn test_into_full_empty_completion_with_tool_calls_should_not_error() { // Fixture: Create a stream with empty content but with tool calls