diff --git a/crates/mu-ai/src/providers/anthropic_tests.rs b/crates/mu-ai/src/providers/anthropic_tests.rs index 3a69ba99..30f38a0e 100644 --- a/crates/mu-ai/src/providers/anthropic_tests.rs +++ b/crates/mu-ai/src/providers/anthropic_tests.rs @@ -997,8 +997,11 @@ fn map_stop_reason_known_and_unknown() { assert_eq!(map_stop_reason(Some(&A::EndTurn)), StopReason::EndTurn); assert_eq!(map_stop_reason(Some(&A::ToolUse)), StopReason::ToolUse); assert_eq!(map_stop_reason(Some(&A::MaxTokens)), StopReason::MaxTokens); - // unmapped wire reasons (stop_sequence / refusal / pause_turn / unknown) - // all fold to EndTurn. + // Gen-5 terminal states map to their own core reasons + // (mu-provider-drift-2026q3-y43la). + assert_eq!(map_stop_reason(Some(&A::Refusal)), StopReason::Refusal); + assert_eq!(map_stop_reason(Some(&A::PauseTurn)), StopReason::PauseTurn); + // stop_sequence and unknown/absent reasons fold to EndTurn. assert_eq!(map_stop_reason(Some(&A::StopSequence)), StopReason::EndTurn); assert_eq!(map_stop_reason(Some(&A::Other)), StopReason::EndTurn); assert_eq!(map_stop_reason(None), StopReason::EndTurn); diff --git a/crates/mu-coding/src/ask.rs b/crates/mu-coding/src/ask.rs index c9f08872..1c4415dd 100644 --- a/crates/mu-coding/src/ask.rs +++ b/crates/mu-coding/src/ask.rs @@ -156,6 +156,25 @@ pub async fn run(opts: AskOptions) -> Result<()> { closed without a terminal stop event (connection drop or upstream \ truncation). Output above may be a fragment." ), + // mu-provider-drift-2026q3-y43la: the gen-5 terminal states must not + // exit 0 — a refused ask has no answer, and a server-paused ask ended + // early. Same rationale as max_tokens: the nonzero exit keeps + // headless scoring pipelines and spawn callers from reading a + // non-answer as a clean success. Edge (panel-raised): if a refusal + // ever cut a turn that ALSO completed a final_answer call, the + // answer still prints above but the exit is nonzero — deliberate: + // an answer the safety classifier cut mid-delivery should force + // caller scrutiny, not score as clean. + Some("refusal") => bail!( + "ask refused (stop_reason=refusal): the provider's safety \ + classifier declined the request or cut generation. There is no \ + answer; consider a different seat/model." + ), + Some("pause_turn") => bail!( + "response paused (stop_reason=pause_turn): the server paused a \ + long-running turn and mu does not implement continuation. \ + Output above may be partial." + ), _ => Ok(()), } } diff --git a/crates/mu-coding/src/resume.rs b/crates/mu-coding/src/resume.rs index 4c551e11..23763acb 100644 --- a/crates/mu-coding/src/resume.rs +++ b/crates/mu-coding/src/resume.rs @@ -154,6 +154,21 @@ pub async fn run(opts: ResumeOptions) -> Result<()> { Some("degraded_eof") => { bail!("response degraded (stop_reason=degraded_eof). Output above may be a fragment.") } + // Same guard as ask.rs (mu-provider-drift-2026q3-y43la): a refused or + // server-paused resumed ask must not exit 0 as if it answered. + Some("refusal") => { + bail!( + "ask refused (stop_reason=refusal): the provider's safety classifier \ + declined the request or cut generation. There is no answer." + ) + } + Some("pause_turn") => { + bail!( + "response paused (stop_reason=pause_turn): the server paused a \ + long-running turn and mu does not implement continuation. Output \ + above may be partial." + ) + } _ => Ok(()), } }