diff --git a/hive-claude/src/classify.rs b/hive-claude/src/classify.rs index 93eb9255..b37511fc 100644 --- a/hive-claude/src/classify.rs +++ b/hive-claude/src/classify.rs @@ -50,10 +50,42 @@ pub(crate) struct Sentinels { } impl Sentinels { - /// Scan a raw line (stdout or stderr) for the always-on markers: - /// prompt-too-long, auth-failed, session-not-found. Rate-limit is handled - /// separately because on stdout it must only fire on JSON `error` events. - pub(crate) fn scan_line(&self, line: &str) { + /// Scan a CLI-authored line — stderr, or a non-JSON stdout line — for every + /// marker. These bytes are always claude-code's own output, never model + /// conversation, so all markers are trusted. + pub(crate) fn scan_cli_line(&self, line: &str) { + self.scan_failure_markers(line); + if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) { + self.rate_limited.store(true, Ordering::Relaxed); + } + } + + /// Scan a parsed stdout JSON event. **Skips model-authored `assistant` / + /// `user` message events**, whose serialized content can quote any marker + /// verbatim (an agent discussing this very code, say) — a false positive + /// that would otherwise trip a needless compact/retry or a spurious + /// auth/session error. Every real signal here is emitted *instead of* a + /// model turn (the API rejected the prompt, the auth failed, or `--resume` + /// missed before any inference), so it can only appear on a control event + /// (`error` / `result` / `system`) or as raw non-JSON text — never inside + /// an assistant/user message. This holds whatever exact shape claude-code + /// uses for the message, so the gate can't suppress a genuine signal. + pub(crate) fn scan_stdout_json(&self, event: &serde_json::Value, raw: &str) { + let ty = event.get("type").and_then(|t| t.as_str()); + if matches!(ty, Some("assistant" | "user")) { + return; + } + self.scan_failure_markers(raw); + // Rate-limit stays scoped to `error` events (unchanged): the only + // control event that carries a rate-limit marker. + if ty == Some("error") && RATE_LIMIT_MARKERS.iter().any(|m| raw.contains(m)) { + self.rate_limited.store(true, Ordering::Relaxed); + } + } + + /// The prompt-too-long / auth-failed / session-not-found markers. Callers + /// gate *where* this runs (see `scan_cli_line` / `scan_stdout_json`). + fn scan_failure_markers(&self, line: &str) { if line.contains(PROMPT_TOO_LONG_MARKER) { self.prompt_too_long.store(true, Ordering::Relaxed); } @@ -65,26 +97,6 @@ impl Sentinels { } } - /// Trust a rate-limit hit only on a JSON `error` event (so a model - /// *discussing* a rate limit in prose can't trigger it). The `type` gate - /// needs the parsed `event`; the marker match runs on `raw`, the original - /// line — the same bytes, so we don't re-serialize the value. - pub(crate) fn scan_stdout_json(&self, event: &serde_json::Value, raw: &str) { - if event.get("type").and_then(|t| t.as_str()) == Some("error") - && RATE_LIMIT_MARKERS.iter().any(|m| raw.contains(m)) - { - self.rate_limited.store(true, Ordering::Relaxed); - } - } - - /// Trust a rate-limit hit on raw text (non-JSON stdout, or any stderr) — - /// these are CLI messages, not conversation content. - pub(crate) fn scan_rate_limit_text(&self, line: &str) { - if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) { - self.rate_limited.store(true, Ordering::Relaxed); - } - } - /// The recognized-sentinel error, if any fired — `None` means no sentinel /// (so the run either completed or failed hard on its exit code). The /// sentinels keep a fixed priority (too-long > rate > auth); a @@ -105,3 +117,51 @@ impl Sentinels { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Error; + + fn json(raw: &str) -> serde_json::Value { + serde_json::from_str(raw).unwrap() + } + + #[test] + fn assistant_content_quoting_marker_is_ignored() { + // An agent discussing this code emits the marker verbatim in an + // assistant message — must NOT trip a sentinel. + let s = Sentinels::default(); + let raw = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"the CLI prints Prompt is too long on overflow"}]}}"#; + s.scan_stdout_json(&json(raw), raw); + assert!(s.soft_error().is_none()); + } + + #[test] + fn control_event_marker_is_detected() { + let s = Sentinels::default(); + let raw = r#"{"type":"result","subtype":"error","error":"Prompt is too long"}"#; + s.scan_stdout_json(&json(raw), raw); + assert!(matches!(s.soft_error(), Some(Error::PromptTooLong))); + } + + #[test] + fn raw_non_json_marker_is_detected() { + let s = Sentinels::default(); + s.scan_cli_line("API Error: Prompt is too long"); + assert!(matches!(s.soft_error(), Some(Error::PromptTooLong))); + } + + #[test] + fn rate_limit_still_only_on_error_event() { + // A non-error control event mentioning the marker must not trip it. + let s = Sentinels::default(); + let raw = r#"{"type":"result","summary":"we hit a rate_limit_error earlier"}"#; + s.scan_stdout_json(&json(raw), raw); + assert!(s.soft_error().is_none()); + // A genuine error event does. + let raw2 = r#"{"type":"error","error":{"type":"rate_limit_error"}}"#; + s.scan_stdout_json(&json(raw2), raw2); + assert!(matches!(s.soft_error(), Some(Error::RateLimited))); + } +} diff --git a/hive-claude/src/driver.rs b/hive-claude/src/driver.rs index b790522f..7b808ad1 100644 --- a/hive-claude/src/driver.rs +++ b/hive-claude/src/driver.rs @@ -152,12 +152,14 @@ fn build_command(program: &str, config: &Config, attach: &Attach) -> Command { async fn pump_stdout(stdout: ChildStdout, sink: &impl Sink, sentinels: &Sentinels) { let mut lines = BufReader::new(stdout).lines(); while let Ok(Some(line)) = lines.next_line().await { - sentinels.scan_line(&line); if let Ok(event) = serde_json::from_str::(&line) { + // JSON stdout: classify with the model-content gate so an + // `assistant`/`user` message quoting a marker can't trip it. sentinels.scan_stdout_json(&event, &line); sink.on_event(&event); } else { - sentinels.scan_rate_limit_text(&line); + // Non-JSON stdout is CLI text, not conversation — trust all markers. + sentinels.scan_cli_line(&line); sink.on_stdout_line(&line); } } @@ -170,8 +172,8 @@ async fn pump_stderr(stderr: ChildStderr, sink: &impl Sink, sentinels: &Sentinel let mut lines = BufReader::new(stderr).lines(); let mut tail: VecDeque = VecDeque::with_capacity(STDERR_TAIL_LINES); while let Ok(Some(line)) = lines.next_line().await { - sentinels.scan_line(&line); - sentinels.scan_rate_limit_text(&line); + // stderr is always CLI output — trust all markers. + sentinels.scan_cli_line(&line); sink.on_stderr_line(&line); if tail.len() >= STDERR_TAIL_LINES { tail.pop_front();