diff --git a/hive-claude/src/classify.rs b/hive-claude/src/classify.rs index 826bacf3..95a11292 100644 --- a/hive-claude/src/classify.rs +++ b/hive-claude/src/classify.rs @@ -75,18 +75,29 @@ impl Sentinels { if matches!(ty, Some("assistant" | "user")) { return; } - // The terminal `result` event carries the model's final answer in its - // `result` field — model-authored text, same trust level as an - // assistant message. A marker quoted there (e.g. the model explaining - // *this* code, or echoing an error string back) would falsely trip a - // sentinel and needlessly kill/downgrade the turn. So for a `result` - // event, scan the control fields (`subtype` / `error` / `is_error`) - // but scrub the model-authored `result` field first; a genuine - // prompt-too-long/auth/session signal lives in those control fields - // (it's emitted *instead of* a successful model answer), never in the - // `result` text. Other control events (`error` / `system`) are - // claude-authored end to end, so they scan raw. - if ty == Some("result") { + // A `result` event's `result` field is the model's final answer on a + // SUCCESSFUL turn — model-authored text, same trust level as an + // assistant message. A marker quoted there (the model explaining *this* + // code, or echoing an error string back) would falsely trip a sentinel + // and needlessly kill/downgrade the turn, so for a *success* result we + // scrub `result` before scanning and rely on the control fields. + // + // But on an ERROR result the same `result` field carries claude-code's + // OWN failure text — a genuine "Prompt is too long" (an API 400) lands + // there, emitted *instead of* a model answer. The model can't forge + // `is_error` / a non-`success` subtype (the CLI sets them from the real + // outcome), so an error result is claude-authored end to end and is + // safe to scan raw. Scrubbing it unconditionally (the original + // result-scrub fix) blinded prompt-too-long / auth detection — hence + // keying the scrub on success only. Other control events are + // claude-authored throughout, so they also scan raw. + let error_result = ty == Some("result") + && (event.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) + || event + .get("subtype") + .and_then(serde_json::Value::as_str) + .is_some_and(|s| s != "success")); + if ty == Some("result") && !error_result { let mut scrubbed = event.clone(); if let Some(obj) = scrubbed.as_object_mut() { obj.remove("result"); @@ -164,6 +175,26 @@ mod tests { assert!(matches!(s.soft_error(), Some(Error::PromptTooLong))); } + #[test] + fn prompt_too_long_in_error_result_field_is_detected() { + // Regression guard: claude-code reports a genuine "Prompt is too long" + // (API 400) in the `result` field of an ERROR result event. The earlier + // result-scrub blinded this by stripping `result` unconditionally. An + // error result is claude-authored (the model can't forge `is_error`), + // so it must be scanned raw. + for raw in [ + r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"API Error: 400 Prompt is too long"}"#, + r#"{"type":"result","subtype":"success","is_error":true,"result":"Prompt is too long"}"#, + ] { + let s = Sentinels::default(); + s.scan_stdout_json(&json(raw), raw); + assert!( + matches!(s.soft_error(), Some(Error::PromptTooLong)), + "prompt-too-long in an error result's `result` field must be detected: {raw}" + ); + } + } + #[test] fn result_field_model_text_quoting_marker_is_ignored() { // The terminal `result` event's `result` field is the model's final