diff --git a/src/core/observability.rs b/src/core/observability.rs index 7a264d0061..fb1c093ec8 100644 --- a/src/core/observability.rs +++ b/src/core/observability.rs @@ -181,26 +181,6 @@ pub enum ExpectedErrorKind { /// deliberately not matched because that's an `rm -rf` invariant /// violation, not user input. FilesystemUserPathInvalid, - /// A memory-store write (document upsert or KV set) was rejected because - /// the namespace or key contained what the PII guard classified as a - /// personal identifier (national ID, phone number, formatted credential, - /// etc.). The guard fires *before* the write reaches SQLite so no data - /// is persisted, and the LLM or caller that triggered the write already - /// receives the error string. Sentry has no remediation path — the fix - /// is either a less aggressive namespace/key choice from the caller or a - /// PII-guard allowlist update — and the volume is high from a single user - /// (TAURI-RUST-54T: 915 events, escalating), indicating that the guard - /// is flagging false positives on valid channel names or usernames used - /// as namespace/key identifiers. Demote to `warn` so the breadcrumb - /// survives for local diagnosis but Sentry sees no error event. - /// - /// Canonical wire shapes (from `memory_store/unified/documents.rs` and - /// `memory_store/kv.rs`): - /// - /// - `"document namespace/key cannot contain personal identifiers"` - /// - `"kv key cannot contain personal identifiers"` - /// - `"kv namespace/key cannot contain personal identifiers"` - MemoryStorePiiRejection, /// The provider/model completed a turn with a completely empty body /// (`text_chars=0 thinking_chars=0 tool_calls=0`), so the agent harness /// bailed with the user-facing `"The model returned an empty response. @@ -253,17 +233,7 @@ pub fn expected_error_kind(message: &str) -> Option { if lower.contains("local ai is disabled") { return Some(ExpectedErrorKind::LocalAiDisabled); } - // `_api_key is not configured` catches backend-reported environment variable - // phrases like `VOYAGE_API_KEY is not configured` and - // `COHERE_API_KEY is not configured` returned by the embeddings backend - // when the relevant env var is absent (TAURI-RUST-2H5, ~5 K events). - // The `_api_key` anchor (lower-cased suffix of an env-var name) keeps - // generic "X is not configured" prose from being silenced — only - // ALL_CAPS_API_KEY-style names match. - if lower.contains("api key not set") - || lower.contains("missing api key") - || lower.contains("_api_key is not configured") - { + if lower.contains("api key not set") || lower.contains("missing api key") { return Some(ExpectedErrorKind::ApiKeyMissing); } // Check `ChannelSupervisorRestart` BEFORE `is_loopback_unavailable` and @@ -370,9 +340,6 @@ pub fn expected_error_kind(message: &str) -> Option { if is_config_load_timed_out_message(&lower) { return Some(ExpectedErrorKind::ConfigLoadTimedOut); } - if is_memory_store_pii_rejection(&lower) { - return Some(ExpectedErrorKind::MemoryStorePiiRejection); - } // Empty-provider-response re-report from the web-channel layer. Runs // last so an earlier, more specific matcher always wins. See the // variant doc-comment and [`is_empty_provider_response_message`] for @@ -389,86 +356,9 @@ pub fn expected_error_kind(message: &str) -> Option { if is_filesystem_user_path_invalid_message(&lower) { return Some(ExpectedErrorKind::FilesystemUserPathInvalid); } - // Upstream rate-limit responses — provider throttles the account (429) or - // wraps the 429 inside an HTTP 500 (`"429 rate limit exceeded"` in the - // body). In both cases the reliable-provider layer already retries with - // backoff, and the embeddings path has a proactive token-bucket limiter - // (`embeddings::rate_limit`). The upstream quota is an account-capacity - // signal, not a code bug — Sentry has no remediation path and the - // per-attempt events generate pure noise (OPENHUMAN-TAURI-S: ~6 984 - // events from HTTP 500 wrapping a "429 rate limit exceeded" body; - // OPENHUMAN-TAURI-6Y: ~19 849 events from direct 429s; OPENHUMAN-TAURI-2E: - // ~1 482 events carrying a `"rate_limit_error"` type in the JSON body; - // OPENHUMAN-TAURI-RQ: ~741 events from the embeddings path). - // - // Checked LAST inside `expected_error_kind` — transient HTTP status matches - // (`is_transient_upstream_http_message`) are already caught by the earlier - // arm, so this arm only adds coverage for the 500-wrapping-429 body shape - // and provider JSON envelopes that name the error type explicitly. - if is_upstream_rate_limit_message(&lower) { - return Some(ExpectedErrorKind::TransientUpstreamHttp); - } None } -/// Detect upstream rate-limit error bodies that bubble up from any provider -/// or embedding API call site. -/// -/// Covers three observed wire shapes: -/// -/// 1. **OpenAI / Anthropic JSON body** — `"rate_limit_error"` is the `"type"` -/// field in the structured error object: -/// `{"error":{"message":"Rate limit exceeded.","type":"rate_limit_error"}}` -/// (OPENHUMAN-TAURI-2E / -RQ). -/// -/// 2. **OpenHuman backend wrapping upstream** — `"Upstream rate limit exceeded -/// for model 'summarization-v1'. Please retry shortly."` embedded in a 500 -/// response body (OPENHUMAN-TAURI-6Y / -7H). -/// -/// 3. **Plain phrase** — `"429 rate limit exceeded, please try again later"` / -/// `"rate limit exceeded"` from any other upstream (OPENHUMAN-TAURI-S). -/// -/// The match is against the full lowercased error string (including any -/// caller wrapping prefix), so it survives `agent.run_single` / `rpc.invoke_method` -/// re-reports as well as the original call-site emit. -/// -/// **Polarity contract**: this predicate is *inclusive* — it returns `true` -/// only for messages that are unambiguously rate-limit throttle signals. It -/// must NOT match unrelated errors that incidentally mention "limit" or "rate" -/// (e.g. action-budget `"Rate limit exceeded: action budget exhausted"` -/// from `security::policy` — distinguished by the `"action budget"` anchor). -pub fn is_upstream_rate_limit_message(lower: &str) -> bool { - // `"rate_limit_error"` is the structured error type from OpenAI / Anthropic - // compatible APIs. Tight anchor — colons and underscores don't appear in - // ordinary log text. - if lower.contains("rate_limit_error") { - return true; - } - // `"upstream rate limit exceeded"` is the OpenHuman backend's own phrase - // when it wraps an upstream provider 429 as an HTTP 500. - if lower.contains("upstream rate limit exceeded") { - return true; - } - // `"429 rate limit exceeded"` is the numeric-prefix form emitted by some - // backends (e.g. OPENHUMAN-TAURI-S: `"error":"429 rate limit exceeded"`). - // Anchored on the `"429 rate limit"` substring so a plain `"rate limit - // exceeded"` mention (which could appear in the `security::policy` action- - // budget message) is NOT matched here — the next arm handles clean phrase - // matches only when scoped by a provider API error prefix. - if lower.contains("429 rate limit") { - return true; - } - // `"rate limit exceeded"` on its own is matched ONLY when it appears inside - // a canonical provider API error envelope (`"api error ("` prefix from - // `ops::api_error` / `embeddings::openai`). This keeps the security::policy - // `"Rate limit exceeded: action budget exhausted"` message from being - // silently swallowed — that phrase does not carry an API error prefix. - if lower.contains("api error (") && lower.contains("rate limit exceeded") { - return true; - } - false -} - /// Detect filesystem-out-of-space errors that bubble up from any syscall /// (`open`, `write`, `mkdir`, `rename`). Three platform-stable renderings: /// @@ -510,13 +400,6 @@ fn is_whatsapp_data_sqlite_busy_message(lower: &str) -> bool { } fn is_embedding_backend_auth_failure(lower: &str) -> bool { - // Skip the OpenHuman-backend envelope shape `{"success":false,"error":"invalid token"}` - // (TAURI-RUST-4K5) — that's a SessionExpired wire shape, not a BYO-key auth failure. - // `is_session_expired_message` claims it via the conjunctive - // `Embedding API error (401` + `"error":"Invalid token"` anchors. - if lower.contains("\"error\":\"invalid token\"") { - return false; - } lower.contains("embedding api error") && lower.contains("401") && lower.contains("invalid token") @@ -590,18 +473,6 @@ fn is_memory_store_breaker_open(lower: &str) -> bool { /// `"session JWT required"` — local pre-flight guards that fire when the /// stored profile is empty (`#1465`-ish onboarding spam) or has been /// cleared by a previous 401 cycle. Both shapes are OpenHuman-specific. -/// - `"backend rejected session token on GET /payments/stripe/currentPlan"` and -/// all analogous `"{METHOD} {path}"` variants — the `BackendApiError::Unauthorized` -/// typed error surfaced by `api::rest::BackendOAuthClient::authed_json` when any -/// OpenHuman REST endpoint returns HTTP 401. The `get_authed_value` wrapper in -/// `billing::ops` stringifies this via `.to_string()`, producing the -/// `"backend rejected session token on …"` prefix. This is uniquely scoped to -/// the `BackendApiError::Unauthorized` variant (the phrase does not appear in -/// any third-party provider error path) so it is safe to classify as session -/// expiry without the conjunctive-anchor guard pattern needed for `"Invalid -/// token"`. Targets TAURI-RUST-E (~1 437 events from -/// `openhuman.billing_get_current_plan` polling on every background billing -/// refresh cycle after the user's JWT lapses). /// /// At the JSON-RPC dispatch boundary the same strict match controls /// `DomainEvent::SessionExpired` publication, so downstream/provider 401s stay @@ -614,14 +485,6 @@ pub fn is_session_expired_message(msg: &str) -> bool { || msg.contains("SESSION_EXPIRED") || (msg.contains("OpenHuman API error (401") && msg.contains("\"error\":\"Invalid token\"")) || (msg.contains("Embedding API error (401") && msg.contains("\"error\":\"Invalid token\"")) - // TAURI-RUST-E — billing endpoint 401s via `BackendApiError::Unauthorized` - // stringified by `billing::ops::get_authed_value(..).map_err(|e| e.to_string())`. - // The display form is `"backend rejected session token on {METHOD} {path}"`; - // the phrase is uniquely scoped to `BackendApiError::Unauthorized` so no - // conjunctive guard is needed. Covers all billing RPC methods - // (billing_get_current_plan, billing_get_balance, etc.) and any other - // `authed_json` caller that stringifies via `.to_string()`. - || lower.contains("backend rejected session token") // OPENHUMAN-TAURI-4P0 — OpenHuman backend's "Invalid token" 401 // envelope. Both anchors must be present: the OpenHuman-scoped // `"OpenHuman API error (401"` prefix (so a third-party provider's @@ -632,18 +495,15 @@ pub fn is_session_expired_message(msg: &str) -> bool { // actionable in Sentry). || (msg.contains("OpenHuman API error (401") && msg.contains("\"error\":\"Invalid token\"")) - // TAURI-RUST-4K5 / -T — same OpenHuman backend "Invalid token" envelope + // TAURI-RUST-4K5 — same OpenHuman backend "Invalid token" envelope // wrapped by `src/openhuman/embeddings/openai.rs:139` with the // `"Embedding API error"` prefix instead of `"OpenHuman API error"`. - // Both parenthesized (`Embedding API error (401`) and bare-status - // (`Embedding API error 401`) wire shapes are observed in production - // and must classify identically. Same conjunctive-anchor pattern as - // 4P0: the embedding-scoped prefix gates the match so a third-party - // BYO-key embedding 401 (e.g. OpenAI/Voyage/Cohere rejecting the - // user's own API key) stays actionable — guarded by + // Same conjunctive-anchor pattern as 4P0: the embedding-scoped + // prefix gates the match so a third-party BYO-key embedding 401 + // (e.g. OpenAI/Voyage/Cohere rejecting the user's own API key) + // stays actionable — guarded by // `does_not_classify_embedding_byo_key_401_as_session_expired`. - || ((msg.contains("Embedding API error (401") - || msg.contains("Embedding API error 401")) + || (msg.contains("Embedding API error (401") && msg.contains("\"error\":\"Invalid token\"")) // TAURI-RUST-1EE — same OpenHuman backend "Invalid token" envelope // wrapped by the streaming-chat path at @@ -1102,7 +962,7 @@ fn is_provider_user_state_message(lower: &str) -> bool { // personal Composio v3 tenant rejected with a 401 because the stored // API key is invalid / revoked / has the wrong prefix. The canonical // wire shape rendered by - // `src/openhuman/composio/composio/tools/direct.rs::response_error` + // `src/openhuman/composio/tools/impl/network/composio.rs::response_error` // and the various direct-mode op wrappers is: // // `[composio-direct] list_connections failed: Composio v3 @@ -1128,28 +988,8 @@ fn is_provider_user_state_message(lower: &str) -> bool { // // Drops Sentry TAURI-RUST-X9 (~15.7 k events / ~22 h, single user, // release openhuman@0.54.0+c25fc8e5fd3e). - // - // TAURI-RUST-322 (#2929): same direct-mode path but the Composio v3 - // `/connected_accounts` API returns HTTP 403 instead of 401. This - // happens when the BYO API key exists and is syntactically valid but - // does not carry the `connected_accounts:read` permission (e.g. a - // scoped or legacy key). Wire shape: - // - // `[composio-direct] list_connections failed: Composio v3 - // connected_accounts failed: HTTP 403` - // - // 403 from Composio v3 is a user-state condition (key permissions), - // not a bug in openhuman_core. Sentry has no remediation path — the - // user must regenerate their key with the correct scopes on - // app.composio.dev. The polling layer retries every 5 s and the UI - // already surfaces the error; flooding Sentry with 1,000+ events per - // user adds no signal. - // - // Drops Sentry TAURI-RUST-322 (1,021 events, multi-release). if lower.contains("[composio-direct]") - && (lower.contains("http 401") - || lower.contains("http 403") - || lower.contains("invalid api key")) + && (lower.contains("http 401") || lower.contains("invalid api key")) { return true; } @@ -1275,31 +1115,6 @@ fn is_filesystem_user_path_invalid_message(lower: &str) -> bool { || lower.contains("hosted path is not a directory:") } -/// Detect memory-store writes rejected because the namespace or key contained -/// a personal identifier detected by the PII guard. -/// -/// The three canonical wire shapes are emitted by -/// `memory_store/unified/documents.rs` and `memory_store/kv.rs`: -/// -/// - `"document namespace/key cannot contain personal identifiers"` — -/// `upsert_document` / `upsert_document_metadata_only` -/// - `"kv key cannot contain personal identifiers"` — `kv_set_global` -/// - `"kv namespace/key cannot contain personal identifiers"` — `kv_set_namespace` -/// -/// These are expected user-content conditions: the PII guard classifies a -/// channel name, username, or LLM-generated key as a personal identifier and -/// rejects the write. The LLM or caller already receives the error message; -/// Sentry has no remediation path. Drops TAURI-RUST-54T (~915 events, -/// escalating — all from a single user hitting false positives on valid -/// namespace/key identifiers). -/// -/// Anchor on `"cannot contain personal identifiers"` — the exact string -/// shared by all three sites — so typos or future rewordings that drop the -/// anchor still reach Sentry until explicitly classified. -fn is_memory_store_pii_rejection(lower: &str) -> bool { - lower.contains("cannot contain personal identifiers") -} - /// Detect the agent harness's empty-provider-response bail. /// /// Anchored on the literal user-facing string emitted at @@ -1629,20 +1444,6 @@ fn report_expected_message(kind: ExpectedErrorKind, message: &str, domain: &str, "[observability] {domain}.{operation} skipped expected filesystem path validation error" ); } - ExpectedErrorKind::MemoryStorePiiRejection => { - // PII guard rejected a memory-store write because the namespace or - // key was classified as containing a personal identifier. The guard - // already logs a `[memory:safety]` warn at the write site; this - // match arm keeps the diagnostic breadcrumb at warn level (not - // error) so local log files retain the context without spawning a - // Sentry error event. TAURI-RUST-54T (~915 events from one user). - tracing::warn!( - domain = domain, - operation = operation, - kind = "memory_store_pii_rejection", - "[observability] {domain}.{operation} skipped expected memory-store PII rejection" - ); - } ExpectedErrorKind::EmptyProviderResponse => { // Model/user-config condition — the provider returned a // completely empty body and the agent harness bailed with the @@ -2031,83 +1832,25 @@ pub fn is_transient_message_failure(msg: &str) -> bool { /// Returns true when a Sentry event is a budget-exhausted 400 that should be /// dropped from `before_send`. /// -/// **Two-tier match — either tier fires a drop:** -/// -/// 1. **Tag-gated path** (primary suppression, added in PR #1633): -/// - tag `failure == "non_2xx"` -/// - tag `status == "400"` -/// - event message or any exception value contains one of the tight -/// budget-exhaustion phrases from -/// [`crate::openhuman::inference::provider::is_budget_exhausted_message`] -/// -/// 2. **Text-only path** (defense-in-depth, covers OPENHUMAN-CORE-N / -/// TAURI-RUST-1P — GitHub issue #2935): -/// - event message or any exception value contains the exact phrase -/// `"Insufficient budget"` (the literal wire body the OpenHuman API -/// returns in `{"success":false,"error":"Insufficient budget"}`), -/// regardless of which tags are set. -/// -/// The text-only tier is intentionally tighter than tier 1 — it only -/// matches the exact phrase the backend uses, never the looser -/// `"add credits"` / `"budget exceeded"` phrases that might appear in -/// unrelated product copy. This prevents future call sites that call -/// `report_error` without setting `failure` / `status` tags (or that -/// invoke `sentry::capture_message` directly) from leaking budget -/// exhaustion events to Sentry. -/// -/// Note: `domain` is intentionally not gated here so a future re-emitter -/// under a different domain tag still gets filtered. -pub fn is_budget_event(event: &sentry::protocol::Event<'_>) -> bool { - // Tier 1 — tag-gated primary path. - let tags = &event.tags; - if tags.get("failure").map(String::as_str) == Some("non_2xx") - && tags.get("status").map(String::as_str) == Some("400") - && event_contains_budget_exhausted_message(event) - { - return true; - } - // Tier 2 — text-only defense-in-depth: drop any event whose message or - // exception value contains the exact wire phrase the OpenHuman backend - // emits for budget exhaustion, regardless of tags. - event_contains_budget_insufficient_phrase(event) -} - -/// Defense-in-depth `before_send` filter for Sentry event CORE-RUST-EK -/// (~827 events): every call to the cloud embedding API (OpenAI -/// `text-embedding-3-large` or Voyage) that returns HTTP 401 fires a Sentry -/// error event via `report_error_or_expected` in -/// `src/openhuman/embeddings/openai.rs`. -/// -/// 401 on the embedding call path means the configured API key is stale or -/// invalid. This is the same class of condition as the VOYAGE_API_KEY-missing -/// error (PR #2915) and the billing-expired 401 (PR #2924): the user's LLM -/// session was interrupted by an auth failure that the core will retry on the -/// next turn, but the Sentry volume-per-key ratio yields zero actionable signal. -/// /// Match criteria (all required): -/// - tag `domain == "embeddings"` — pins the filter to the embeddings call -/// path and avoids silencing unrelated 401s from provider chat, billing, or -/// the backend RPC layer -/// - tag `failure == "non_2xx"` — the marker set by `embeddings::openai::embed` -/// at the non-2xx path -/// - tag `status == "401"` — narrows to auth-rejection failures (429 / 500 -/// are handled by the existing rate-limit filter) -/// -/// The primary suppression for the OpenHuman-backend "Invalid token" shape -/// already lives in `expected_error_kind` / `is_session_expired_message`. This -/// filter is defense-in-depth: it catches any third-party provider 401 that -/// doesn't carry the OpenHuman-backend body (e.g. OpenAI's -/// `{"error":{"code":"invalid_api_key",...}}`), ensuring CORE-RUST-EK stays -/// off Sentry regardless of which embedding provider is configured. -pub fn is_embeddings_api_key_401_event(event: &sentry::protocol::Event<'_>) -> bool { +/// - tag `failure == "non_2xx"` +/// - tag `status == "400"` +/// - the event message or any exception value contains one of the tight +/// budget-exhaustion phrases +/// +/// Note: `domain` is intentionally not gated here as defense-in-depth over +/// the emit-site classifier — any non_2xx/400 event that carries the +/// budget-exhausted phrasing is dropped regardless of which domain produced +/// it, so a future re-emitter under a different tag still gets filtered. +pub fn is_budget_event(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; - if tags.get("domain").map(String::as_str) != Some("embeddings") { + if tags.get("failure").map(String::as_str) != Some("non_2xx") { return false; } - if tags.get("failure").map(String::as_str) != Some("non_2xx") { + if tags.get("status").map(String::as_str) != Some("400") { return false; } - tags.get("status").map(String::as_str) == Some("401") + event_contains_budget_exhausted_message(event) } /// 404 on PATCH/DELETE to a channel-message path is an expected backend state @@ -2169,30 +1912,3014 @@ fn event_contains_budget_exhausted_message(event: &sentry::protocol::Event<'_>) }) } -/// Tier-2 (text-only) budget check for [`is_budget_event`]. -/// -/// Matches the exact literal phrase `"Insufficient budget"` (case-insensitive) -/// in the event message or any exception value. This is the wire body the -/// OpenHuman backend returns: `{"success":false,"error":"Insufficient budget"}`. -/// -/// Deliberately narrower than [`event_contains_budget_exhausted_message`]: -/// only the precise backend phrase is matched, never the looser -/// `"add credits"` / `"budget exceeded"` / `"insufficient balance"` phrases -/// which might appear in unrelated product copy that would otherwise produce -/// false positives. -fn event_contains_budget_insufficient_phrase(event: &sentry::protocol::Event<'_>) -> bool { - const PHRASE: &str = "insufficient budget"; - let has_phrase = |s: &str| s.to_ascii_lowercase().contains(PHRASE); - if event.message.as_deref().is_some_and(has_phrase) { - return true; +#[cfg(test)] +mod tests { + use super::*; + + /// Helper must accept `&anyhow::Error`, `&dyn std::error::Error`, and + /// plain `&str` — the three shapes that show up at error sites today. + #[test] + fn report_error_accepts_common_error_shapes() { + let anyhow_err = anyhow::anyhow!("boom"); + report_error(&anyhow_err, "test", "anyhow_shape", &[]); + + let io_err = std::io::Error::other("io failed"); + report_error(&io_err, "test", "io_shape", &[("kind", "io")]); + + report_error("plain message", "test", "str_shape", &[]); } - event - .exception - .values - .iter() - .any(|exc| exc.value.as_deref().is_some_and(has_phrase)) -} -#[cfg(test)] -#[path = "observability_tests.rs"] -mod tests; + #[test] + fn anyhow_chain_is_rendered_in_full() { + // Regression guard: `err.to_string()` on an anyhow chain only emits + // the outermost context. Using `{:#}` joins every cause, which is + // what Sentry needs to actually diagnose wrapped failures. + let inner = std::io::Error::other("inner cause"); + let wrapped = anyhow::Error::from(inner).context("outer ctx"); + assert_eq!(format!("{wrapped:#}"), "outer ctx: inner cause"); + } + + #[test] + fn classifies_expected_config_errors() { + assert_eq!( + expected_error_kind("rpc.invoke_method failed: local ai is disabled"), + Some(ExpectedErrorKind::LocalAiDisabled) + ); + assert_eq!( + expected_error_kind( + "agent.provider_chat failed: ollama API key not set. Configure via the web UI" + ), + Some(ExpectedErrorKind::ApiKeyMissing) + ); + assert_eq!( + expected_error_kind("ollama embed failed with status 500"), + None + ); + } + + /// Task B (issue #2898): prove the canonical 429 error message produced by + /// the embedding clients is already classified as `TransientUpstreamHttp` + /// so Sentry events are suppressed even without backoff. + /// + /// The `is_transient_upstream_http_message` matcher checks for + /// `"api error (429 "` (case-insensitive), which is present in both the + /// OpenAI and Cohere canonical error shapes. + #[test] + fn embedding_429_classifies_as_transient_upstream_http() { + // OpenAI/Voyage canonical shape (openai.rs emit site). + let msg = "Embedding API error (429 Too Many Requests): Rate limit exceeded."; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "OpenAI 429 must classify as TransientUpstreamHttp: {msg}" + ); + + // Cohere canonical shape (cohere.rs emit site). + let cohere_msg = "Cohere embed API error (429 Too Many Requests): rate limit exceeded."; + assert_eq!( + expected_error_kind(cohere_msg), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "Cohere 429 must classify as TransientUpstreamHttp: {cohere_msg}" + ); + + // After-cap bail shape from the retry loop (openai.rs). + let cap_msg = + "Embedding API error (429 Too Many Requests): rate limit exceeded after 3 retries"; + assert_eq!( + expected_error_kind(cap_msg), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "retry-cap bail message must classify as TransientUpstreamHttp: {cap_msg}" + ); + + // After-cap bail shape from the retry loop (cohere.rs). + let cohere_cap_msg = + "Cohere embed API error (429 Too Many Requests): rate limit exceeded after 3 retries"; + assert_eq!( + expected_error_kind(cohere_cap_msg), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "Cohere retry-cap bail message must classify as TransientUpstreamHttp: {cohere_cap_msg}" + ); + } + + #[test] + fn does_not_classify_unrelated_is_not_configured_messages() { + assert_eq!( + expected_error_kind("workspace path is not configured for this user"), + None + ); + assert_eq!( + expected_error_kind("provider 'voyage' is not configured in settings"), + None + ); + } + + #[test] + fn classifies_ollama_user_config_rejections() { + // TAURI-RUST-XS (~376 events): user pointed embedder at a chat / + // vision model id, sometimes with a temperature suffix like `@0.7` + // that Ollama parses as malformed. + for raw in [ + // Canonical XS wire shape from + // `OllamaEmbedding::embed` non-2xx path on a 400 Bad Request. + r#"ollama embed failed with status 400 Bad Request: {"error":"invalid model name"}"#, + // Same shape with a temperature-suffix model id the user pasted + // into Settings → Embeddings → Ollama. + r#"ollama embed failed with status 400 Bad Request: {"error":"invalid model name: qwen3-vl:4b@0.7"}"#, + // OPENHUMAN-TAURI-MA — model not pulled (404 Not Found). + r#"ollama embed failed with status 404 Not Found: {"error":"model \"bge-m3\" not found, try pulling it first"}"#, + // OPENHUMAN-TAURI-KM — same shape, different model id + `:latest` tag. + r#"ollama embed failed with status 404 Not Found: {"error":"model \"nomic-embed-text:latest\" not found, try pulling it first"}"#, + // OPENHUMAN-TAURI-GX — daemon-unreachable opt-in state. + "ollama embeddings opted-in but daemon unreachable at http://localhost:11434; falling back to cloud embeddings for this session", + // TAURI-RUST-3X — 501-status model-does-not-support-embeddings. + r#"ollama embed failed with status 501 Not Implemented: {"error":"this model does not support embeddings"}"#, + // TAURI-RUST-3E — 401 unauthorized embed (auth required at ollama endpoint). + r#"ollama embed failed with status 401 Unauthorized: {"error": "unauthorized"}"#, + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ProviderUserState), + "should classify Ollama user-config rejection: {raw}" + ); + } + } + + #[test] + fn classifies_embedding_backend_auth_failure() { + // TAURI-RUST-T (~4k events) — companion of TAURI-RUST-4K5: the + // OpenHuman backend rejected the embeddings worker's bearer + // token. Both the bare-status and parenthesised wire shapes + // must classify as SessionExpired so the FE re-login prompt + // fires (matches the contract introduced by #2786 and + // exercised by classifies_embedding_api_invalid_token_401_as_session_expired). + for raw in [ + r#"Embedding API error 401 Unauthorized: {"success":false,"error":"Invalid token"}"#, + r#"Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#, + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::SessionExpired), + "should classify embedding backend auth failure as SessionExpired: {raw}" + ); + } + } + + #[test] + fn does_not_classify_unrelated_invalid_token_messages() { + // Provider 401s with "invalid token" in the body but no + // `Embedding API error` prefix must keep reaching Sentry — they're + // not the same wire shape and may indicate real provider bugs. + assert_eq!( + expected_error_kind(r#"openai chat failed 401: {"error":"invalid token"}"#), + None + ); + // Embedding error without 401 must not be silenced. + assert_eq!( + expected_error_kind( + r#"Embedding API error 500 Internal Server Error: {"error":"invalid token signature service down"}"# + ), + None + ); + } + + #[test] + fn does_not_classify_unrelated_ollama_errors_as_user_config() { + // Unrelated 500 — server-side ollama bug must still reach Sentry. + assert_eq!( + expected_error_kind("ollama embed failed with status 500"), + None + ); + // Parse-failure on the response — real bug in either the server + // or our deserializer, must still reach Sentry. + assert_eq!( + expected_error_kind( + "ollama embed response parse failed: invalid type: expected sequence" + ), + None + ); + // Dimension mismatch — real bug (model dims don't match what we + // recorded), must still reach Sentry. + assert_eq!( + expected_error_kind( + "ollama embed dimension mismatch at index 0: expected 768, got 1024" + ), + None + ); + // Unrelated `invalid model name` outside Ollama embed call — + // anchor on the `ollama embed` prefix keeps this from being silenced. + assert_eq!( + expected_error_kind("provider config validation failed: invalid model name"), + None + ); + // Unrelated `model "…" not found` text without the `ollama embed` + // prefix — anchor keeps this from being silenced even when the + // exact MA/KM wire-shape substring appears in another context. + assert_eq!( + expected_error_kind(r#"provider listing failed: model \"foo\" not found in registry"#), + None + ); + } + + #[test] + fn classifies_local_ai_capability_unavailable_errors() { + // OPENHUMAN-TAURI-3B: surfaced by `local_ai_download_asset` when a + // user on a 0–4 GB RAM tier requests a vision asset. Both canonical + // wire shapes — emitted from `assets.rs` and `vision_embed.rs` — + // must classify as expected so they stop reaching Sentry. + for raw in [ + "Vision is disabled for this RAM tier. Switch to the 4-8 GB tier or above to enable it.", + "vision summaries are unavailable for this RAM tier. Use OCR-only summarization or switch to a higher local AI tier.", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::LocalAiCapabilityUnavailable), + "should classify as local-ai capability unavailable: {raw}" + ); + } + + // Wrapped by the RPC dispatch layer as it reaches `report_error_or_expected` + // — the classifier is substring-based, so caller context must not defeat it. + assert_eq!( + expected_error_kind( + "rpc.invoke_method failed: Vision is disabled for this RAM tier. Switch to the 4-8 GB tier or above to enable it." + ), + Some(ExpectedErrorKind::LocalAiCapabilityUnavailable) + ); + } + + #[test] + fn classifies_prompt_injection_blocked_errors() { + // OPENHUMAN-TAURI-140: ~1 480 events from `openhuman.agent_chat` where + // users' messages scored ≥ 0.45 on the injection heuristic. Both + // enforcement wire shapes must be classified as expected so they stop + // reaching Sentry. + for raw in [ + "Prompt flagged for security review and was not processed. Please rephrase clearly.", + "Prompt blocked by security policy. Please rephrase without instruction overrides or exfiltration requests.", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::PromptInjectionBlocked), + "should classify as prompt-injection blocked: {raw}" + ); + } + + // Wrapped by the RPC dispatch layer — substring match must survive the prefix. + assert_eq!( + expected_error_kind( + "rpc.invoke_method failed: Prompt flagged for security review and was not processed. Please rephrase clearly." + ), + Some(ExpectedErrorKind::PromptInjectionBlocked) + ); + } + + #[test] + fn does_not_classify_unrelated_messages_as_prompt_injection_blocked() { + // Must not silently swallow real security errors or generic "prompt" mentions. + assert_eq!( + expected_error_kind("prompt injection detected in tool arguments"), + None + ); + assert_eq!( + expected_error_kind("security review required for deploy"), + None + ); + } + + // ── ContextWindowExceeded (TAURI-RUST-501) ───────────────────────────── + + #[test] + fn classifies_context_window_exceeded_rereport() { + // TAURI-RUST-501: the custom-provider 500 body that escapes the + // provider api_error cascade's own status-gated checks. When the + // error is re-raised by `agent.run_single` / `web_channel. + // run_chat_task`, `report_error_or_expected` runs the classifier on + // the full message — this arm must catch the new phrasing. + assert_eq!( + expected_error_kind( + "custom API error (500 Internal Server Error): \ + {\"error\":{\"code\":500,\"message\":\"Context size has been exceeded.\",\"type\":\"server_error\"}}" + ), + Some(ExpectedErrorKind::ContextWindowExceeded) + ); + + // The established phrasings the provider/reliable layer already + // recognized must classify here too (single-source matcher). + for raw in [ + "OpenAI API error (400): This model's maximum context length is 8192 tokens", + "request exceeds the context window of this model", + "context length exceeded", + "prompt is too long", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ContextWindowExceeded), + "should classify as context-window-exceeded: {raw}" + ); + } + } + + #[test] + fn does_not_classify_unrelated_messages_as_context_window_exceeded() { + // Anchors are context-overflow specific. A generic "window" or + // "context" mention, or an unrelated rate-limit "exceeded", must + // not classify. + for raw in [ + "rate limit exceeded, retry after 30s", + "failed to open context menu window", + "tool call exceeded the allowed budget", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "must NOT classify as context-window-exceeded: {raw}" + ); + } + } + + // ── FilesystemUserPathInvalid (TAURI-RUST-4QH) ───────────────────────── + + #[test] + fn classifies_vault_create_root_path_not_a_directory_as_filesystem_user_path_invalid() { + // TAURI-RUST-4QH: verbatim wire shape from + // `openhuman::vault::ops::vault_create` line 37 when the + // user-picked vault folder doesn't resolve to an existing + // directory. Bubbles up as the RPC dispatcher's + // `display_message` and reaches `report_error_or_expected` — + // must classify so no Sentry event fires. + assert_eq!( + expected_error_kind( + "root_path is not a directory: /Users/zadam/Documents/SndBrainOpenHuman" + ), + Some(ExpectedErrorKind::FilesystemUserPathInvalid) + ); + + // The same body wrapped by the JSON-RPC dispatcher's `display_message` + // prefix (`rpc.invoke_method` re-emit shape from `src/core/jsonrpc.rs`). + // Must still classify so the dispatch-site re-report doesn't escape + // the matcher even if a future caller layers more context. + assert_eq!( + expected_error_kind( + "rpc.invoke_method failed: root_path is not a directory: /Users/alice/openhuman-data" + ), + Some(ExpectedErrorKind::FilesystemUserPathInvalid) + ); + } + + #[test] + fn classifies_http_host_hosted_path_not_a_directory_as_filesystem_user_path_invalid() { + // Preempt the symmetric shape from + // `openhuman::http_host::path_utils:23` — + // `"hosted path is not a directory: "`. Not yet observed + // in Sentry but shares the same RPC validation polarity as + // vault_create's `root_path` check. Anchoring on + // `"path is not a directory:"` (with trailing colon) covers + // both without two separate matchers. + assert_eq!( + expected_error_kind("hosted path is not a directory: /var/www/static-site"), + Some(ExpectedErrorKind::FilesystemUserPathInvalid) + ); + } + + #[test] + fn does_not_classify_unrelated_path_messages_as_filesystem_user_path_invalid() { + // Polarity contract — the anchor requires a trailing colon + // after `"is not a directory"`, which discriminates user input + // (path follows the colon) from other shapes: + // + // 1. The `skills::ops_install:475` SAFETY GUARD — + // `" is not a directory — refusing to remove"` — must + // stay actionable. It catches an `rm -rf` invariant violation + // (the target should have been a directory but wasn't), + // which is a code bug, not user input. + // 2. A narrative log line that happens to mention the phrase + // without the user-path colon suffix is not a validation + // failure and must not be silenced. + // 3. The dot-prefix variant from POSIX `EISDIR`/`ENOTDIR` + // renderings (`"Is a directory (os error 21)"`) is the + // inverse condition — different code path entirely. + for raw in [ + // Safety guard — must NOT classify. + "/tmp/openhuman-cache is not a directory — refusing to remove", + // Narrative log line — must NOT classify. + "checked that path is not a directory before mkdir", + // Inverse condition (os error 21: EISDIR) — must NOT classify. + "open /etc/passwd failed: Is a directory (os error 21)", + // Bare path with no `directory` mention — must NOT classify. + "root_path must be absolute: ./relative/path", + // Generic body with the trailing colon but no known vault/http_host + // prefix — must NOT classify (future provider/storage errors that + // happen to embed "path is not a directory: ..." should reach Sentry). + "input config path is not a directory: /etc/foo", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "polarity contract: must NOT classify as FilesystemUserPathInvalid: {raw}" + ); + } + } + + // ── EmptyProviderResponse (TAURI-RUST-4Z1) ───────────────────────────── + + #[test] + fn classifies_empty_provider_response_web_channel_rereport() { + // TAURI-RUST-4Z1: the web-channel re-report of the agent harness's + // empty-provider-response bail. `run_chat_task` wraps the flattened + // string and routes it through `report_error_or_expected` — the + // agent-layer typed suppression (PR #2790) can't reach it, so this + // string classifier must. + assert_eq!( + expected_error_kind( + "run_chat_task failed client_id=l1uxaLd20_1mAdhp \ + thread_id=thread-8f03e7f7-3477-42cd-9283-f0bacd4bfbca \ + request_id=a73716a3-a85a-4045-984b-315772c5b3b8 \ + error=The model returned an empty response. Please try again." + ), + Some(ExpectedErrorKind::EmptyProviderResponse) + ); + + // Bare user-facing string (the verbatim `turn.rs` emission), in case + // a different call site re-reports it without the run_chat_task wrap. + assert_eq!( + expected_error_kind("The model returned an empty response. Please try again."), + Some(ExpectedErrorKind::EmptyProviderResponse) + ); + } + + #[test] + fn does_not_classify_unrelated_empty_response_phrases() { + // Polarity contract: the anchor is `"model returned an empty + // response"`, NOT the looser `"empty response"`. The sibling paths + // below use different subjects or phrasings and are not user-facing + // failures — they must stay out of this bucket so a real regression + // in those paths still reaches Sentry. + for raw in [ + // payload_summarizer.rs:261 — internal fall-through, not a failure. + "[payload_summarizer] summarizer returned empty response, falling through", + // subagent_runner/extract_tool.rs:379 — graceful empty extraction. + "[extract_from_result] provider returned an empty response; returning empty extraction", + // Generic mention without the model-subject anchor. + "warning: empty response body from health probe", + // channels/bus.rs:185 — channel-inbound graceful fallback (routes + // through report_error_or_expected; subject is "agent", not "model"). + "[channel-inbound] agent returned empty response — finalizing draft with fallback", + // memory/query/walk.rs:292 — debug-level memory walk, not a failure. + "[memory_tree_walk] turn=3 LLM gave up (empty response)", + // learning/reflection.rs:576 — reflection skip, not a failure. + "[learning] reflection skipped (empty response — gate off or local AI unavailable)", + // agent/harness/session/turn.rs:811 — "provider returned an empty + // final response" uses subject "provider", not "model"; must not match. + "[agent_loop] provider returned an empty final response (i=2, no text, no tool calls)", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "must NOT classify as EmptyProviderResponse: {raw}" + ); + } + } + + #[test] + fn classifies_memory_store_breaker_open() { + // TAURI-RUST-52X (~455 events on self-hosted Sentry): the chunk-store + // per-path circuit breaker tripped after consecutive SQLite init + // failures. The Windows wire shape is wrapped by + // `memory_tree::tree::rpc::pipeline_status_rpc`'s `chunk aggregates: …` + // context so the substring matcher must survive that prefix. + for raw in [ + // Canonical wire shape from `get_or_init_connection`. + "[memory_tree] circuit breaker open for /home/u/.openhuman/workspace/memory_tree/chunks.db: too many consecutive init failures", + // Canonical wire shape wrapped by the RPC handler's + // `format!("chunk aggregates: {e:#}")` context. + r"chunk aggregates: [memory_tree] circuit breaker open for C:\Users\u\.openhuman\users\6a09\workspace\memory_tree\chunks.db: too many consecutive init failures", + // Wrapped further by the JSON-RPC dispatch layer before reaching + // `report_error_or_expected`. + r"rpc.invoke_method failed: chunk aggregates: [memory_tree] circuit breaker open for /home/u/.openhuman/workspace/memory_tree/chunks.db: too many consecutive init failures", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::MemoryStoreBreakerOpen), + "should classify memory-store breaker-open: {raw}" + ); + } + } + + #[test] + fn classifies_disk_full_errors() { + for raw in [ + // Canonical POSIX errno 28 rendering from `std::io::Error`. + "Failed to create auth profile lock: open lock file: No space left on device (os error 28)", + // Same shape from a different call site — `tokio::fs::write` + // for a state snapshot. + "state snapshot write failed: No space left on device (os error 28)", + // Windows ERROR_DISK_FULL (112) rendering. + "log rotation failed: There is not enough space on the disk. (os error 112)", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::DiskFull), + "should classify disk-full: {raw}" + ); + } + } + + #[test] + fn does_not_classify_unrelated_space_messages() { + // Generic "space" prose without the errno-text anchor must not be + // silenced — the matcher pins to the platform-stable errno + // renderings only. + assert_eq!( + expected_error_kind("workspace path is invalid: contains a space character"), + None + ); + assert_eq!( + expected_error_kind("not enough memory to allocate buffer"), + None + ); + } + + #[test] + fn classifies_config_load_timed_out() { + // Canonical wire string emitted by `load_config_with_timeout` and + // `reload_config_snapshot_with_timeout` in + // `src/openhuman/config/ops.rs`. Drops TAURI-RUST-5X. + assert_eq!( + expected_error_kind("Config loading timed out"), + Some(ExpectedErrorKind::ConfigLoadTimedOut), + ); + // Same shape after the RPC dispatch wraps it for display — the + // matcher is substring-anchored, so a context prefix does not + // break it. + assert_eq!( + expected_error_kind("rpc.invoke_method failed: Config loading timed out"), + Some(ExpectedErrorKind::ConfigLoadTimedOut), + ); + } + + #[test] + fn does_not_classify_unrelated_timeouts_as_config_load_timed_out() { + // Network / HTTP timeouts go to `NetworkUnreachable` / + // `TransientUpstreamHttp`, not the config-load bucket. The + // anchor is the full literal phrase, so a bare "timed out" or + // "operation timed out" body cannot trip this matcher. + assert_ne!( + expected_error_kind( + "Channel discord error: IO error: Operation timed out (os error 60); restarting" + ), + Some(ExpectedErrorKind::ConfigLoadTimedOut), + ); + assert_ne!( + expected_error_kind("OpenHuman API error (504 Gateway Timeout): error code: 504"), + Some(ExpectedErrorKind::ConfigLoadTimedOut), + ); + // Bare "timed out" without the config-load phrase must not match. + assert_eq!(expected_error_kind("cron job timed out after 30s"), None,); + } + + #[test] + fn classifies_whatsapp_data_sqlite_busy_errors() { + for raw in [ + r#"[whatsapp_data] ingest failed: upsert wa_message chat=120363402402350155@g.us msg=false_120363402402350155@g.us_3A357F28AE74548B1507_207897942335683@lid: database is locked: Error code 5: The database file is locked"#, + r#"rpc.invoke_method failed: [whatsapp_data] ingest failed: upsert wa_message [email] msg=false_120363402402350155@g.us_3A357F28AE74548B1507_207897942335683@lid: database is locked: Error code 5: The database file is locked"#, + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::WhatsAppDataSqliteBusy), + "should classify whatsapp_data sqlite busy/locked: {raw}" + ); + } + } + + #[test] + fn does_not_classify_unrelated_sqlite_lock_messages_as_whatsapp_busy() { + for raw in [ + "failed to run subconscious schema DDL: database is locked", + "memory queue write failed: database table is locked", + "[whatsapp_data] list_messages failed: database is locked", + ] { + assert_ne!( + expected_error_kind(raw), + Some(ExpectedErrorKind::WhatsAppDataSqliteBusy), + "must not classify as whatsapp_data sqlite busy: {raw}" + ); + } + } + + #[test] + fn does_not_classify_unrelated_breaker_messages() { + // Generic "circuit breaker open" without the `[memory_tree]` anchor + // must not be silenced — other domains may use the same phrase for + // real bugs that need to reach Sentry. + assert_eq!( + expected_error_kind("provider reliability: circuit breaker open for openai"), + None + ); + // The `[memory_tree]` tag alone is not enough — must co-occur with + // the `circuit breaker open` substring. + assert_eq!( + expected_error_kind("[memory_tree] failed to run schema DDL: disk full"), + None + ); + } + + #[test] + fn does_not_classify_unrelated_messages_as_capability_unavailable() { + // The classifier anchors on the exact "for this RAM tier" substring. + // Messages that talk about RAM in a different context (sizing the + // tier list, doc references) must not be silenced. + assert_eq!(expected_error_kind("ollama embed failed: out of RAM"), None); + assert_eq!( + expected_error_kind("local_ai_set_ram_tier failed: invalid tier value"), + None + ); + } + + #[test] + fn classifies_network_unreachable_errors() { + // OPENHUMAN-TAURI-32: reqwest's transport-level error wrapped by the + // web_channel error site. The classifier must catch it even when + // embedded in caller context, since `report_error_or_expected` runs + // `expected_error_kind` on the full anyhow chain. + assert_eq!( + expected_error_kind( + "run_chat_task failed client_id=abc thread_id=t1 request_id=r1 \ + error=error sending request for url (https://api.tinyhumans.ai/openai/v1/chat/completions)" + ), + Some(ExpectedErrorKind::NetworkUnreachable) + ); + for raw in [ + "error sending request for url (https://api.example.com/x)", + "provider failed: dns error: failed to lookup address information", + "tcp connect: connection refused (os error 61)", + "stream closed: connection reset by peer", + "network is unreachable (os error 51)", + "no route to host", + "tls handshake eof", + "certificate verify failed: unable to get local issuer certificate", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::NetworkUnreachable), + "should classify as network-unreachable: {raw}" + ); + } + } + + #[test] + fn does_not_classify_unrelated_provider_errors_as_network() { + // Status-bearing provider failures (404, 500, …) are surfaced via + // their HTTP status path and must NOT be silenced by the + // network-unreachable classifier — the body text doesn't hit any of + // the transport-level markers. + assert_eq!( + expected_error_kind("OpenAI API error (404): model gpt-x not found"), + None + ); + assert_eq!( + expected_error_kind("OpenAI API error (500): internal server error"), + None + ); + } + + #[test] + fn classifies_wave4_socket_transport_wire_shapes() { + // OPENHUMAN-TAURI-44 (~50 events): libc `getaddrinfo()` rendering + // without the `dns error` token, wrapped by the socket emit site. + // The Wave 4 matcher arms catch the literal resolver phrases that + // the original `dns error` substring would miss when reqwest's + // wrapper isn't in the chain (e.g. tungstenite IO errors). + assert_eq!( + expected_error_kind( + "[socket] Connection failed (sustained outage after 5 attempts): \ + WebSocket connect: IO error: failed to lookup address information: \ + nodename nor servname provided, or not known" + ), + Some(ExpectedErrorKind::NetworkUnreachable) + ); + + // OPENHUMAN-TAURI-4P (~66 events): tungstenite renders a captive + // portal / corporate proxy that intercepts the WS handshake as + // `WsError::Http(200)` → `"HTTP error: 200 OK"`. Classify as + // network-unreachable since no amount of app-side retry can pierce + // an intercepting proxy. + assert_eq!( + expected_error_kind( + "[socket] Connection failed (sustained outage after 5 attempts): \ + WebSocket connect: HTTP error: 200 OK" + ), + Some(ExpectedErrorKind::NetworkUnreachable) + ); + } + + #[test] + fn http_200_classifier_does_not_silence_unrelated_log_lines() { + // The captive-portal arm anchors on `"http error: 200 ok"` (the + // exact tungstenite `WsError::Http(200)` Display rendering). + // Adjacent non-WebSocket log lines that mention `"HTTP/1.1 200 OK"` + // or `"status: 200 OK"` MUST NOT classify — those are normal-flow + // success traces, not failure events. Pin this precedence so a + // future refactor doesn't broaden the substring. + assert_eq!(expected_error_kind("HTTP/1.1 200 OK"), None); + assert_eq!( + expected_error_kind("upstream returned status: 200 OK after retry"), + None + ); + } + + #[test] + fn classifies_tls_handshake_eof_as_network_unreachable() { + // TAURI-RUST-4ZD (first seen on `openhuman@0.56.0+e8968077aeb5`, + // Windows): `native-tls` renders a peer / firewall / antivirus / + // corporate-proxy TCP close mid-TLS-handshake as + // `"TLS error: native-tls error: unexpected EOF during handshake"`, + // which `socket::ws_loop::run_connection` wraps as + // `"WebSocket connect: "` and the supervisor's + // sustained-outage escalation wraps again. The existing + // `"tls handshake"` arm misses it because the words are not + // contiguous in this render (`"tls error"` … `"during handshake"`). + // Same user-environment shape as the other handshake-stage entries: + // the socket supervisor already retries with exponential backoff and + // Sentry has no actionable signal beyond that. + assert_eq!( + expected_error_kind( + "[socket] Connection failed (sustained outage after 5 attempts): \ + WebSocket connect: TLS error: native-tls error: unexpected EOF during handshake" + ), + Some(ExpectedErrorKind::NetworkUnreachable) + ); + + // Bare native-tls render (no socket-supervisor wrap) — fires when the + // same handshake EOF escapes through a non-supervisor call site. The + // classifier runs on the full anyhow chain, so the shorter form must + // also match. + assert_eq!( + expected_error_kind("TLS error: native-tls error: unexpected EOF during handshake"), + Some(ExpectedErrorKind::NetworkUnreachable) + ); + } + + #[test] + fn classifies_ws_protocol_wrong_http_version_as_network_unreachable() { + // CORE-RUST-DP (~2 events / 24h on `openhuman@0.56.0+e8968077aeb5`, + // self-hosted `core-rust`): tungstenite renders + // `ProtocolError::WrongHttpVersion` as + // `"WebSocket protocol error: HTTP version must be 1.1 or higher"`, + // wrapped by `socket::ws_loop::run_connection` as + // `"WebSocket connect: "` and then by the supervisor's + // sustained-outage escalation as + // `"[socket] Connection failed (sustained outage after N attempts): + // WebSocket connect: WebSocket protocol error: HTTP version must be + // 1.1 or higher"`. + // + // The handshake requires HTTP/1.1; a server or intermediary proxy + // that responds with HTTP/2+ to the upgrade is misconfigured + // upstream — same shape as the existing `"tls handshake"` / + // `"certificate verify failed"` user-environment entries. The + // supervisor already retries with exponential backoff; Sentry has + // no actionable signal to add. + assert_eq!( + expected_error_kind( + "[socket] Connection failed (sustained outage after 5 attempts): \ + WebSocket connect: WebSocket protocol error: HTTP version must be 1.1 or higher" + ), + Some(ExpectedErrorKind::NetworkUnreachable) + ); + + // Bare tungstenite render (no socket-supervisor wrap) — fires when + // the same protocol error escapes through a non-supervisor call + // site. The classifier runs on the full anyhow chain, so the + // shorter form must also match. + assert_eq!( + expected_error_kind("WebSocket protocol error: HTTP version must be 1.1 or higher"), + Some(ExpectedErrorKind::NetworkUnreachable) + ); + } + + #[test] + fn tls_handshake_eof_anchor_does_not_silence_unrelated_log_lines() { + // The anchor is the literal `"unexpected eof during handshake"` + // phrase. A bare data-phase `"unexpected EOF"` (server closed + // mid-stream, parser truncation, …) MUST NOT classify — those are + // outside the handshake stage and may carry actionable signal. Pin + // the rejection contract so a future refactor doesn't loosen the + // substring into a generic `"unexpected eof"` matcher. + for raw in [ + "stream closed: unexpected EOF", + "reqwest: unexpected EOF while reading body", + "json parser: unexpected EOF at byte 1024", + "decoder hit unexpected eof mid-frame", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "non-handshake unexpected-EOF log line must NOT classify: {raw}" + ); + } + } + + #[test] + fn wrong_http_version_anchor_does_not_silence_unrelated_log_lines() { + // The anchor is the literal tungstenite Display string. Adjacent + // log lines that mention HTTP version in any other context + // (`"upgrading from HTTP/1.0 to HTTP/2"`, `"HTTP/1.1 only"`, + // `"server requires HTTP version 2.0"`) MUST NOT classify — those + // are unrelated transport / negotiation traces and may carry + // actionable signal. Pin the rejection contract so a future + // refactor doesn't loosen the substring into a generic + // `"http version"` matcher. + for raw in [ + "[transport] upgrading from HTTP/1.0 to HTTP/2", + "server advertises HTTP version 2.0 (h2 alpn)", + "client supports HTTP/1.1 only", + "version mismatch: requires HTTP/1.2 or higher", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "unrelated HTTP-version log line must NOT classify: {raw}" + ); + } + } + + #[test] + fn classifies_transient_upstream_http_errors() { + // OPENHUMAN-TAURI-5Z: the canonical shape emitted by + // `providers::ops::api_error` and re-raised through `agent.run_single`. + assert_eq!( + expected_error_kind("OpenHuman API error (504 Gateway Timeout): error code: 504"), + Some(ExpectedErrorKind::TransientUpstreamHttp) + ); + + // Every transient code must classify, whether the status renders as + // bare digits or " ". + for raw in [ + "OpenHuman API error (408): request timeout", + "OpenAI API error (429 Too Many Requests): rate limit", + "Anthropic API error (502 Bad Gateway): upstream unhealthy", + "OpenHuman API error (503): service unavailable", + "Provider API error (504): upstream timed out", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "should classify as transient upstream HTTP: {raw}" + ); + } + + // Wrapped in an anyhow chain (as it reaches the agent layer) must + // still classify — `expected_error_kind` is substring-based. + assert_eq!( + expected_error_kind( + "agent turn failed: OpenHuman API error (504 Gateway Timeout): \ + error code: 504" + ), + Some(ExpectedErrorKind::TransientUpstreamHttp) + ); + + // TAURI-RUST-H (~1360 events, 504) / TAURI-RUST-2T (~310 events, 502): + // legacy no-paren wire shape from older `embeddings::openai` / + // `embeddings::cohere` emit-site formats that predate the + // parenthesised `({status})` rendering. Anchored on the trailing + // space after the status code so unrelated digit runs don't match. + for raw in [ + "Embedding API error 504 Gateway Timeout: error code: 504", + "Embedding API error 502 Bad Gateway: error code: 502", + "Cohere embed API error 503 Service Unavailable: error code: 503", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "should classify legacy no-paren transient shape: {raw}" + ); + } + } + + #[test] + fn does_not_classify_unrelated_digit_runs_as_transient() { + // The legacy no-paren matcher anchors `api error ` with a + // trailing space so adjacent digit runs (`api error 5042…`) and + // non-transient codes (400/401/403/404) don't get silenced. + assert_eq!( + expected_error_kind("OpenHuman API error 400 Bad Request: malformed body"), + None + ); + assert_eq!( + expected_error_kind("provider returned api error 5042 (custom internal sentinel)"), + None + ); + } + + #[test] + fn integrations_post_composio_timeout_dropped() { + // OPENHUMAN-TAURI-18 / -G regression guard. The integrations + // client at `crate::openhuman::integrations::client::IntegrationClient::post` + // builds the reqwest error chain and routes it through + // `report_error_or_expected(.., "integrations", "post", &[("failure", + // "transport")])`. The chain text contains the + // `"error sending request for url"` anchor so + // `is_network_unreachable_message` matches first and demotes to + // `NetworkUnreachable` (functionally equivalent to + // `TransientUpstreamHttp` for Sentry suppression — both routes + // skip the report path via `report_expected_message`). + // + // Pinning this exact wire shape catches a future refactor that + // drops the URL anchor (e.g. a chain-flatten helper that strips + // it for "PII safety"), which would silently re-open the leak. + let chain = "error sending request for url \ + (https://api.tinyhumans.ai/agent-integrations/composio/execute) → \ + client error (SendRequest) → connection error → \ + Operation timed out (os error 60)"; + assert_eq!( + expected_error_kind(chain), + Some(ExpectedErrorKind::NetworkUnreachable), + "TAURI-18 chain shape must classify as NetworkUnreachable" + ); + + // If the URL anchor is ever dropped, the transport-phrase + // fallback (`operation timed out` from + // `TRANSIENT_TRANSPORT_PHRASES`) catches it via the message + // classifier helper used at upstream re-emit sites — confirm + // both paths so the regression surface is fully pinned. + assert!( + is_transient_message_failure(chain), + "TAURI-18 chain must also satisfy upstream message classifier \ + (defense-in-depth for sites that lose the URL anchor)" + ); + } + + #[test] + fn channel_supervisor_operation_timed_out_classifies_as_expected() { + // OPENHUMAN-TAURI-EM (128 events) + TAURI-RUST-15/-BB: `channels::runtime::supervision` + // wraps a channel listener failure as + // `format!("Channel {} error: {e:#}; restarting", ch.name())` and + // routes the message through `report_error_or_expected`. The + // newer `ChannelSupervisorRestart` classifier (added for the + // broader 11.4k-event Sentry leak) anchors on the supervisor + // wrapper shape itself — `"Channel error: …; restarting"` + // — and takes precedence over `NetworkUnreachable`. That single + // arm now covers every ETIMEDOUT / WSAETIMEDOUT / hyper-prose + // shape the old narrower anchor pinned, plus OS-localized + // variants the English-only `NetworkUnreachable` would miss. + // + // Demotion tier difference: `ChannelSupervisorRestart` emits at + // `info!` (breadcrumb only, no Sentry event) where + // `NetworkUnreachable` emitted at `warn!` (still captured as a + // Sentry warn event). Sustained outages still page via + // `health.bus` / `FAIL_ESCALATE_THRESHOLD`. + for raw in [ + // macOS (os error 60 = ETIMEDOUT on BSD) + "Channel discord error: IO error: Operation timed out (os error 60); restarting", + // Linux (os error 110 = ETIMEDOUT) + "Channel discord error: IO error: Operation timed out (os error 110); restarting", + // Windows (os error 10060 = WSAETIMEDOUT) + "Channel discord error: IO error: Operation timed out (os error 10060); restarting", + // Same shape on other channels — supervisor wrapper is provider-agnostic. + "Channel slack error: IO error: Operation timed out (os error 60); restarting", + "Channel telegram error: IO error: Operation timed out (os error 110); restarting", + // Bare prose form (no errno suffix) from hyper / tungstenite layers + // that render `std::io::Error` without `raw_os_error()`. + "Channel discord error: WebSocket connect: IO error: Operation timed out; restarting", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ChannelSupervisorRestart), + "channel supervisor timeout shape must classify as ChannelSupervisorRestart \ + (precedence over NetworkUnreachable; got {:?} for {raw:?})", + expected_error_kind(raw) + ); + } + } + + #[test] + fn operation_timed_out_negative_cases_still_report() { + // Counter-case: a configuration/validation message that mentions + // "timeout" as a knob name (not transport state) and has no other + // classifier anchor must still reach Sentry. The substring chosen + // for the new matcher is `"operation timed out"`, not `"timeout"`, + // precisely so unrelated mentions of the word do not collide. + assert_eq!( + expected_error_kind("config rejected: timeout must be a positive integer"), + None, + "config validation noise (no 'operation timed out' anchor) must still reach Sentry" + ); + // Bare empty string — no anchors at all. + assert_eq!(expected_error_kind(""), None); + } + + #[test] + fn channels_dispatch_re_emit_of_provider_502_classifies_as_transient() { + // OPENHUMAN-TAURI-4F (~157 events) / -1C (~87 events) / -8F + // (~39 events): the reliable provider layer retried 5xx, the + // agent re-raised the error, and `channels::runtime::dispatch` + // re-emitted it under `domain="channels", operation="dispatch_llm_error"` + // via raw `report_error` (which skips classification). Switching + // that site to `report_error_or_expected` routes the chain + // through this classifier — but only works if the canonical + // `"OpenHuman API error (NNN ...)"` substring still anchors the + // match through the channels-layer wrapping. + // + // The wrapping shape at the dispatch site is the agent error + // chain rendered via `format!("{e:#}")`. For a backend 502 from + // `providers::ops::api_error`, that resolves to: + // "OpenHuman API error (502 Bad Gateway): error code: 502" + // possibly prepended with a runner / iteration prefix. Both + // shapes must classify as transient so the dispatch re-emit + // gets demoted. + for raw in [ + "OpenHuman API error (502 Bad Gateway): error code: 502", + "agent.provider_chat failed: OpenHuman API error (503 Service Unavailable): retry budget exhausted", + "all providers exhausted: OpenHuman API error (504 Gateway Timeout): error code: 504", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "channels.dispatch re-emit of {raw:?} must classify as transient" + ); + } + } + + #[test] + fn classifies_socket_transient_http_errors() { + // OPENHUMAN-TAURI-5P / -EZ: tungstenite's `WsError::Http(response)` + // surfaces during the WebSocket upgrade handshake when the backend + // load balancer returns 502 / 504. The socket reconnect loop wraps + // it as `format!("WebSocket connect: {e}")`, producing + // `"WebSocket connect: HTTP error: "`. Each + // sustained-outage threshold escalation routes the formatted reason + // through `report_error_or_expected`, which must classify as + // transient so the per-client noise stops reaching Sentry. + for raw in [ + "WebSocket connect: HTTP error: 502 Bad Gateway", + "WebSocket connect: HTTP error: 503 Service Unavailable", + "WebSocket connect: HTTP error: 504 Gateway Timeout", + "[socket] Connection failed (sustained outage after 5 attempts): \ + WebSocket connect: HTTP error: 502 Bad Gateway", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "should classify as transient upstream HTTP (socket shape): {raw}" + ); + } + + // Trailing-colon separator (chained error formatting). + // Note: avoid words like "connection refused" or "timeout" in the + // suffix — those would also match `is_network_unreachable_message` / + // `TRANSIENT_TRANSPORT_PHRASES` and the order in `expected_error_kind` + // would route through `NetworkUnreachable` first, defeating the + // assertion. Both classifications silence the event so production + // behavior is identical, but the test is anchored on the canonical + // socket shape so a future regression in `is_transient_upstream_http_message` + // surfaces here, not behind another classifier. + assert_eq!( + expected_error_kind( + "WebSocket connect: HTTP error: 502: upstream returned bad gateway" + ), + Some(ExpectedErrorKind::TransientUpstreamHttp) + ); + + // Trailing-newline separator (multi-line error chain). + assert_eq!( + expected_error_kind("WebSocket connect: HTTP error: 504\nupstream gateway"), + Some(ExpectedErrorKind::TransientUpstreamHttp) + ); + } + + #[test] + fn does_not_classify_unrelated_http_error_text_as_transient_socket() { + // Bare numeric "HTTP error: 5023" (port number, runbook ID) without + // a separator must NOT silence — pin the matcher to space/newline/colon. + assert_eq!(expected_error_kind("HTTP error: 5023"), None); + // Non-transient HTTP statuses must not match — `WsError::Http` for + // a 401 / 403 / 404 is genuinely actionable (auth / routing bug). + for raw in [ + "WebSocket connect: HTTP error: 401 Unauthorized", + "WebSocket connect: HTTP error: 403 Forbidden", + "WebSocket connect: HTTP error: 404 Not Found", + "WebSocket connect: HTTP error: 500 Internal Server Error", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "must NOT silence actionable socket HTTP error: {raw}" + ); + } + } + + #[test] + fn does_not_classify_actionable_provider_errors_as_transient_upstream() { + // 4xx (other than 408/429) and non-transient 5xx must continue to + // reach Sentry — those are real bugs (wrong model name, malformed + // request, internal exception) that need to be triaged. + for raw in [ + "OpenAI API error (400): bad request", + "OpenAI API error (401): unauthorized", + "OpenAI API error (403): forbidden", + "OpenAI API error (404): model not found", + "OpenAI API error (500): internal server error", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "must NOT silence actionable provider error: {raw}" + ); + } + + // A free-form message that merely mentions "504" without the + // `api error (` prefix must not be classified — pin the match to + // the canonical shape from `ops::api_error`. + assert_eq!( + expected_error_kind("see runbook for 504 handling at https://example.com/504"), + None + ); + } + + #[test] + fn classifies_backend_user_error_responses() { + // OPENHUMAN-TAURI-BC: SharePoint authorize 400 because the user + // didn't fill in the required Tenant Name field. After the + // ProviderUserState classifier was added (#1472 wave E), this + // canonical shape now lands in the more specific + // ProviderUserState bucket — `"missing required fields"` wins + // over the generic 4xx matcher. Either expected-kind silences + // Sentry; the dedicated bucket gives operators a finer-grained + // `kind="provider_user_state"` info-log facet for triage. + let bc = "Backend returned 400 Bad Request for POST \ + https://api.tinyhumans.ai/agent-integrations/composio/authorize: \ + Composio authorization failed: 400 \ + {\"error\":{\"message\":\"Missing required fields: Tenant Name\",\ + \"slug\":\"ConnectedAccount_MissingRequiredFields\",\"status\":400}}"; + assert_eq!( + expected_error_kind(bc), + Some(ExpectedErrorKind::ProviderUserState), + "OPENHUMAN-TAURI-BC wire shape must classify as ProviderUserState (the \ + more specific bucket once #1472 wave E added it)" + ); + + // Cover the rest of the 4xx surface produced by integrations / + // composio clients — all user-input / auth-state failures that + // Sentry can't action. + for raw in [ + "Backend returned 400 Bad Request for POST https://api.example.com/x: bad input", + "Backend returned 401 Unauthorized for GET https://api.example.com/x: token expired", + "Backend returned 403 Forbidden for GET https://api.example.com/x: permission denied", + "Backend returned 404 Not Found for GET https://api.example.com/x: missing", + "Backend returned 422 Unprocessable Entity for POST https://api.example.com/x: validation failed", + "Backend returned 451 Unavailable for Legal Reasons for GET https://api.example.com/x: blocked", + // Lowercased context wrapping is irrelevant — substring match is case-insensitive. + "[observability] integrations.post failed: Backend returned 400 Bad Request for POST https://api.tinyhumans.ai/x: detail", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::BackendUserError), + "must classify as backend user-error: {raw}" + ); + } + } + + #[test] + fn does_not_classify_transient_or_server_backend_errors_as_user_error() { + // 408 / 429 are transient — they belong to the + // upstream-transient bucket (or are retried at the caller), not + // the user-error bucket. A sustained 429 (rate limit cliff) MUST + // still surface so we can react. + for raw in [ + "Backend returned 408 Request Timeout for POST https://api.example.com/x: timeout", + "Backend returned 429 Too Many Requests for POST https://api.example.com/x: slow down", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "transient 4xx must NOT be classified as user-error: {raw}" + ); + } + + // 5xx is always actionable — server bugs need to reach Sentry. + for raw in [ + "Backend returned 500 Internal Server Error for POST https://api.example.com/x: oops", + "Backend returned 502 Bad Gateway for POST https://api.example.com/x: upstream down", + "Backend returned 503 Service Unavailable for POST https://api.example.com/x: maintenance", + "Backend returned 504 Gateway Timeout for POST https://api.example.com/x: slow upstream", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "5xx must NOT be classified as user-error: {raw}" + ); + } + + // A free-form message that mentions "400" but doesn't follow the + // `Backend returned ` prefix from the integrations / + // composio clients must not be silenced. + assert_eq!( + expected_error_kind("see HTTP 400 specification at https://example.com/400"), + None + ); + assert_eq!( + expected_error_kind("OpenAI API error (400): bad request"), + None, + "provider-formatted 4xx must keep going through the provider classifier path" + ); + } + + #[test] + fn classifies_trigger_type_not_found_as_provider_user_state() { + // OPENHUMAN-TAURI-3R / -3S: composio enable_trigger when the slug + // isn't in the trigger registry. Backend wraps the upstream + // composio 4xx as 500, so this would otherwise escape the + // 4xx-only `is_backend_user_error_message` matcher. + assert_eq!( + expected_error_kind( + "Backend returned 500 Internal Server Error for POST \ + https://api.tinyhumans.ai/agent-integrations/composio/triggers: \ + Trigger type GITHUB_PUSH_EVENT not found" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + + // Wrapped by `rpc.invoke_method` / `[composio] sync(toolkit) failed: …` + // — substring match must survive caller context. + assert_eq!( + expected_error_kind( + "rpc.invoke_method failed: Backend returned 500 Internal Server Error \ + for POST /agent-integrations/composio/triggers: \ + Trigger type SLACK_NEW_MESSAGE not found" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + + // Alternate phrasing observed from the same cluster. + assert_eq!( + expected_error_kind( + "composio: Cannot enable trigger 'GITHUB_PUSH_EVENT': trigger not found in registry" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + } + + #[test] + fn classifies_toolkit_not_enabled_as_provider_user_state() { + // OPENHUMAN-TAURI-34: 400 from composio because the user hasn't + // enabled the toolkit. Must classify as ProviderUserState (more + // specific) rather than the generic BackendUserError bucket — the + // ordering in `expected_error_kind` enforces that. + let msg = "Backend returned 400 Bad Request for POST \ + https://api.tinyhumans.ai/agent-integrations/composio/execute: \ + Toolkit \"get\" is not enabled"; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::ProviderUserState) + ); + + // Wrapped variant (anyhow chain through the agent runtime). + assert_eq!( + expected_error_kind( + "tool.invoke failed: Backend returned 400 Bad Request for POST \ + /agent-integrations/composio/execute: Toolkit \"linear\" is not enabled \ + for this account" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + } + + #[test] + fn classifies_custom_openai_upstream_bad_request_as_provider_user_state() { + assert_eq!( + expected_error_kind( + "custom_openai API error (400 Bad Request): \ + {\"error\":{\"message\":\"Bad request to upstream provider\",\ + \"type\":\"upstream_error\",\"status\":400}}" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + + // Wrapped by higher-level callers (`agent.run_single`, + // `rpc.invoke_method`) must still classify. + assert_eq!( + expected_error_kind( + "agent.run_single failed: custom_openai API error (400 Bad Request): \ + {\"error\":{\"message\":\"Bad request to upstream provider\",\ + \"type\":\"upstream_error\",\"status\":400}}" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + } + + /// Regression for CodeRabbit feedback on PR #2107: the matcher must + /// not demote unrelated errors that happen to contain both + /// "bad request to upstream provider" and "upstream_error" without + /// the `custom_openai API error (400` anchor. + #[test] + fn does_not_silence_unrelated_error_with_only_inner_substrings() { + // No `custom_openai API error (400` prefix → must NOT classify + // as ProviderUserState, otherwise we'd silence actionable bugs. + assert_eq!( + expected_error_kind( + "internal panic in router: bad request to upstream provider \ + (state=upstream_error)" + ), + None, + ); + + // A future hypothetical provider envelope reusing one substring + // also must not classify. + assert_eq!( + expected_error_kind( + "anthropic_api error: upstream_error encountered while \ + forwarding bad request to upstream provider" + ), + None, + ); + } + + #[test] + fn classifies_missing_required_fields_as_provider_user_state() { + // OPENHUMAN-TAURI-97: composio authorize with a blank required + // field. Backend wraps the composio 400 as 500 with the inner + // body embedded as a JSON-stringified error message. + assert_eq!( + expected_error_kind( + "Backend returned 500 Internal Server Error for POST \ + https://api.tinyhumans.ai/agent-integrations/composio/authorize: \ + 400 {\"error\":{\"message\":\"Missing required fields: Your Subdomain\"}}" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + + // Sibling toolkits surface the same shape with different field names. + for raw in [ + "Backend returned 500 Internal Server Error for POST /authorize: Missing required fields: WABA ID", + "Backend returned 500 Internal Server Error for POST /authorize: Missing required fields: Tenant Name", + "Backend returned 400 Bad Request for POST /authorize: Missing required fields: Domain URL", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ProviderUserState), + "missing-required-fields shape must classify: {raw}" + ); + } + } + + #[test] + fn classifies_insufficient_scopes_as_provider_user_state() { + // OPENHUMAN-TAURI-33: gmail sync surfaced the upstream Google + // OAuth scopes error verbatim through composio. Reaches the RPC + // dispatch site via `[composio] sync(gmail) failed: [composio:gmail] + // GMAIL_FETCH_EMAILS page 0: HTTP 403: Request had insufficient + // authentication scopes.`. + assert_eq!( + expected_error_kind( + "[composio:gmail] GMAIL_FETCH_EMAILS page 0: HTTP 403: \ + Request had insufficient authentication scopes." + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + + // Bare upstream shape (in case any future caller forwards without + // the gmail prefix). + assert_eq!( + expected_error_kind("HTTP 403: Request had insufficient authentication scopes."), + Some(ExpectedErrorKind::ProviderUserState) + ); + } + + #[test] + fn classifies_access_terminated_provider_policy_as_provider_user_state() { + assert_eq!( + expected_error_kind( + "custom_openai API error (403 Forbidden): {\"error\":{\"message\":\"Kimi For Coding is currently only available for Coding Agents such as Kimi CLI, Claude Code, Roo Code, Kilo Code, etc.\",\"type\":\"access_terminated_error\"}}" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + + assert_eq!( + expected_error_kind( + "agent turn failed: custom_openai API error (403): currently only available for coding agents" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + } + + #[test] + fn does_not_classify_unrelated_500s_as_provider_user_state() { + // Sanity check: a generic 500 with no provider-user-state body + // shape must continue to reach Sentry as an actionable event. + assert_eq!( + expected_error_kind( + "Backend returned 500 Internal Server Error for POST \ + /agent-integrations/composio/triggers: random panic in handler" + ), + None + ); + assert_eq!( + expected_error_kind( + "Backend returned 500 Internal Server Error for GET /teams: database connection lost" + ), + None + ); + + // Free-form text that mentions "not found" / "is not enabled" out + // of context must not be silenced. + assert_eq!( + expected_error_kind("file not found at /tmp/x.json"), + None, + "bare 'not found' without 'trigger type' anchor must NOT classify" + ); + assert_eq!( + expected_error_kind("the cache is not enabled in this build"), + None, + "bare 'is not enabled' without 'toolkit ' anchor must NOT classify" + ); + } + + #[test] + fn classifies_provider_config_rejection() { + // #2079 — an OpenHuman abstract tier alias leaked to a custom + // provider; raised again by `agent.run_single` / + // `web_channel.run_chat_task` so it escapes the provider-layer + // demotion and reaches `report_error_or_expected` here. + assert_eq!( + expected_error_kind( + "agent.run_single failed: custom_openai API error (400 Bad Request): \ + The supported API model names are deepseek-v4-pro or deepseek-v4-flash, \ + but you passed reasoning-v1." + ), + Some(ExpectedErrorKind::ProviderConfigRejection) + ); + // #2076 — Moonshot Kimi K2 temperature constraint. + assert_eq!( + expected_error_kind( + "custom_openai API error (400): invalid temperature: only 1 is allowed for this model" + ), + Some(ExpectedErrorKind::ProviderConfigRejection) + ); + // #2202 — unknown / stale model pin (OpenAI-compatible body). + assert_eq!( + expected_error_kind( + "custom_openai API error (400): Model 'claude-opus-4-7' is not available. \ + Use GET /openai/v1/models to list available models." + ), + Some(ExpectedErrorKind::ProviderConfigRejection) + ); + } + + #[test] + fn does_not_classify_unrelated_provider_failures_as_config_rejection() { + // Inverted polarity / scope guard: a 5xx or a generic 4xx with no + // config-rejection body must still reach Sentry as actionable. + // (The OpenHuman backend never emits these phrases, so the + // message-level predicate is intrinsically custom-provider scoped; + // the HTTP-layer twin enforces the non-backend guard explicitly.) + assert_eq!( + expected_error_kind("custom_openai API error (500): internal server error"), + None + ); + assert_eq!( + expected_error_kind( + "custom_openai API error (400 Bad Request): missing required field 'messages'" + ), + None, + "generic 4xx without a config-rejection body must NOT demote" + ); + } + + #[test] + fn unrelated_missing_required_fields_classifies_as_accepted_false_positive() { + // Documents the breadth of the `"missing required fields"` arm — + // unlike the trigger/toolkit arms it has no second anchor, so a + // non-composio call site whose error happens to contain the phrase + // will also demote. This is the accepted false-positive surface + // per the classifier doc-comment (every current emit site is + // scoped to composio/integrations envelopes, so a stray collision + // would have to come from a brand-new opt-in call site). + // + // Pinning this assertion locks the breadth in so a future + // narrowing of the matcher surfaces here instead of silently + // re-bucketing the demote path. + assert_eq!( + expected_error_kind("Internal error: missing required fields in config"), + Some(ExpectedErrorKind::ProviderUserState), + "accepted false-positive: bare 'missing required fields' demotes by design" + ); + } + + #[test] + fn provider_user_state_takes_precedence_over_backend_user_error() { + // Critical ordering guarantee: a 4xx body that contains the + // toolkit-not-enabled phrasing must land in `ProviderUserState` + // (more specific) — not in the generic `BackendUserError` bucket. + // Without the ordering in `expected_error_kind`, the 4xx matcher + // would win and the operator would see a different breadcrumb + // kind than intended (and miss the `kind="provider_user_state"` + // tag in info logs). + let msg = "Backend returned 400 Bad Request for POST \ + /agent-integrations/composio/execute: \ + Toolkit \"github\" is not enabled"; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::ProviderUserState), + "4xx + toolkit-not-enabled must land in ProviderUserState, not BackendUserError" + ); + } + + // ── TAURI-RUST-X9 (#1166): composio-direct 401 / Invalid API key ──── + + #[test] + fn classifies_composio_direct_invalid_api_key_as_provider_user_state() { + // Canonical Sentry TAURI-RUST-X9 wire shape — the verbatim title + // body from the issue, captured 15,732 times in ~22h on a single + // user with a bad direct-mode key. The classifier must demote + // this to `ProviderUserState` so the polling layer's 5 s retry + // doesn't keep flooding Sentry. + let msg = "[composio-direct] list_connections failed: \ + Composio v3 connected_accounts failed: \ + HTTP 401: Invalid API key: ak_VsUvq*****"; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::ProviderUserState), + "composio-direct HTTP 401 + Invalid API key must demote to ProviderUserState" + ); + } + + #[test] + fn classifies_composio_direct_invalid_api_key_for_other_ops() { + // Same arm must cover every op-name the direct branches emit — + // not just `list_connections`. The matcher gates on the + // `[composio-direct]` prefix, not on a specific op string, so + // `list_tools` / `authorize` / `list_connections` all demote. + let shapes = [ + // list_tools prefetch fails before the actual list_tools call + "[composio-direct] list_tools: prefetch connections failed: \ + Composio v3 connected_accounts failed: HTTP 401: Invalid API key: ak_…", + // direct authorize hits the v3 /connected_accounts/link wall + "[composio-direct] authorize failed: \ + Composio v3 connected_accounts/link failed: HTTP 401: Invalid API key: ak_…", + // direct list_tools itself + "[composio-direct] list_tools failed: \ + Composio v3 tools failed: HTTP 401: Invalid API key: ak_…", + // periodic-tick rendering (no "[composio-direct]" prefix because + // periodic.rs wraps differently, but the failure still gets the + // hook — handled by ops.rs's report path, not the + // expected_error_kind body shape, so we only verify the + // composio-direct branch here) + ]; + for msg in shapes { + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::ProviderUserState), + "every [composio-direct] op with HTTP 401 / Invalid API key must demote: {msg}" + ); + } + } + + #[test] + fn classifies_composio_direct_with_invalid_api_key_only_no_http_401() { + // The matcher accepts EITHER `HTTP 401` OR `Invalid API key` + // alongside the `[composio-direct]` prefix. Catches the wire + // shape variant where the body anchor lands but the status text + // is rendered differently (e.g. "401 Unauthorized" instead of + // "HTTP 401") — same user-state condition. + let msg = "[composio-direct] list_connections failed: \ + Composio v3 connected_accounts failed: \ + 401 Unauthorized: Invalid API key: ak_…"; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::ProviderUserState), + "composio-direct + Invalid API key body must demote even without literal 'HTTP 401'" + ); + } + + #[test] + fn does_not_classify_unrelated_http_401_as_composio_direct_user_state() { + // Discrimination test: a generic 401 that does NOT carry the + // `[composio-direct]` prefix must NOT match this arm. This + // protects against the arm accidentally swallowing backend-mode + // composio 401s, unrelated integration 401s, or any other + // 401-containing message that lacks the direct-mode anchor. + // + // The backend-mode shape is `Backend returned 401 …`; it does + // not contain `[composio-direct]`, so the new arm rightly skips + // it. Backend-mode 401s remain a real Sentry signal (bad + // service-to-service auth, expired token, etc.). + let backend_401 = "[composio] list_connections failed: \ + Backend returned 401 Unauthorized for GET \ + https://api.tinyhumans.ai/agent-integrations/composio/connections: \ + Invalid API key"; + assert_ne!( + expected_error_kind(backend_401), + Some(ExpectedErrorKind::ProviderUserState), + "backend-mode 401 must NOT demote via the composio-direct arm" + ); + + let unrelated_401 = "GitHub API error: HTTP 401: Bad credentials"; + assert_ne!( + expected_error_kind(unrelated_401), + Some(ExpectedErrorKind::ProviderUserState), + "unrelated 401 (no [composio-direct] anchor) must NOT match the composio-direct arm" + ); + } + + #[test] + fn does_not_classify_composio_direct_500_as_user_state() { + // Real bug shapes — a 500 from the direct v3 path with no auth + // body anchor — must still fall through to `None` so Sentry + // sees them. Without this guard the arm could be too permissive + // and silence genuine backend faults. + let msg = "[composio-direct] list_connections failed: \ + Composio v3 connected_accounts failed: HTTP 500"; + assert_eq!( + expected_error_kind(msg), + None, + "composio-direct 500 with no auth body must NOT demote — it is a real bug shape" + ); + } + + // ── TAURI-RUST-34H: backend-wrapped Cloudflare anti-bot interstitial ─ + + #[test] + fn classifies_backend_cloudflare_antibot_wrap_as_provider_user_state() { + // Canonical Sentry TAURI-RUST-34H wire shape — the verbatim title + // body from the issue (8,851 events / 14d on self-hosted + // `tauri-rust`). The backend wraps an upstream Cloudflare 403 + // anti-bot challenge as `Backend returned 500 … 403 Just a moment...

Icon for api.tinyhumans.aiapi.tinyhumans.ai

...Powered by Cloudflare..."#; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::ProviderUserState), + "backend-wrapped Cloudflare anti-bot interstitial must demote to ProviderUserState" + ); + } + + #[test] + fn classifies_minimal_cloudflare_antibot_body_as_provider_user_state() { + // Strip the wire shape down to just the two anchors — the + // matcher should still fire so future renderings (different + // line breaks, stripped HTML, alternate caller wrappers) still + // demote. + let msg = "Just a moment...\ncloudflare\n"; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::ProviderUserState), + "minimal `Just a moment...` + `cloudflare` body must demote" + ); + } + + #[test] + fn does_not_classify_half_anchor_cloudflare_messages_as_user_state() { + // Discrimination test for the double-anchor: either half on its + // own must NOT match. This guards against unrelated bodies that + // happen to use either phrase out of context. + + // Half-anchor 1: `just a moment` without `cloudflare` — e.g. + // a daemon restart spinner blurb. + let half_a = "Just a moment, while we restart the daemon"; + assert_ne!( + expected_error_kind(half_a), + Some(ExpectedErrorKind::ProviderUserState), + "`Just a moment` without `cloudflare` must NOT match the CF anti-bot arm" + ); + + // Half-anchor 2: `cloudflare` without `just a moment...` — e.g. + // a CF Workers footer mention elsewhere. + let half_b = "Powered by Cloudflare"; + assert_ne!( + expected_error_kind(half_b), + Some(ExpectedErrorKind::ProviderUserState), + "`cloudflare` without `Just a moment...` must NOT match the CF anti-bot arm" + ); + } + + #[test] + fn does_not_classify_genuine_backend_500_without_cloudflare_body() { + // Real bug shape — a 500 from the same backend endpoint with no + // Cloudflare interstitial body — must still fall through so + // Sentry sees it. Without this guard the arm could be too + // permissive and silence genuine database / handler faults. + let msg = "Backend returned 500 Internal Server Error for GET \ + https://api.tinyhumans.ai/agent-integrations/composio/connections: \ + database connection pool exhausted"; + assert_eq!( + expected_error_kind(msg), + None, + "genuine backend 500 without Cloudflare body must NOT demote — it is a real bug" + ); + } + + #[test] + fn classifies_list_models_404_as_provider_user_state() { + // OPENHUMAN-TAURI-YJ: `inference/provider/ops.rs::list_models` probed + // a custom-provider's `/models` endpoint and the upstream server + // returned 404 because the base URL is wrong / doesn't host a models + // listing. User-config state — the model-dropdown probe already + // surfaces it inline. Pin the verbatim Sentry payload plus a few + // body-shape variants (different upstreams emit different 404 bodies) + // so the path-agnostic prefix anchor stays the source of truth. + for raw in [ + // Verbatim shape from the Sentry event. + r#"provider returned 404: {"error":"path \"/api/v1/models\" not found"}"#, + // FastAPI-style: `{"detail":"Not Found"}`. + r#"provider returned 404: {"detail":"Not Found"}"#, + // Bare HTML — happens when the user pointed at a non-API origin + // (e.g. the provider's docs site). + "provider returned 404: Not Found", + // After `truncate_with_ellipsis(.., 300)` clips a longer body — + // prefix anchor must still match. + r#"provider returned 404: {"error":{"message":"The requested URL /api/v1/models was not found on this server. Please check the URL or co…"#, + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ProviderUserState), + "OPENHUMAN-TAURI-YJ list_models 404 must classify as ProviderUserState: {raw}" + ); + } + } + + #[test] + fn does_not_classify_non_404_list_models_failures_as_user_state() { + // Discrimination guard: only the 404 prefix demotes. Sibling 4xx / + // 5xx codes from the same `provider returned NNN:` emit site must + // stay actionable in Sentry — they map to BYO-key auth walls (401 / + // 403), client-shape bugs (400), and transient / server faults + // (429 / 5xx) respectively. Pinning each shape here protects the + // #2286 BYO-key 401 contract and prevents the arm from silently + // widening to all 4xx. + for raw in [ + // BYO-key auth wall — must still escalate (`does_not_classify_byo_key_provider_401_as_session_expired` sibling guard). + r#"provider returned 401: {"error":"Invalid API key"}"#, + r#"provider returned 403: {"error":"Forbidden: API key revoked"}"#, + // Request-shape mismatch — likely a bug in our client. + r#"provider returned 400: {"error":"Bad Request"}"#, + // Transient — caught by retry/backoff at the provider layer, + // does NOT belong in the user-state bucket. + r#"provider returned 429: {"error":"rate_limited"}"#, + r#"provider returned 503: upstream temporarily unavailable"#, + // 500 — a real upstream bug; must reach Sentry. + r#"provider returned 500: {"error":"internal_server_error"}"#, + ] { + assert_ne!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ProviderUserState), + "non-404 list_models failure must NOT demote to ProviderUserState: {raw}" + ); + } + } + + #[test] + fn classifies_local_ai_binary_missing_errors() { + // OPENHUMAN-TAURI-9N: `local_ai_tts` returns this exact string + // from `service::speech::tts` when piper isn't on PATH or + // `PIPER_BIN` isn't set. + assert_eq!( + expected_error_kind("piper binary not found. Set PIPER_BIN or install piper."), + Some(ExpectedErrorKind::LocalAiBinaryMissing) + ); + // Sibling shapes from the same service area share the anchor and + // must classify the same way — the user-facing remediation is + // identical (install / configure the binary). + assert_eq!( + expected_error_kind( + "whisper.cpp binary not found. Set WHISPER_BIN or install whisper-cli." + ), + Some(ExpectedErrorKind::LocalAiBinaryMissing) + ); + assert_eq!( + expected_error_kind( + "Ollama binary not found at '/usr/local/bin/ollama'. Provide a valid path to the ollama executable." + ), + Some(ExpectedErrorKind::LocalAiBinaryMissing) + ); + assert_eq!( + expected_error_kind("Ollama installed but binary not found on system"), + Some(ExpectedErrorKind::LocalAiBinaryMissing) + ); + // Wrapped by the RPC dispatcher in production: + // `"rpc.invoke_method failed: piper binary not found. …"`. + // The classifier is substring-based, so caller context must not + // defeat it. + assert_eq!( + expected_error_kind( + "rpc.invoke_method failed: piper binary not found. Set PIPER_BIN or install piper." + ), + Some(ExpectedErrorKind::LocalAiBinaryMissing) + ); + } + + #[test] + fn does_not_classify_unrelated_messages_as_binary_missing() { + // Pin the anchor: messages that talk about binaries in a + // different context (download failures, version mismatches) + // must not be silenced. + assert_eq!( + expected_error_kind("piper binary failed to spawn: permission denied"), + None + ); + assert_eq!( + expected_error_kind("whisper.cpp returned empty transcript"), + None + ); + } + + #[test] + fn classifies_session_expired_messages() { + // OPENHUMAN-TAURI-26: the canonical wire shape that `agent.run_single` + // and `web_channel.run_chat_task` re-emit via `report_error_or_expected` + // when the user's JWT expires mid-conversation. The classifier + // anchors on the literal `"session expired"` substring from the + // OpenHuman backend's 401 body — NOT on the bare `(401 Unauthorized)` + // status, which would also silence BYO-key OpenAI/Anthropic 401s + // that are actionable. + assert_eq!( + expected_error_kind( + r#"OpenHuman API error (401 Unauthorized): {"success":false,"error":"Session expired. Please log in again."}"# + ), + Some(ExpectedErrorKind::SessionExpired) + ); + + // Wrapped by the agent / web-channel report sites in production — + // the classifier is substring-based so caller context must not + // defeat it. + assert_eq!( + expected_error_kind( + r#"run_chat_task failed client_id=abc thread_id=t1 request_id=r1 error=OpenHuman API error (401 Unauthorized): {"success":false,"error":"Session expired. Please log in again."}"# + ), + Some(ExpectedErrorKind::SessionExpired) + ); + + // Sentinel raised by `providers::openhuman_backend::resolve_bearer` + // when the scheduler-gate signed-out override is set + // (OPENHUMAN-TAURI-1T's cascade dampener returns this so callers + // get the same teardown path as a real backend 401). + assert_eq!( + expected_error_kind( + "SESSION_EXPIRED: backend session not active — sign in to resume LLM work" + ), + Some(ExpectedErrorKind::SessionExpired) + ); + + // Local pre-flight guards — OpenHuman-specific phrasing, safe to + // match regardless of caller wrapping. + for raw in [ + "no backend session token; run auth_store_session first", + "session JWT required", + "composio unavailable: no backend session token. Sign in first (auth_store_session).", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::SessionExpired), + "should classify as session-expired: {raw}" + ); + } + } + + /// OPENHUMAN-TAURI-SG (33 events, escalating, release `0.53.43+2b64ea8…`): + /// pre-#1763 leak of the `resolve_bearer` sentinel through + /// `agent.run_single`. PR #1763 (1fb0bef5) wired the `SessionExpired` + /// arm and the existing `classifies_session_expired_messages` test + /// covers the same byte string — this test pins the *Sentry-event + /// verbatim* shape (taken from the OPENHUMAN-TAURI-SG event payload) + /// so a future tweak to `is_session_expired_message` cannot regress + /// this exact wire form without a red test. + #[test] + fn session_expired_sg_wire_shape_matches() { + let msg = "SESSION_EXPIRED: backend session not active — sign in to resume LLM work"; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::SessionExpired), + "OPENHUMAN-TAURI-SG wire shape must classify as SessionExpired — \ + a regression here re-leaks 33+ events/cycle to Sentry" + ); + } + + /// The two sibling `SESSION_EXPIRED:` bail sites in + /// `providers::factory::verify_session_active` emit different message + /// suffixes but the same sentinel prefix. They route through the same + /// classifier as the run_single bail at + /// `providers::openhuman_backend::resolve_bearer`, and any matcher + /// tweak that breaks the family (e.g. moving from `contains` to a + /// stricter prefix/suffix match) would re-leak ALL of them. Pin every + /// variant the codebase actually emits so a future regression on the + /// matcher is caught for the whole family, not just the SG instance. + #[test] + fn session_expired_sibling_family_factory_strings_match() { + // src/openhuman/inference/provider/factory.rs:247 + // (verify_session_active — scheduler_gate signed-out path) + let custom_providers_variant = + "SESSION_EXPIRED: backend session not active — sign in to use custom providers"; + // src/openhuman/inference/provider/factory.rs:266 + // (verify_session_active — empty auth-profile JWT path) + let no_backend_session_variant = + "SESSION_EXPIRED: no backend session — sign in to use OpenHuman"; + + for raw in [custom_providers_variant, no_backend_session_variant] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::SessionExpired), + "factory.rs sibling sentinel must classify as SessionExpired: {raw}" + ); + } + } + + /// OPENHUMAN-TAURI-4P0: the OpenHuman backend rejects an expired/ + /// revoked JWT with the envelope `{"success":false,"error":"Invalid + /// token"}` (vs. the explicit `"Session expired. Please log in again."` + /// body covered by `classifies_session_expired_messages`). Same emit + /// site, same wrapping by `web_channel.run_chat_task`, but the body + /// substring is different. + /// + /// The matcher uses a conjunctive `"OpenHuman API error (401"` + + /// envelope-shaped `"\"error\":\"Invalid token\""` anchor pair so the + /// #2286 contract for bare `"Invalid token"` / BYO-key 401s is + /// preserved — `does_not_classify_byo_key_provider_401_as_session_expired` + /// pins that and must stay green. + #[test] + fn classifies_openhuman_invalid_token_401_as_session_expired() { + // Verbatim wire shape from the OPENHUMAN-TAURI-4P0 event payload. + let msg = r#"run_chat_task failed client_id=lssXhQidBfzGXG9k thread_id=thread-743193ba-f0c1-4008-b665-64d3030d1453 request_id=00696b71-fa05-4574-bcdb-5744a5dac6ea error=OpenHuman API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::SessionExpired), + "OPENHUMAN-TAURI-4P0 verbatim wire shape must classify as SessionExpired" + ); + + // Unwrapped emit shape (without the run_chat_task prefix) — also + // appears at provider/agent layers; the substring matcher must + // catch it regardless of caller wrapping. + assert_eq!( + expected_error_kind( + r#"OpenHuman API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"# + ), + Some(ExpectedErrorKind::SessionExpired), + "unwrapped OpenHuman invalid-token envelope must classify as SessionExpired" + ); + } + + /// TAURI-RUST-4K5 (118 events, escalating on 0.56.0): the embedding + /// client at `src/openhuman/embeddings/openai.rs:139` wraps the same + /// OpenHuman backend `{"success":false,"error":"Invalid token"}` 401 + /// envelope as 4P0, but with the `"Embedding API error"` prefix + /// instead of `"OpenHuman API error"` (different emit-site format + /// string, same underlying session-expired cause — see breadcrumb + /// `[scheduler_gate] signed_out false -> true` immediately preceding + /// the 401 in the event payload). + /// + /// Uses the same conjunctive `" (401"` + envelope-shaped + /// `"\"error\":\"Invalid token\""` anchor pattern as 4P0 so the + /// #2286 / BYO-key contract is preserved — covered by + /// `does_not_classify_byo_key_provider_401_as_session_expired` and + /// `does_not_classify_embedding_byo_key_401_as_session_expired` + /// (below). + #[test] + fn classifies_embedding_api_invalid_token_401_as_session_expired() { + // Verbatim wire shape from the TAURI-RUST-4K5 event payload (Sentry + // issue 5230, latest event 2026-05-27 20:49 on openhuman@0.56.0, + // domain=embeddings operation=openai_embed status=401). + let msg = + r#"Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::SessionExpired), + "TAURI-RUST-4K5 verbatim wire shape must classify as SessionExpired" + ); + + // The substring matcher must survive caller wrapping the same way + // the 4P0 web-channel `run_chat_task` test wraps the body — callers + // that re-emit through a tracing field or another layer prepend + // arbitrary context. + let wrapped = r#"openai_embed failed error=Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; + assert_eq!( + expected_error_kind(wrapped), + Some(ExpectedErrorKind::SessionExpired), + "wrapped 4K5 envelope must still classify as SessionExpired" + ); + } + + /// TAURI-RUST-1EE (Sentry issue 1807, 110 events, 109 on + /// openhuman@0.56.0): the streaming-chat path wraps the same OpenHuman + /// backend `{"success":false,"error":"Invalid token"}` 401 envelope + /// with the `"OpenHuman streaming API error"` prefix (emitted at + /// `inference/provider/compatible.rs:949`) — distinct from the + /// non-streaming `"OpenHuman API error"` prefix (4P0) and the + /// `"Embedding API error"` prefix (4K5). The `streaming` token between + /// `OpenHuman` and `API error` means the 4P0 anchor + /// (`"OpenHuman API error (401"`) does not match it, so it needs its + /// own prefix arm. + #[test] + fn classifies_openhuman_streaming_invalid_token_401_as_session_expired() { + // Verbatim wire shape from the TAURI-RUST-1EE event payload + // (domain=llm_provider operation=streaming_chat status=401 + // provider=OpenHuman model=reasoning-v1). + let msg = r#"OpenHuman streaming API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::SessionExpired), + "TAURI-RUST-1EE verbatim streaming wire shape must classify as SessionExpired" + ); + + // Caller-wrapped (agent.run_single / web_channel.run_chat_task + // re-emit prepends context) must still classify. + let wrapped = r#"run_chat_task failed error=OpenHuman streaming API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; + assert_eq!( + expected_error_kind(wrapped), + Some(ExpectedErrorKind::SessionExpired), + "wrapped 1EE streaming envelope must still classify as SessionExpired" + ); + } + + /// Polarity guard for the 1EE streaming arm — a third-party BYO-key + /// provider's streaming 401 (`"OpenAI streaming API error (401 …): + /// invalid_api_key"`) must STILL reach Sentry as actionable + /// misconfiguration. The `"OpenHuman streaming API error (401"` prefix + /// gate keeps the match OpenHuman-scoped. + #[test] + fn does_not_classify_streaming_byo_key_401_as_session_expired() { + for raw in [ + "OpenAI streaming API error (401 Unauthorized): invalid_api_key", + r#"OpenAI streaming API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#, + "Anthropic streaming API error (401): authentication_error", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "BYO-key streaming 401 must reach Sentry as actionable error: {raw}" + ); + } + } + + /// Polarity guard for the 4K5 arm. The classifier must NOT swallow + /// `"Embedding API error (401 …)"` shapes from third-party BYO-key + /// embedding providers (OpenAI / Voyage / Cohere upstream rejecting + /// the user's own API key). Those are actionable user-config errors + /// that need to reach Sentry — same contract as + /// `does_not_classify_byo_key_provider_401_as_session_expired` for + /// the OpenAI chat API. + #[test] + fn does_not_classify_embedding_byo_key_401_as_session_expired() { + for raw in [ + "Embedding API error (401 Unauthorized): invalid_api_key", + r#"Embedding API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#, + // Wire shape without the OpenHuman envelope — bare provider + // rejection prose. Must reach Sentry as actionable BYO-key + // misconfiguration. + "Embedding API error (401): authentication_error", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "BYO-key embedding 401 must reach Sentry as actionable error: {raw}" + ); + } + } + + #[test] + fn does_not_classify_byo_key_provider_401_as_session_expired() { + // Critical: a BYO-key 401 from OpenAI / Anthropic etc. is an + // actionable misconfiguration (wrong API key) that the user needs + // to fix in settings. It must reach Sentry as an error and must + // NOT be classified as session-expired at the agent layer — the + // strict classifier requires the OpenHuman backend's + // "session expired" body to anchor the match. The JSON-RPC + // dispatch-site classifier uses the same strict rule so these + // scoped provider failures never clear the app session either. + for raw in [ + "OpenAI API error (401 Unauthorized): invalid_api_key", + "Anthropic API error (401 Unauthorized): authentication_error", + "OpenAI API error (401): unauthorized", + r#"OpenAI API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#, + // Generic "invalid token" without OpenHuman session phrasing — + // could mean a third-party provider rejected its own token. + "Invalid token", + "got an invalid token here", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "BYO-key / generic 401 must reach Sentry as actionable error: {raw}" + ); + } + } + + #[test] + fn does_not_classify_unrelated_messages_as_session_expired() { + // Bare numeric 401 (port number, runbook reference) must not be + // silenced. + assert_eq!(expected_error_kind("server returned 401"), None); + assert_eq!( + expected_error_kind("see runbook for 401 handling at https://example.com/401"), + None + ); + // Provider 5xx — must reach Sentry. + assert_eq!( + expected_error_kind("OpenAI API error (500): internal server error"), + None + ); + // Lowercase sentinel must NOT match — the SESSION_EXPIRED sentinel + // is case-sensitive by design (matches the sentinel emitted by + // `providers::openhuman_backend::resolve_bearer` exactly). + assert_eq!(expected_error_kind("session_expired lowercase"), None); + } + + #[test] + fn report_error_does_not_panic_with_many_tags() { + let err = anyhow::anyhow!("multi-tag"); + report_error( + &err, + "test", + "multi_tag", + &[("a", "1"), ("b", "2"), ("c", "3"), ("d", "4")], + ); + } + + fn event_with_tags(pairs: &[(&str, &str)]) -> sentry::protocol::Event<'static> { + let mut event = sentry::protocol::Event::default(); + let mut tags: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for (k, v) in pairs { + tags.insert((*k).to_string(), (*v).to_string()); + } + event.tags = tags; + event + } + + fn event_with_tags_and_message( + pairs: &[(&str, &str)], + message: &str, + ) -> sentry::protocol::Event<'static> { + let mut event = event_with_tags(pairs); + event.message = Some(message.to_string()); + event + } + + #[test] + fn transient_filter_drops_429_408_502_503_504() { + for status in ["429", "408", "502", "503", "504"] { + let event = event_with_tags(&[ + ("domain", "llm_provider"), + ("failure", "non_2xx"), + ("status", status), + ]); + assert!( + is_transient_provider_http_failure(&event), + "status {status} must be classified as transient and filtered" + ); + } + } + + #[test] + fn transient_filter_keeps_permanent_failures() { + for status in ["400", "401", "403", "404", "500"] { + let event = event_with_tags(&[ + ("domain", "llm_provider"), + ("failure", "non_2xx"), + ("status", status), + ]); + assert!( + !is_transient_provider_http_failure(&event), + "status {status} must NOT be filtered — it's actionable" + ); + } + } + + #[test] + fn transient_filter_keeps_aggregate_all_exhausted() { + let event = event_with_tags(&[ + ("domain", "llm_provider"), + ("failure", "all_exhausted"), + ("status", "503"), + ]); + assert!( + !is_transient_provider_http_failure(&event), + "aggregate all_exhausted events must surface (they are the cascade signal)" + ); + } + + #[test] + fn transient_filter_keeps_events_with_no_status_tag() { + let event = event_with_tags(&[("domain", "llm_provider"), ("failure", "non_2xx")]); + assert!( + !is_transient_provider_http_failure(&event), + "missing status tag must not be silently dropped" + ); + } + + // Regression guard: the filter must scope to provider events only. Other + // subsystems emit `failure=non_2xx` (e.g. + // `providers/compatible.rs` uses the same marker for OAI-compatible + // error paths, but every site goes through `report_error(.., + // "llm_provider", ..)` so the domain tag is consistent), but the broader + // point is: any future caller that re-uses the same tag set for a + // different domain must NOT be silently dropped by this filter. + #[test] + fn transient_filter_keeps_events_with_no_domain_tag() { + let event = event_with_tags(&[("failure", "non_2xx"), ("status", "503")]); + assert!( + !is_transient_provider_http_failure(&event), + "missing domain tag means the event isn't provider-originated — must surface" + ); + } + + #[test] + fn transient_filter_keeps_events_from_other_domains() { + let event = event_with_tags(&[ + ("domain", "scheduler"), + ("failure", "non_2xx"), + ("status", "503"), + ]); + assert!( + !is_transient_provider_http_failure(&event), + "non-provider domain must surface even if failure/status tags collide" + ); + } + + #[test] + fn backend_api_filter_drops_transient_statuses() { + for status in TRANSIENT_HTTP_STATUSES { + let event = event_with_tags(&[ + ("domain", "backend_api"), + ("failure", "non_2xx"), + ("status", status), + ]); + assert!( + is_transient_backend_api_failure(&event), + "backend status {status} must be classified as transient" + ); + } + } + + #[test] + fn backend_api_filter_drops_transient_transport_phrases() { + for phrase in TRANSIENT_TRANSPORT_PHRASES { + let event = event_with_tags_and_message( + &[("domain", "backend_api"), ("failure", "transport")], + &format!("GET /teams failed: {phrase}"), + ); + assert!( + is_transient_backend_api_failure(&event), + "backend transport phrase {phrase} must be classified as transient" + ); + } + } + + #[test] + fn backend_api_filter_keeps_non_transient_failures() { + for status in ["404", "500"] { + let event = event_with_tags(&[ + ("domain", "backend_api"), + ("failure", "non_2xx"), + ("status", status), + ]); + assert!( + !is_transient_backend_api_failure(&event), + "backend status {status} must stay visible" + ); + } + + let wrong_domain = event_with_tags(&[ + ("domain", "scheduler"), + ("failure", "non_2xx"), + ("status", "503"), + ]); + assert!( + !is_transient_backend_api_failure(&wrong_domain), + "domain scoping must keep unrelated transient-shaped events visible" + ); + + let non_matching_transport = event_with_tags_and_message( + &[("domain", "backend_api"), ("failure", "transport")], + "GET /teams failed: certificate verify failed", + ); + assert!( + !is_transient_backend_api_failure(&non_matching_transport), + "transport failures without an allowlisted phrase must stay visible" + ); + } + + #[test] + fn integrations_filter_drops_transient_statuses() { + for status in TRANSIENT_HTTP_STATUSES { + let event = event_with_tags(&[ + ("domain", "integrations"), + ("failure", "non_2xx"), + ("status", status), + ]); + assert!( + is_transient_integrations_failure(&event), + "integrations status {status} must be classified as transient" + ); + } + } + + #[test] + fn integrations_filter_drops_transient_transport_phrases() { + for phrase in TRANSIENT_TRANSPORT_PHRASES { + let event = event_with_tags_and_message( + &[("domain", "integrations"), ("failure", "transport")], + &format!("GET /agent-integrations/tools failed: {phrase}"), + ); + assert!( + is_transient_integrations_failure(&event), + "integrations transport phrase {phrase} must be classified as transient" + ); + } + } + + #[test] + fn integrations_filter_keeps_non_transient_failures() { + for status in ["404", "500"] { + let event = event_with_tags(&[ + ("domain", "integrations"), + ("failure", "non_2xx"), + ("status", status), + ]); + assert!( + !is_transient_integrations_failure(&event), + "integrations status {status} must stay visible" + ); + } + + // Sibling-domain check: composio op-layer events MUST be silenced + // by the integrations filter — composio routes through the same + // `IntegrationClient` so the failure shape is identical, but + // op-level reporters that wrap and re-emit with their own domain + // tag would otherwise escape (OPENHUMAN-TAURI-35 / -2H). + let scheduler_domain = event_with_tags(&[ + ("domain", "scheduler"), + ("failure", "non_2xx"), + ("status", "503"), + ]); + assert!( + !is_transient_integrations_failure(&scheduler_domain), + "domain scoping must keep unrelated transient-shaped events visible" + ); + + let non_matching_transport = event_with_tags_and_message( + &[("domain", "integrations"), ("failure", "transport")], + "GET /agent-integrations/tools failed: invalid certificate", + ); + assert!( + !is_transient_integrations_failure(&non_matching_transport), + "transport failures without an allowlisted phrase must stay visible" + ); + } + + #[test] + fn composio_domain_routes_through_integrations_filter() { + // OPENHUMAN-TAURI-35 (~139 events) / -2H (~26 events): + // `[composio] list_connections failed: Backend returned 502 …` — + // composio op-layer wrappers (e.g. `composio_list_connections`) emit + // errors under `domain="composio"` so the original + // `domain="integrations"` filter let them through. Routing the + // composio domain through the same transient classifier closes + // that gap; the underlying transport / non_2xx semantics are + // identical because both layers share the same `IntegrationClient`. + for status in TRANSIENT_HTTP_STATUSES { + let event = event_with_tags(&[ + ("domain", "composio"), + ("failure", "non_2xx"), + ("status", status), + ]); + assert!( + is_transient_integrations_failure(&event), + "composio status {status} must be classified as transient" + ); + } + + // Transport-phrase variant — composio also surfaces reqwest + // transport failures (timeouts, connection resets) once the op + // wrapper has tagged the event with `failure=transport`. + for phrase in TRANSIENT_TRANSPORT_PHRASES { + let event = event_with_tags_and_message( + &[("domain", "composio"), ("failure", "transport")], + &format!("[composio] execute failed: {phrase}"), + ); + assert!( + is_transient_integrations_failure(&event), + "composio transport phrase {phrase} must be classified as transient" + ); + } + + // Non-transient composio statuses (404 / 500) must still surface — + // actionable bugs even when reported under the composio domain. + for status in ["404", "500"] { + let event = event_with_tags(&[ + ("domain", "composio"), + ("failure", "non_2xx"), + ("status", status), + ]); + assert!( + !is_transient_integrations_failure(&event), + "composio status {status} must stay visible" + ); + } + } + + #[test] + fn updater_transient_403_is_dropped() { + let event = event_with_tags_and_message( + &[ + ("domain", "update"), + ("operation", "check_releases"), + ("failure", "non_2xx"), + ("status", "403"), + ], + "[observability] update.check_releases failed: GitHub API error: 403 Forbidden", + ); + assert!( + is_updater_transient_event(&event), + "GitHub 403 updater checks are unactionable transient/rate-limit noise" + ); + } + + #[test] + fn updater_transient_502_is_dropped() { + let event = event_with_tags_and_message( + &[ + ("domain", "update.check_releases"), + ("failure", "non_2xx"), + ("status", "502"), + ], + "GitHub API error: 502 Bad Gateway", + ); + assert!( + is_updater_transient_event(&event), + "GitHub 5xx updater checks must be filtered as transient" + ); + } + + #[test] + fn updater_real_panic_still_reported() { + let event = event_with_tags_and_message( + &[("domain", "update"), ("operation", "check_releases")], + "thread 'main' panicked at src/openhuman/update/core.rs: index out of bounds", + ); + assert!( + !is_updater_transient_event(&event), + "update-domain events without a transient updater shape must still reach Sentry" + ); + } + + #[test] + fn updater_endpoint_non_success_message_is_dropped() { + // TAURI-RUST-CD (~151 events / 9 days, Windows): `tauri-plugin-updater` + // logs `update endpoint did not respond with a successful status code` + // (updater.rs) on any non-2xx response and discards the status, so the + // captured event has NO `domain`/`status` tag — only the bare message. + // It can therefore only be matched via the message fast-path. + assert!(is_updater_transient_message( + "update endpoint did not respond with a successful status code" + )); + + let event = event_with_tags_and_message( + &[], + "update endpoint did not respond with a successful status code", + ); + assert!( + is_updater_transient_event(&event), + "the plugin's status-blind, domain-less non-success log line is unactionable updater noise" + ); + } + + #[test] + fn updater_endpoint_non_success_anchor_does_not_silence_unrelated_errors() { + // The new anchor is the literal plugin string. Other updater failures + // that DO carry an actionable signal (signature/permission failures on + // apply, deserialize errors) and unrelated non-updater errors that + // merely mention a status code MUST NOT be dropped by it. Pin the + // rejection contract so a future refactor doesn't loosen the substring. + for msg in [ + "failed to apply update: signature verification failed", + "failed to deserialize update response: missing field `version`", + "backend request to /agent-integrations failed with status code 500", + "tool exited with non-zero status code 1", + ] { + let event = event_with_tags_and_message(&[], msg); + assert!( + !is_updater_transient_event(&event), + "unrelated/actionable error must still reach Sentry: {msg}" + ); + } + } + + #[test] + fn message_failure_classifier_matches_canonical_status_phrases() { + for msg in [ + "rpc.invoke_method failed: GET /teams failed (502 Bad Gateway)", + "GET /teams/me/usage failed (503 Service Unavailable)", + "downstream returned (504 Gateway Timeout): retry budget exhausted", + "OpenHuman API error (520 ): cf", + "POST /channels/telegram/typing failed (429 Too Many Requests)", + "auth connect failed: 503 Service Unavailable", + ] { + assert!( + is_transient_message_failure(msg), + "{msg:?} must be classified as transient" + ); + } + } + + #[test] + fn message_failure_classifier_matches_transport_phrases() { + for msg in [ + "integrations.get failed: composio/tools → operation timed out", + "GET https://api.example.com → connection forcibly closed (os 10054)", + "POST /v1/foo → tls handshake eof", + "error sending request for url (https://api.example.com)", + ] { + assert!( + is_transient_message_failure(msg), + "{msg:?} must be classified as transient" + ); + } + } + + #[test] + fn message_failure_classifier_keeps_unrelated_messages() { + for msg in [ + "rpc.invoke_method failed: schema validation error", + "process 502 exited unexpectedly", + "GET /teams failed (404 Not Found)", + "GET /teams failed (500 Internal Server Error)", + "unrelated error with port 5023", + "", + ] { + assert!( + !is_transient_message_failure(msg), + "{msg:?} must not be classified as transient" + ); + } + } + + #[test] + fn budget_filter_drops_budget_message_on_tagged_400() { + let event = event_with_tags_and_message( + &[("failure", "non_2xx"), ("status", "400")], + r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"#, + ); + + assert!(is_budget_event(&event)); + } + + #[test] + fn budget_filter_drops_budget_exception_on_tagged_400() { + let mut event = event_with_tags(&[("failure", "non_2xx"), ("status", "400")]); + event.exception.values.push(sentry::protocol::Exception { + value: Some("Budget exceeded — add credits to continue".to_string()), + ..Default::default() + }); + + assert!(is_budget_event(&event)); + } + + #[test] + fn budget_filter_keeps_non_budget_400() { + let event = event_with_tags_and_message( + &[("failure", "non_2xx"), ("status", "400")], + "Bad request: missing field", + ); + + assert!(!is_budget_event(&event)); + } + + #[test] + fn budget_filter_requires_non_2xx_failure_and_400_status() { + let message = "Budget exceeded — add credits to continue"; + for tags in [ + vec![("failure", "transport"), ("status", "400")], + vec![("failure", "non_2xx"), ("status", "500")], + vec![("failure", "non_2xx")], + ] { + let event = event_with_tags_and_message(&tags, message); + assert!(!is_budget_event(&event)); + } + } + + #[test] + fn report_error_or_expected_does_not_panic() { + report_error_or_expected( + "local ai is disabled", + "rpc", + "invoke_method", + &[("method", "openhuman.inference_prompt")], + ); + report_error_or_expected( + "ollama API key not set", + "agent", + "provider_chat", + &[("provider", "ollama")], + ); + // #2079 / #2076 / #2202 — exercises the expected_error_kind + // ProviderConfigRejection branch AND the report_expected_message + // skip-log arm (the agent/web-channel re-report demotion path). + report_error_or_expected( + "agent.run_single failed: custom_openai API error (400 Bad Request): \ + The supported API model names are deepseek-v4-pro or deepseek-v4-flash, \ + but you passed reasoning-v1.", + "agent", + "native_chat", + &[("provider", "custom_openai")], + ); + report_error_or_expected( + "custom_openai API error (400): invalid temperature: only 1 is allowed for this model", + "web_channel", + "run_chat_task", + &[("provider", "custom_openai")], + ); + } + + fn event_with_message(msg: &str) -> sentry::protocol::Event<'static> { + let mut event = sentry::protocol::Event::default(); + event.message = Some(msg.to_string()); + event + } + + fn event_with_exception_value(value: &str) -> sentry::protocol::Event<'static> { + let mut event = sentry::protocol::Event::default(); + event.exception = vec![sentry::protocol::Exception { + value: Some(value.to_string()), + ..Default::default() + }] + .into(); + event + } + + #[test] + fn max_iterations_filter_matches_message_path() { + // `report_error_message` calls `sentry::capture_message`, which + // populates `event.message`. The filter must see the canonical + // phrase on that field path. + let event = event_with_message("Agent exceeded maximum tool iterations (8)"); + assert!(is_max_iterations_event(&event)); + } + + #[test] + fn max_iterations_filter_matches_exception_path() { + // sentry-tracing with attach_stacktrace=true populates the + // exception list instead of (or in addition to) `event.message`. + // Filter must still catch the noise. + let event = event_with_exception_value( + "agent.run_single failed: Agent exceeded maximum tool iterations (10)", + ); + assert!(is_max_iterations_event(&event)); + } + + #[test] + fn max_iterations_filter_keeps_unrelated_events() { + assert!(!is_max_iterations_event(&event_with_message( + "provider returned 503" + ))); + assert!(!is_max_iterations_event(&event_with_message(""))); + assert!(!is_max_iterations_event(&sentry::protocol::Event::default())); + } + + // ── is_channel_message_not_found_event (TAURI-R7) ──────────────────────── + + fn channel_message_404_event(method: &str) -> sentry::protocol::Event<'static> { + let mut event = sentry::protocol::Event::default(); + event.tags.insert("domain".into(), "backend_api".into()); + event.tags.insert("failure".into(), "non_2xx".into()); + event.tags.insert("status".into(), "404".into()); + event.tags.insert("method".into(), method.into()); + event.message = Some( + "PATCH /channels/telegram/messages/1103 failed (404); response_body_len=172" + .to_string(), + ); + event + } + + #[test] + fn channel_message_not_found_filter_matches_patch() { + // Canonical TAURI-R7 shape: PATCH 404 on a channel-message path. + assert!(is_channel_message_not_found_event( + &channel_message_404_event("PATCH") + )); + } + + #[test] + fn channel_message_not_found_filter_matches_delete() { + assert!(is_channel_message_not_found_event( + &channel_message_404_event("DELETE") + )); + } + + #[test] + fn channel_message_not_found_filter_ignores_get_404() { + // GET 404 on a channel-message path is NOT an expected state — must keep Sentry signal. + assert!(!is_channel_message_not_found_event( + &channel_message_404_event("GET") + )); + } + + #[test] + fn channel_message_not_found_filter_ignores_non_channel_path() { + let mut event = channel_message_404_event("PATCH"); + event.message = Some("PATCH /auth/profile failed (404); response_body_len=42".to_string()); + assert!(!is_channel_message_not_found_event(&event)); + } + + #[test] + fn channel_message_not_found_filter_ignores_wrong_status() { + let mut event = channel_message_404_event("PATCH"); + event.tags.insert("status".into(), "403".into()); + assert!(!is_channel_message_not_found_event(&event)); + } + + #[test] + fn channel_message_not_found_filter_ignores_wrong_domain() { + let mut event = channel_message_404_event("PATCH"); + event.tags.insert("domain".into(), "channels".into()); + assert!(!is_channel_message_not_found_event(&event)); + } + + #[test] + fn channel_message_not_found_filter_matches_exception_path() { + // sentry-tracing with attach_stacktrace=true populates exception list. + let mut event = sentry::protocol::Event::default(); + event.tags.insert("domain".into(), "backend_api".into()); + event.tags.insert("failure".into(), "non_2xx".into()); + event.tags.insert("status".into(), "404".into()); + event.tags.insert("method".into(), "PATCH".into()); + event.exception = vec![sentry::protocol::Exception { + value: Some("PATCH /channels/discord/messages/abc failed (404): Not Found".to_string()), + ..Default::default() + }] + .into(); + assert!(is_channel_message_not_found_event(&event)); + } + + // ── LoopbackUnavailable (TAURI-R5, TAURI-R6) ───────────────────────────── + + /// Verbatim body shape from OPENHUMAN-TAURI-R5 (~2.5k events): the + /// `integrations.get` site reaches the embedded core's `127.0.0.1:18474` + /// listener during the boot window and reqwest's source chain renders as + /// `error sending request for url (…) → client error (Connect) → tcp + /// connect error → Connection refused (os error 61)`. + const R5_BODY: &str = "error sending request for url \ + (http://127.0.0.1:18474/agent-integrations/composio/connections) \ + → client error (Connect) → tcp connect error → Connection refused (os error 61)"; + + /// Verbatim body shape from OPENHUMAN-TAURI-R6 (~2.5k events): the same + /// transport failure as R5, re-wrapped one frame up by the composio + /// op-layer and re-emitted at the `rpc.invoke_method` site so it lands in + /// Sentry under `domain=rpc` instead of `domain=integrations`. + const R6_BODY: &str = "[composio] list_connections failed: \ + GET http://127.0.0.1:18474/agent-integrations/composio/connections failed: \ + error sending request for url \ + (http://127.0.0.1:18474/agent-integrations/composio/connections) \ + → client error (Connect) → tcp connect error → Connection refused (os error 61)"; + + #[test] + fn classifies_r5_loopback_connect_refused_as_loopback_unavailable() { + assert_eq!( + expected_error_kind(R5_BODY), + Some(ExpectedErrorKind::LoopbackUnavailable), + "R5 body must classify as LoopbackUnavailable, not the broader NetworkUnreachable bucket" + ); + } + + #[test] + fn classifies_r6_rpc_wrapped_loopback_connect_refused_as_loopback_unavailable() { + assert_eq!( + expected_error_kind(R6_BODY), + Some(ExpectedErrorKind::LoopbackUnavailable), + "R6 body (rpc.invoke_method re-wrap) must classify as LoopbackUnavailable" + ); + } + + #[test] + fn classifies_loopback_connect_refused_across_platforms() { + // Linux WSL / native: os error 111. Windows WSAECONNREFUSED: 10061. + // Both must classify so the matcher works regardless of where the + // user's desktop happens to be running. + for raw in [ + "error sending request for url (http://127.0.0.1:18474/x) \ + → tcp connect error → Connection refused (os error 111)", + "error sending request for url (http://localhost:18474/x) \ + → tcp connect error → Connection refused (os error 10061)", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::LoopbackUnavailable), + "should classify as LoopbackUnavailable across platforms: {raw}" + ); + } + } + + #[test] + fn loopback_unavailable_precedence_over_network_unreachable() { + // Precedence guard: a loopback `Connection refused (os error 61)` + // body would ALSO match `is_network_unreachable_message` because the + // broader matcher catches both `error sending request for url` and + // `connection refused`. The ladder must route through the + // loopback-specific bucket first so the two error classes stay + // distinguishable in Sentry. + let kind = expected_error_kind(R5_BODY); + assert_eq!(kind, Some(ExpectedErrorKind::LoopbackUnavailable)); + assert_ne!(kind, Some(ExpectedErrorKind::NetworkUnreachable)); + } + + #[test] + fn does_not_classify_loopback_url_with_different_error_class_as_loopback() { + // A real upstream HTTP failure that happens to hit a developer's + // local proxy on `127.0.0.1:` (e.g. `mitmproxy`, `Charles`, + // `ngrok http`) must NOT be silenced as loopback noise — the body + // shape is a 503 status, not a transport-level connect-refused, and + // is actionable for Sentry. + let raw = "Backend returned 503 Service Unavailable for GET \ + http://127.0.0.1:8080/agent-integrations/composio/connections: \ + upstream timed out"; + assert!( + !matches!( + expected_error_kind(raw), + Some(ExpectedErrorKind::LoopbackUnavailable) + ), + "loopback URL with non-transport error must not classify as LoopbackUnavailable" + ); + } + + #[test] + fn does_not_classify_non_loopback_connect_refused_as_loopback() { + // A `Connection refused` against a non-loopback host (DNS resolved + // to a remote IP, ISP-level block, captive portal) must fall + // through to `NetworkUnreachable`, not into the loopback bucket. + let raw = "error sending request for url \ + (https://api.tinyhumans.ai/agent-integrations/composio/connections) \ + → tcp connect error → Connection refused (os error 61)"; + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::NetworkUnreachable) + ); + } + + #[test] + fn loopback_matcher_requires_both_host_and_errno_anchors() { + // Defense against the matcher being too eager: bodies that satisfy + // only one of the two conjunctive anchors must not classify into the + // loopback bucket. They may still demote via the broader + // `NetworkUnreachable` matcher — that is the correct fall-through — + // but the bucket must stay distinct so Sentry's "what class is + // spiking?" signal is preserved. + let loopback_host_no_errno = + "doctor: probed 127.0.0.1:18474 and got connection refused without errno detail"; + assert_ne!( + expected_error_kind(loopback_host_no_errno), + Some(ExpectedErrorKind::LoopbackUnavailable), + "loopback host without `(os error N)` errno must not classify as LoopbackUnavailable" + ); + + let errno_no_loopback_host = "note: connection refused (os error 61) on retry"; + assert_ne!( + expected_error_kind(errno_no_loopback_host), + Some(ExpectedErrorKind::LoopbackUnavailable), + "errno without loopback host anchor must not classify as LoopbackUnavailable" + ); + } + + #[test] + fn report_error_or_expected_routes_r5_r6_through_expected_path() { + // Smoke test: both verbatim Sentry bodies flow through + // `report_error_or_expected` without panicking. The classifier + // routes them to `report_expected_message` (debug breadcrumb, + // metadata-only) instead of `report_error_message` + // (`sentry::capture_message` at error level). We can't observe the + // Sentry hub from this test, but exercising the call path catches + // any future regression that re-introduces a panic or mis-types + // the arm. + report_error_or_expected( + R5_BODY, + "integrations", + "get", + &[ + ("path", "/agent-integrations/composio/connections"), + ("failure", "transport"), + ], + ); + report_error_or_expected( + R6_BODY, + "rpc", + "invoke_method", + &[("method", "openhuman.composio_list_connections")], + ); + } + + #[test] + fn classifies_channel_supervisor_restart_english_discord_gateway() { + // TAURI-RUST-15 (~11.4k events / 14d on self-hosted `tauri-rust`): + // verbatim wrapper from `channels::runtime::supervision::spawn_supervised_listener` + // around the Discord gateway transport error. The English body + // would otherwise match `is_network_unreachable_message` (which + // demotes to `warn!` — still a Sentry event); the supervisor + // wrap precedence routes it to `ChannelSupervisorRestart` + // (info-only breadcrumb). + let body = "Channel discord error: error sending request for url \ + (https://discord.com/api/v10/gateway/bot); restarting"; + assert_eq!( + expected_error_kind(body), + Some(ExpectedErrorKind::ChannelSupervisorRestart) + ); + } + + #[test] + fn classifies_channel_supervisor_restart_chinese_windows_wsaetimedout() { + // TAURI-RUST-BB (~815 events / 14d): same supervisor wrapper, + // OS-localized inner WSAETIMEDOUT body on Chinese Windows. The + // English-only `is_network_unreachable_message` anchors miss + // this inner message, so without the language-agnostic + // supervisor matcher it would escape classification entirely + // and emit a full Sentry error. The wrapper-anchored predicate + // catches it regardless of OS locale. + let body = "Channel discord error: IO error: \ + 由于连接方在一段时间后没有正确答复或连接的主机没有反应,连接尝试失败。 \ + (os error 10060); restarting"; + assert_eq!( + expected_error_kind(body), + Some(ExpectedErrorKind::ChannelSupervisorRestart) + ); + } + + #[test] + fn channel_supervisor_restart_matches_multiple_channel_names() { + // The wrapper format is `"Channel error: ; restarting"`. + // The name slot varies by provider (discord, slack, telegram, + // whatsapp, gmessages, …). The matcher must classify all of them — + // language-agnostic, name-agnostic. + for raw in [ + "Channel slack error: gateway disconnect; restarting", + "Channel telegram error: tls handshake eof; restarting", + "Channel whatsapp error: connection reset by peer (os error 54); restarting", + "Channel gmessages error: WebSocket connect: HTTP error: 502 Bad Gateway; restarting", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ChannelSupervisorRestart), + "should classify as channel-supervisor-restart: {raw}" + ); + } + } + + #[test] + fn channel_supervisor_restart_precedence_over_network_unreachable() { + // Pin the precedence: a supervisor-wrap body that ALSO contains + // the canonical `"error sending request for url"` anchor (which + // would by itself classify as `NetworkUnreachable`) MUST route + // to `ChannelSupervisorRestart`. The supervisor's own backoff + // handles the condition; `NetworkUnreachable` would demote to + // `warn!` (still a Sentry event), whereas + // `ChannelSupervisorRestart` demotes to `info!` (no event). + let body = "Channel discord error: error sending request for url \ + (https://discord.com/api/v10/gateway/bot); restarting"; + let kind = expected_error_kind(body); + assert_eq!(kind, Some(ExpectedErrorKind::ChannelSupervisorRestart)); + assert_ne!(kind, Some(ExpectedErrorKind::NetworkUnreachable)); + } + + #[test] + fn channel_supervisor_restart_does_not_classify_unrelated_restart_notes() { + // Defense against the matcher being too eager: bodies that + // contain `"; restarting"` but NOT the `"Channel error:"` + // preamble must NOT classify — those are generic restart logs + // from other subsystems where Sentry signal may still be + // actionable. The matcher requires all three anchors together + // (`"channel "` prefix + `" error:"` separator + `"; restarting"` + // trailer). + for raw in [ + // No `Channel ` preamble. + "systemd: docker.service; restarting", + // No `Channel ` preamble even though `; restarting` + // appears. + "Connection refused; restarting", + // The string `channel` appears but not as the leading + // `"Channel error:"` wrapper — must not classify. + "channels::runtime::dispatch failed: error: provider exhausted; restarting", + // The wrapper prefix is present but the trailer is not — + // a half-formed log line must not classify. + "Channel discord error: gateway disconnect", + ] { + assert_ne!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ChannelSupervisorRestart), + "must NOT classify as channel-supervisor-restart: {raw}" + ); + } + } + + #[test] + fn report_error_or_expected_routes_channel_supervisor_restart_through_expected_path() { + // Smoke test: the verbatim TAURI-RUST-15 Sentry body flows through + // `report_error_or_expected` without panicking. The classifier + // routes it to `report_expected_message` (info breadcrumb) instead + // of `report_error_message` (`sentry::capture_message` at error + // level). We can't observe the Sentry hub from this test, but + // exercising the call path catches any future regression that + // re-introduces a panic or mis-types the arm. + report_error_or_expected( + "Channel discord error: error sending request for url \ + (https://discord.com/api/v10/gateway/bot); restarting", + "channels", + "supervised_listener", + &[("channel", "discord")], + ); + } +} diff --git a/src/core/observability_tests.rs b/src/core/observability_tests.rs deleted file mode 100644 index 1a1b772b05..0000000000 --- a/src/core/observability_tests.rs +++ /dev/null @@ -1,3549 +0,0 @@ -use super::*; - -/// Helper must accept `&anyhow::Error`, `&dyn std::error::Error`, and -/// plain `&str` — the three shapes that show up at error sites today. -#[test] -fn report_error_accepts_common_error_shapes() { - let anyhow_err = anyhow::anyhow!("boom"); - report_error(&anyhow_err, "test", "anyhow_shape", &[]); - - let io_err = std::io::Error::other("io failed"); - report_error(&io_err, "test", "io_shape", &[("kind", "io")]); - - report_error("plain message", "test", "str_shape", &[]); -} - -#[test] -fn anyhow_chain_is_rendered_in_full() { - // Regression guard: `err.to_string()` on an anyhow chain only emits - // the outermost context. Using `{:#}` joins every cause, which is - // what Sentry needs to actually diagnose wrapped failures. - let inner = std::io::Error::other("inner cause"); - let wrapped = anyhow::Error::from(inner).context("outer ctx"); - assert_eq!(format!("{wrapped:#}"), "outer ctx: inner cause"); -} - -#[test] -fn classifies_expected_config_errors() { - assert_eq!( - expected_error_kind("rpc.invoke_method failed: local ai is disabled"), - Some(ExpectedErrorKind::LocalAiDisabled) - ); - assert_eq!( - expected_error_kind( - "agent.provider_chat failed: ollama API key not set. Configure via the web UI" - ), - Some(ExpectedErrorKind::ApiKeyMissing) - ); - assert_eq!( - expected_error_kind("ollama embed failed with status 500"), - None - ); -} - -/// Task B (issue #2898): prove the canonical 429 error message produced by -/// the embedding clients is already classified as `TransientUpstreamHttp` -/// so Sentry events are suppressed even without backoff. -/// -/// The `is_transient_upstream_http_message` matcher checks for -/// `"api error (429 "` (case-insensitive), which is present in both the -/// OpenAI and Cohere canonical error shapes. -#[test] -fn embedding_429_classifies_as_transient_upstream_http() { - // OpenAI/Voyage canonical shape (openai.rs emit site). - let msg = "Embedding API error (429 Too Many Requests): Rate limit exceeded."; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "OpenAI 429 must classify as TransientUpstreamHttp: {msg}" - ); - - // Cohere canonical shape (cohere.rs emit site). - let cohere_msg = "Cohere embed API error (429 Too Many Requests): rate limit exceeded."; - assert_eq!( - expected_error_kind(cohere_msg), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "Cohere 429 must classify as TransientUpstreamHttp: {cohere_msg}" - ); - - // After-cap bail shape from the retry loop (openai.rs). - let cap_msg = - "Embedding API error (429 Too Many Requests): rate limit exceeded after 3 retries"; - assert_eq!( - expected_error_kind(cap_msg), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "retry-cap bail message must classify as TransientUpstreamHttp: {cap_msg}" - ); - - // After-cap bail shape from the retry loop (cohere.rs). - let cohere_cap_msg = - "Cohere embed API error (429 Too Many Requests): rate limit exceeded after 3 retries"; - assert_eq!( - expected_error_kind(cohere_cap_msg), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "Cohere retry-cap bail message must classify as TransientUpstreamHttp: {cohere_cap_msg}" - ); -} - -#[test] -fn classifies_backend_env_api_key_not_configured() { - // TAURI-RUST-2H5 (~5 K events): backend embedding endpoint returns a - // 400 with `{"success":false,"error":"VOYAGE_API_KEY is not configured"}` - // whenever the backend env var is absent. This is a known server-side - // config state, not an app error — silence it the same way we silence - // other `ApiKeyMissing` variants. - for raw in [ - r#"Embedding API error (400 Bad Request): {"success":false,"error":"VOYAGE_API_KEY is not configured"}"#, - r#"Embedding API error 400 Bad Request: {"success":false,"error":"VOYAGE_API_KEY is not configured"}"#, - // Future-proof: same shape for any other backend-managed embedder. - r#"Embedding API error (400 Bad Request): {"success":false,"error":"COHERE_API_KEY is not configured"}"#, - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ApiKeyMissing), - "should classify backend env api-key missing: {raw}" - ); - } -} - -#[test] -fn does_not_classify_unrelated_is_not_configured_messages() { - // The `_api_key` anchor must keep prose that merely says "is not - // configured" from being silenced — only env-var-style key names - // should match. - assert_eq!( - expected_error_kind("workspace path is not configured for this user"), - None - ); - assert_eq!( - expected_error_kind("embedding model is not configured"), - None - ); - assert_eq!( - expected_error_kind("provider 'voyage' is not configured in settings"), - None - ); -} - -#[test] -fn classifies_ollama_user_config_rejections() { - // TAURI-RUST-XS (~376 events): user pointed embedder at a chat / - // vision model id, sometimes with a temperature suffix like `@0.7` - // that Ollama parses as malformed. - for raw in [ - // Canonical XS wire shape from - // `OllamaEmbedding::embed` non-2xx path on a 400 Bad Request. - r#"ollama embed failed with status 400 Bad Request: {"error":"invalid model name"}"#, - // Same shape with a temperature-suffix model id the user pasted - // into Settings → Embeddings → Ollama. - r#"ollama embed failed with status 400 Bad Request: {"error":"invalid model name: qwen3-vl:4b@0.7"}"#, - // OPENHUMAN-TAURI-MA — model not pulled (404 Not Found). - r#"ollama embed failed with status 404 Not Found: {"error":"model \"bge-m3\" not found, try pulling it first"}"#, - // OPENHUMAN-TAURI-KM — same shape, different model id + `:latest` tag. - r#"ollama embed failed with status 404 Not Found: {"error":"model \"nomic-embed-text:latest\" not found, try pulling it first"}"#, - // OPENHUMAN-TAURI-GX — daemon-unreachable opt-in state. - "ollama embeddings opted-in but daemon unreachable at http://localhost:11434; falling back to cloud embeddings for this session", - // TAURI-RUST-3X — 501-status model-does-not-support-embeddings. - r#"ollama embed failed with status 501 Not Implemented: {"error":"this model does not support embeddings"}"#, - // TAURI-RUST-3E — 401 unauthorized embed (auth required at ollama endpoint). - r#"ollama embed failed with status 401 Unauthorized: {"error": "unauthorized"}"#, - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ProviderUserState), - "should classify Ollama user-config rejection: {raw}" - ); - } -} - -#[test] -fn classifies_embedding_backend_auth_failure() { - // TAURI-RUST-T (~4k events) — companion of TAURI-RUST-4K5: the - // OpenHuman backend rejected the embeddings worker's bearer - // token. Both the bare-status and parenthesised wire shapes - // must classify as SessionExpired so the FE re-login prompt - // fires (matches the contract introduced by #2786 and - // exercised by classifies_embedding_api_invalid_token_401_as_session_expired). - // Kept as a regression guard against the broader - // `is_embedding_backend_auth_failure` matcher re-claiming this - // shape — see the guard in that function. - for raw in [ - r#"Embedding API error 401 Unauthorized: {"success":false,"error":"Invalid token"}"#, - r#"Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#, - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::SessionExpired), - "should classify embedding backend auth failure as SessionExpired: {raw}" - ); - } -} - -#[test] -fn does_not_classify_unrelated_invalid_token_messages() { - // Provider 401s with "invalid token" in the body but no - // `Embedding API error` prefix must keep reaching Sentry — they're - // not the same wire shape and may indicate real provider bugs. - assert_eq!( - expected_error_kind(r#"openai chat failed 401: {"error":"invalid token"}"#), - None - ); - // Embedding error without 401 must not be silenced. - assert_eq!( - expected_error_kind( - r#"Embedding API error 500 Internal Server Error: {"error":"invalid token signature service down"}"# - ), - None - ); -} - -#[test] -fn does_not_classify_unrelated_ollama_errors_as_user_config() { - // Unrelated 500 — server-side ollama bug must still reach Sentry. - assert_eq!( - expected_error_kind("ollama embed failed with status 500"), - None - ); - // Parse-failure on the response — real bug in either the server - // or our deserializer, must still reach Sentry. - assert_eq!( - expected_error_kind("ollama embed response parse failed: invalid type: expected sequence"), - None - ); - // Dimension mismatch — real bug (model dims don't match what we - // recorded), must still reach Sentry. - assert_eq!( - expected_error_kind("ollama embed dimension mismatch at index 0: expected 768, got 1024"), - None - ); - // Unrelated `invalid model name` outside Ollama embed call — - // anchor on the `ollama embed` prefix keeps this from being silenced. - assert_eq!( - expected_error_kind("provider config validation failed: invalid model name"), - None - ); - // Unrelated `model "…" not found` text without the `ollama embed` - // prefix — anchor keeps this from being silenced even when the - // exact MA/KM wire-shape substring appears in another context. - assert_eq!( - expected_error_kind(r#"provider listing failed: model \"foo\" not found in registry"#), - None - ); -} - -#[test] -fn classifies_local_ai_capability_unavailable_errors() { - // OPENHUMAN-TAURI-3B: surfaced by `local_ai_download_asset` when a - // user on a 0–4 GB RAM tier requests a vision asset. Both canonical - // wire shapes — emitted from `assets.rs` and `vision_embed.rs` — - // must classify as expected so they stop reaching Sentry. - for raw in [ - "Vision is disabled for this RAM tier. Switch to the 4-8 GB tier or above to enable it.", - "vision summaries are unavailable for this RAM tier. Use OCR-only summarization or switch to a higher local AI tier.", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::LocalAiCapabilityUnavailable), - "should classify as local-ai capability unavailable: {raw}" - ); - } - - // Wrapped by the RPC dispatch layer as it reaches `report_error_or_expected` - // — the classifier is substring-based, so caller context must not defeat it. - assert_eq!( - expected_error_kind( - "rpc.invoke_method failed: Vision is disabled for this RAM tier. Switch to the 4-8 GB tier or above to enable it." - ), - Some(ExpectedErrorKind::LocalAiCapabilityUnavailable) - ); -} - -#[test] -fn classifies_prompt_injection_blocked_errors() { - // OPENHUMAN-TAURI-140: ~1 480 events from `openhuman.agent_chat` where - // users' messages scored ≥ 0.45 on the injection heuristic. Both - // enforcement wire shapes must be classified as expected so they stop - // reaching Sentry. - for raw in [ - "Prompt flagged for security review and was not processed. Please rephrase clearly.", - "Prompt blocked by security policy. Please rephrase without instruction overrides or exfiltration requests.", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::PromptInjectionBlocked), - "should classify as prompt-injection blocked: {raw}" - ); - } - - // Wrapped by the RPC dispatch layer — substring match must survive the prefix. - assert_eq!( - expected_error_kind( - "rpc.invoke_method failed: Prompt flagged for security review and was not processed. Please rephrase clearly." - ), - Some(ExpectedErrorKind::PromptInjectionBlocked) - ); -} - -#[test] -fn does_not_classify_unrelated_messages_as_prompt_injection_blocked() { - // Must not silently swallow real security errors or generic "prompt" mentions. - assert_eq!( - expected_error_kind("prompt injection detected in tool arguments"), - None - ); - assert_eq!( - expected_error_kind("security review required for deploy"), - None - ); -} - -// ── ContextWindowExceeded (TAURI-RUST-501) ───────────────────────────── - -#[test] -fn classifies_context_window_exceeded_rereport() { - // TAURI-RUST-501: the custom-provider 500 body that escapes the - // provider api_error cascade's own status-gated checks. When the - // error is re-raised by `agent.run_single` / `web_channel. - // run_chat_task`, `report_error_or_expected` runs the classifier on - // the full message — this arm must catch the new phrasing. - assert_eq!( - expected_error_kind( - "custom API error (500 Internal Server Error): \ - {\"error\":{\"code\":500,\"message\":\"Context size has been exceeded.\",\"type\":\"server_error\"}}" - ), - Some(ExpectedErrorKind::ContextWindowExceeded) - ); - - // The established phrasings the provider/reliable layer already - // recognized must classify here too (single-source matcher). - for raw in [ - "OpenAI API error (400): This model's maximum context length is 8192 tokens", - "request exceeds the context window of this model", - "context length exceeded", - "prompt is too long", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ContextWindowExceeded), - "should classify as context-window-exceeded: {raw}" - ); - } -} - -#[test] -fn does_not_classify_unrelated_messages_as_context_window_exceeded() { - // Anchors are context-overflow specific. A generic "window" or - // "context" mention, or an unrelated rate-limit "exceeded", must - // not classify. - for raw in [ - "rate limit exceeded, retry after 30s", - "failed to open context menu window", - "tool call exceeded the allowed budget", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "must NOT classify as context-window-exceeded: {raw}" - ); - } -} - -// ── FilesystemUserPathInvalid (TAURI-RUST-4QH) ───────────────────────── - -#[test] -fn classifies_vault_create_root_path_not_a_directory_as_filesystem_user_path_invalid() { - // TAURI-RUST-4QH: verbatim wire shape historically emitted by the - // now-removed knowledge-vault `vault_create` path when the - // user-picked folder didn't resolve to an existing directory. - // Retained as a classifier fixture in case the shape recurs from - // another caller. Bubbles up as the RPC dispatcher's - // `display_message` and reaches `report_error_or_expected` — - // must classify so no Sentry event fires. - assert_eq!( - expected_error_kind( - "root_path is not a directory: /Users/zadam/Documents/SndBrainOpenHuman" - ), - Some(ExpectedErrorKind::FilesystemUserPathInvalid) - ); - - // The same body wrapped by the JSON-RPC dispatcher's `display_message` - // prefix (`rpc.invoke_method` re-emit shape from `src/core/jsonrpc.rs`). - // Must still classify so the dispatch-site re-report doesn't escape - // the matcher even if a future caller layers more context. - assert_eq!( - expected_error_kind( - "rpc.invoke_method failed: root_path is not a directory: /Users/alice/openhuman-data" - ), - Some(ExpectedErrorKind::FilesystemUserPathInvalid) - ); -} - -#[test] -fn classifies_http_host_hosted_path_not_a_directory_as_filesystem_user_path_invalid() { - // Preempt the symmetric shape from - // `openhuman::http_host::path_utils:23` — - // `"hosted path is not a directory: "`. Not yet observed - // in Sentry but shares the same RPC validation polarity as - // vault_create's `root_path` check. Anchoring on - // `"path is not a directory:"` (with trailing colon) covers - // both without two separate matchers. - assert_eq!( - expected_error_kind("hosted path is not a directory: /var/www/static-site"), - Some(ExpectedErrorKind::FilesystemUserPathInvalid) - ); -} - -#[test] -fn does_not_classify_unrelated_path_messages_as_filesystem_user_path_invalid() { - // Polarity contract — the anchor requires a trailing colon - // after `"is not a directory"`, which discriminates user input - // (path follows the colon) from other shapes: - // - // 1. The `skills::ops_install:475` SAFETY GUARD — - // `" is not a directory — refusing to remove"` — must - // stay actionable. It catches an `rm -rf` invariant violation - // (the target should have been a directory but wasn't), - // which is a code bug, not user input. - // 2. A narrative log line that happens to mention the phrase - // without the user-path colon suffix is not a validation - // failure and must not be silenced. - // 3. The dot-prefix variant from POSIX `EISDIR`/`ENOTDIR` - // renderings (`"Is a directory (os error 21)"`) is the - // inverse condition — different code path entirely. - for raw in [ - // Safety guard — must NOT classify. - "/tmp/openhuman-cache is not a directory — refusing to remove", - // Narrative log line — must NOT classify. - "checked that path is not a directory before mkdir", - // Inverse condition (os error 21: EISDIR) — must NOT classify. - "open /etc/passwd failed: Is a directory (os error 21)", - // Bare path with no `directory` mention — must NOT classify. - "root_path must be absolute: ./relative/path", - // Generic body with the trailing colon but no known vault/http_host - // prefix — must NOT classify (future provider/storage errors that - // happen to embed "path is not a directory: ..." should reach Sentry). - "input config path is not a directory: /etc/foo", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "polarity contract: must NOT classify as FilesystemUserPathInvalid: {raw}" - ); - } -} - -// ── EmptyProviderResponse (TAURI-RUST-4Z1) ───────────────────────────── - -#[test] -fn classifies_empty_provider_response_web_channel_rereport() { - // TAURI-RUST-4Z1: the web-channel re-report of the agent harness's - // empty-provider-response bail. `run_chat_task` wraps the flattened - // string and routes it through `report_error_or_expected` — the - // agent-layer typed suppression (PR #2790) can't reach it, so this - // string classifier must. - assert_eq!( - expected_error_kind( - "run_chat_task failed client_id=l1uxaLd20_1mAdhp \ - thread_id=thread-8f03e7f7-3477-42cd-9283-f0bacd4bfbca \ - request_id=a73716a3-a85a-4045-984b-315772c5b3b8 \ - error=The model returned an empty response. Please try again." - ), - Some(ExpectedErrorKind::EmptyProviderResponse) - ); - - // Bare user-facing string (the verbatim `turn.rs` emission), in case - // a different call site re-reports it without the run_chat_task wrap. - assert_eq!( - expected_error_kind("The model returned an empty response. Please try again."), - Some(ExpectedErrorKind::EmptyProviderResponse) - ); -} - -#[test] -fn does_not_classify_unrelated_empty_response_phrases() { - // Polarity contract: the anchor is `"model returned an empty - // response"`, NOT the looser `"empty response"`. The sibling paths - // below use different subjects or phrasings and are not user-facing - // failures — they must stay out of this bucket so a real regression - // in those paths still reaches Sentry. - for raw in [ - // payload_summarizer.rs:261 — internal fall-through, not a failure. - "[payload_summarizer] summarizer returned empty response, falling through", - // subagent_runner/extract_tool.rs:379 — graceful empty extraction. - "[extract_from_result] provider returned an empty response; returning empty extraction", - // Generic mention without the model-subject anchor. - "warning: empty response body from health probe", - // channels/bus.rs:185 — channel-inbound graceful fallback (routes - // through report_error_or_expected; subject is "agent", not "model"). - "[channel-inbound] agent returned empty response — finalizing draft with fallback", - // memory/query/walk.rs:292 — debug-level memory walk, not a failure. - "[memory_tree_walk] turn=3 LLM gave up (empty response)", - // learning/reflection.rs:576 — reflection skip, not a failure. - "[learning] reflection skipped (empty response — gate off or local AI unavailable)", - // agent/harness/session/turn.rs:811 — "provider returned an empty - // final response" uses subject "provider", not "model"; must not match. - "[agent_loop] provider returned an empty final response (i=2, no text, no tool calls)", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "must NOT classify as EmptyProviderResponse: {raw}" - ); - } -} - -#[test] -fn classifies_memory_store_pii_rejection_errors() { - // TAURI-RUST-54T: ~915 events from one user where the PII guard - // rejected memory-store writes on namespace/key values that look like - // personal identifiers. All three canonical wire shapes — from - // `documents.rs` (upsert_document / upsert_document_metadata_only) - // and `kv.rs` (kv_set_global / kv_set_namespace) — must classify as - // expected so they stop reaching Sentry. - for raw in [ - "document namespace/key cannot contain personal identifiers", - "kv key cannot contain personal identifiers", - "kv namespace/key cannot contain personal identifiers", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::MemoryStorePiiRejection), - "should classify as memory-store PII rejection: {raw}" - ); - } - - // Wrapped by the RPC dispatch layer — substring match must survive the - // `rpc.invoke_method failed: ` prefix that `jsonrpc.rs` prepends. - assert_eq!( - expected_error_kind( - "rpc.invoke_method failed: document namespace/key cannot contain personal identifiers" - ), - Some(ExpectedErrorKind::MemoryStorePiiRejection) - ); -} - -#[test] -fn classifies_memory_store_breaker_open() { - // TAURI-RUST-52X (~455 events on self-hosted Sentry): the chunk-store - // per-path circuit breaker tripped after consecutive SQLite init - // failures. The Windows wire shape is wrapped by - // `memory_tree::tree::rpc::pipeline_status_rpc`'s `chunk aggregates: …` - // context so the substring matcher must survive that prefix. - for raw in [ - // Canonical wire shape from `get_or_init_connection`. - "[memory_tree] circuit breaker open for /home/u/.openhuman/workspace/memory_tree/chunks.db: too many consecutive init failures", - // Canonical wire shape wrapped by the RPC handler's - // `format!("chunk aggregates: {e:#}")` context. - r"chunk aggregates: [memory_tree] circuit breaker open for C:\Users\u\.openhuman\users\6a09\workspace\memory_tree\chunks.db: too many consecutive init failures", - // Wrapped further by the JSON-RPC dispatch layer before reaching - // `report_error_or_expected`. - r"rpc.invoke_method failed: chunk aggregates: [memory_tree] circuit breaker open for /home/u/.openhuman/workspace/memory_tree/chunks.db: too many consecutive init failures", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::MemoryStoreBreakerOpen), - "should classify memory-store breaker-open: {raw}" - ); - } -} - -#[test] -fn classifies_disk_full_errors() { - for raw in [ - // Canonical POSIX errno 28 rendering from `std::io::Error`. - "Failed to create auth profile lock: open lock file: No space left on device (os error 28)", - // Same shape from a different call site — `tokio::fs::write` - // for a state snapshot. - "state snapshot write failed: No space left on device (os error 28)", - // Windows ERROR_DISK_FULL (112) rendering. - "log rotation failed: There is not enough space on the disk. (os error 112)", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::DiskFull), - "should classify disk-full: {raw}" - ); - } -} - -#[test] -fn does_not_classify_unrelated_space_messages() { - // Generic "space" prose without the errno-text anchor must not be - // silenced — the matcher pins to the platform-stable errno - // renderings only. - assert_eq!( - expected_error_kind("workspace path is invalid: contains a space character"), - None - ); - assert_eq!( - expected_error_kind("not enough memory to allocate buffer"), - None - ); -} - -#[test] -fn classifies_config_load_timed_out() { - // Canonical wire string emitted by `load_config_with_timeout` and - // `reload_config_snapshot_with_timeout` in - // `src/openhuman/config/ops.rs`. Drops TAURI-RUST-5X. - assert_eq!( - expected_error_kind("Config loading timed out"), - Some(ExpectedErrorKind::ConfigLoadTimedOut), - ); - // Same shape after the RPC dispatch wraps it for display — the - // matcher is substring-anchored, so a context prefix does not - // break it. - assert_eq!( - expected_error_kind("rpc.invoke_method failed: Config loading timed out"), - Some(ExpectedErrorKind::ConfigLoadTimedOut), - ); -} - -#[test] -fn does_not_classify_unrelated_timeouts_as_config_load_timed_out() { - // Network / HTTP timeouts go to `NetworkUnreachable` / - // `TransientUpstreamHttp`, not the config-load bucket. The - // anchor is the full literal phrase, so a bare "timed out" or - // "operation timed out" body cannot trip this matcher. - assert_ne!( - expected_error_kind( - "Channel discord error: IO error: Operation timed out (os error 60); restarting" - ), - Some(ExpectedErrorKind::ConfigLoadTimedOut), - ); - assert_ne!( - expected_error_kind("OpenHuman API error (504 Gateway Timeout): error code: 504"), - Some(ExpectedErrorKind::ConfigLoadTimedOut), - ); - // Bare "timed out" without the config-load phrase must not match. - assert_eq!(expected_error_kind("cron job timed out after 30s"), None,); -} - -fn classifies_whatsapp_data_sqlite_busy_errors() { - for raw in [ - r#"[whatsapp_data] ingest failed: upsert wa_message chat=120363402402350155@g.us msg=false_120363402402350155@g.us_3A357F28AE74548B1507_207897942335683@lid: database is locked: Error code 5: The database file is locked"#, - r#"rpc.invoke_method failed: [whatsapp_data] ingest failed: upsert wa_message [email] msg=false_120363402402350155@g.us_3A357F28AE74548B1507_207897942335683@lid: database is locked: Error code 5: The database file is locked"#, - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::WhatsAppDataSqliteBusy), - "should classify whatsapp_data sqlite busy/locked: {raw}" - ); - } -} - -#[test] -fn does_not_classify_unrelated_sqlite_lock_messages_as_whatsapp_busy() { - for raw in [ - "failed to run subconscious schema DDL: database is locked", - "memory queue write failed: database table is locked", - "[whatsapp_data] list_messages failed: database is locked", - ] { - assert_ne!( - expected_error_kind(raw), - Some(ExpectedErrorKind::WhatsAppDataSqliteBusy), - "must not classify as whatsapp_data sqlite busy: {raw}" - ); - } -} - -#[test] -fn does_not_classify_unrelated_messages_as_memory_pii_rejection() { - // A generic "personal identifiers" mention without the "cannot contain" - // anchor must not be silenced. - assert_eq!( - expected_error_kind("processing personal identifiers"), - None, - "must not match a bare 'personal identifiers' mention" - ); - // The secret-rejection variant uses different wording and must not be - // swallowed by the PII classifier. - assert_eq!( - expected_error_kind("document namespace/key cannot contain secrets"), - None, - "secret rejection must remain unclassified" - ); -} - -#[test] -fn does_not_classify_unrelated_breaker_messages() { - // Generic "circuit breaker open" without the `[memory_tree]` anchor - // must not be silenced — other domains may use the same phrase for - // real bugs that need to reach Sentry. - assert_eq!( - expected_error_kind("provider reliability: circuit breaker open for openai"), - None - ); - // The `[memory_tree]` tag alone is not enough — must co-occur with - // the `circuit breaker open` substring. - assert_eq!( - expected_error_kind("[memory_tree] failed to run schema DDL: disk full"), - None - ); -} - -// ── Upstream rate-limit suppression (OPENHUMAN-TAURI-S / -6Y / -2E / -RQ) ─ - -/// Canonical Anthropic / OpenAI body with a structured `"rate_limit_error"` -/// type — OPENHUMAN-TAURI-2E (~1 482 events) and -RQ (~741 events). -#[test] -fn classifies_rate_limit_error_type_as_transient() { - for raw in [ - // Direct 429 from the embeddings path (OPENHUMAN-TAURI-RQ): - r#"Embedding API error (429 Too Many Requests): {"error":{"message":"Rate limit exceeded. Please retry after a brief wait.","type":"rate_limit_error"}}"#, - // Via llm_provider.api_error (OPENHUMAN-TAURI-2E): - r#"[observability] llm_provider.api_error failed: OpenHuman API error (429 Too Many Requests): {"error":{"message":"Rate limit exceeded. Please retry after a brief wait.","type":"rate_limit_error"}}"#, - // Re-reported by agent.run_single: - r#"run_chat_task failed client_id=abc thread_id=t1 request_id=r1 error=OpenHuman API error (429 Too Many Requests): {"error":{"message":"Rate limit exceeded.","type":"rate_limit_error"}}"#, - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "should classify rate_limit_error body as transient: {raw}" - ); - } -} - -/// OpenHuman backend wrapping an upstream 429 as HTTP 500 with a -/// `"upstream rate limit exceeded"` body — OPENHUMAN-TAURI-6Y (~19 849 -/// events). -#[test] -fn classifies_upstream_rate_limit_in_500_body_as_transient() { - for raw in [ - r#"OpenHuman API error (500 Internal Server Error): {"success":false,"error":"Upstream rate limit exceeded for model 'summarization-v1'. Please retry shortly."}"#, - r#"[observability] llm_provider.api_error failed: OpenHuman API error (500 Internal Server Error): {"success":false,"error":"Upstream rate limit exceeded for model 'summarization-v1'. Please retry shortly.","details":{"provider":"gmi","upstreamModel":"deepseek-ai/DeepSeek-V3-0324"}}"#, - // Re-wrapped by rpc.invoke_method: - r#"rpc.invoke_method failed: LLM summarisation failed: OpenHuman API error (500 Internal Server Error): {"success":false,"error":"Upstream rate limit exceeded for model 'summarization-v1'."}"#, - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "should classify upstream-rate-limit-in-500 as transient: {raw}" - ); - } -} - -/// Backend returning HTTP 500 with a numeric `"429 rate limit exceeded"` -/// body — OPENHUMAN-TAURI-S (~6 984 events). -#[test] -fn classifies_429_rate_limit_in_500_body_as_transient() { - for raw in [ - r#"OpenHuman API error (500 Internal Server Error): {"success":false,"error":"429 rate limit exceeded, please try again later"}"#, - r#"[observability] llm_provider.api_error failed: OpenHuman API error (500 Internal Server Error): {"success":false,"error":"429 rate limit exceeded, please try again later"}"#, - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "should classify 429-in-500-body as transient: {raw}" - ); - } -} - -/// The security::policy `"Rate limit exceeded: action budget exhausted"` -/// must NOT be silenced — it's a user-facing hard stop, not a transient -/// upstream quota hit. -#[test] -fn does_not_classify_security_policy_rate_limit_as_transient() { - let msg = "Rate limit exceeded: action budget exhausted (0 actions/hour). \ - Increase the limit in Settings -> Advanced -> Agent autonomy"; - assert_eq!( - expected_error_kind(msg), - None, - "security policy action-budget error must reach Sentry: {msg}" - ); - // Wrapped by rpc.invoke_method — the prefix must not accidentally - // trigger the `api error (` anchor. - assert_eq!( - expected_error_kind(&format!("rpc.invoke_method failed: {msg}")), - None, - "wrapped security policy action-budget error must reach Sentry" - ); -} - -/// Standalone `"rate limit exceeded"` without the `"api error ("` anchor -/// must NOT be silenced — keeps loose phrases from accidentally demoting -/// unrelated errors. -#[test] -fn does_not_classify_bare_rate_limit_exceeded_as_transient() { - assert_eq!( - expected_error_kind("rate limit exceeded"), - None, - "bare 'rate limit exceeded' without API error anchor must reach Sentry" - ); -} - -/// `is_upstream_rate_limit_message` predicate unit tests — verifies the -/// polarity contract independently of `expected_error_kind`. -#[test] -fn upstream_rate_limit_predicate_matches_expected_shapes() { - for lower in [ - r#"{"error":{"message":"rate limit exceeded.","type":"rate_limit_error"}}"#, - "upstream rate limit exceeded for model 'summarization-v1'", - "429 rate limit exceeded, please try again later", - r#"openai api error (429 too many requests): {"error":{"message":"rate limit exceeded.","type":"rate_limit_error"}}"#, - ] { - assert!( - is_upstream_rate_limit_message(lower), - "should match: {lower}" - ); - } -} - -#[test] -fn upstream_rate_limit_predicate_does_not_match_unrelated() { - for lower in [ - // security::policy budget message — must not be swallowed - "rate limit exceeded: action budget exhausted (0 actions/hour)", - // bare phrase without anchor - "rate limit exceeded", - // unrelated 500 body - r#"{"success":false,"error":"internal server error"}"#, - // budget exhausted — different concept - "budget exhausted, add credits to continue", - ] { - assert!( - !is_upstream_rate_limit_message(lower), - "should not match: {lower}" - ); - } -} - -#[test] -fn does_not_classify_unrelated_messages_as_capability_unavailable() { - // The classifier anchors on the exact "for this RAM tier" substring. - // Messages that talk about RAM in a different context (sizing the - // tier list, doc references) must not be silenced. - assert_eq!(expected_error_kind("ollama embed failed: out of RAM"), None); - assert_eq!( - expected_error_kind("local_ai_set_ram_tier failed: invalid tier value"), - None - ); -} - -#[test] -fn classifies_network_unreachable_errors() { - // OPENHUMAN-TAURI-32: reqwest's transport-level error wrapped by the - // web_channel error site. The classifier must catch it even when - // embedded in caller context, since `report_error_or_expected` runs - // `expected_error_kind` on the full anyhow chain. - assert_eq!( - expected_error_kind( - "run_chat_task failed client_id=abc thread_id=t1 request_id=r1 \ - error=error sending request for url (https://api.tinyhumans.ai/openai/v1/chat/completions)" - ), - Some(ExpectedErrorKind::NetworkUnreachable) - ); - for raw in [ - "error sending request for url (https://api.example.com/x)", - "provider failed: dns error: failed to lookup address information", - "tcp connect: connection refused (os error 61)", - "stream closed: connection reset by peer", - "network is unreachable (os error 51)", - "no route to host", - "tls handshake eof", - "certificate verify failed: unable to get local issuer certificate", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::NetworkUnreachable), - "should classify as network-unreachable: {raw}" - ); - } -} - -#[test] -fn does_not_classify_unrelated_provider_errors_as_network() { - // Status-bearing provider failures (404, 500, …) are surfaced via - // their HTTP status path and must NOT be silenced by the - // network-unreachable classifier — the body text doesn't hit any of - // the transport-level markers. - assert_eq!( - expected_error_kind("OpenAI API error (404): model gpt-x not found"), - None - ); - assert_eq!( - expected_error_kind("OpenAI API error (500): internal server error"), - None - ); -} - -#[test] -fn classifies_wave4_socket_transport_wire_shapes() { - // OPENHUMAN-TAURI-44 (~50 events): libc `getaddrinfo()` rendering - // without the `dns error` token, wrapped by the socket emit site. - // The Wave 4 matcher arms catch the literal resolver phrases that - // the original `dns error` substring would miss when reqwest's - // wrapper isn't in the chain (e.g. tungstenite IO errors). - assert_eq!( - expected_error_kind( - "[socket] Connection failed (sustained outage after 5 attempts): \ - WebSocket connect: IO error: failed to lookup address information: \ - nodename nor servname provided, or not known" - ), - Some(ExpectedErrorKind::NetworkUnreachable) - ); - - // OPENHUMAN-TAURI-4P (~66 events): tungstenite renders a captive - // portal / corporate proxy that intercepts the WS handshake as - // `WsError::Http(200)` → `"HTTP error: 200 OK"`. Classify as - // network-unreachable since no amount of app-side retry can pierce - // an intercepting proxy. - assert_eq!( - expected_error_kind( - "[socket] Connection failed (sustained outage after 5 attempts): \ - WebSocket connect: HTTP error: 200 OK" - ), - Some(ExpectedErrorKind::NetworkUnreachable) - ); -} - -#[test] -fn http_200_classifier_does_not_silence_unrelated_log_lines() { - // The captive-portal arm anchors on `"http error: 200 ok"` (the - // exact tungstenite `WsError::Http(200)` Display rendering). - // Adjacent non-WebSocket log lines that mention `"HTTP/1.1 200 OK"` - // or `"status: 200 OK"` MUST NOT classify — those are normal-flow - // success traces, not failure events. Pin this precedence so a - // future refactor doesn't broaden the substring. - assert_eq!(expected_error_kind("HTTP/1.1 200 OK"), None); - assert_eq!( - expected_error_kind("upstream returned status: 200 OK after retry"), - None - ); -} - -#[test] -fn classifies_tls_handshake_eof_as_network_unreachable() { - // TAURI-RUST-4ZD (first seen on `openhuman@0.56.0+e8968077aeb5`, - // Windows): `native-tls` renders a peer / firewall / antivirus / - // corporate-proxy TCP close mid-TLS-handshake as - // `"TLS error: native-tls error: unexpected EOF during handshake"`, - // which `socket::ws_loop::run_connection` wraps as - // `"WebSocket connect: "` and the supervisor's - // sustained-outage escalation wraps again. The existing - // `"tls handshake"` arm misses it because the words are not - // contiguous in this render (`"tls error"` … `"during handshake"`). - // Same user-environment shape as the other handshake-stage entries: - // the socket supervisor already retries with exponential backoff and - // Sentry has no actionable signal beyond that. - assert_eq!( - expected_error_kind( - "[socket] Connection failed (sustained outage after 5 attempts): \ - WebSocket connect: TLS error: native-tls error: unexpected EOF during handshake" - ), - Some(ExpectedErrorKind::NetworkUnreachable) - ); - - // Bare native-tls render (no socket-supervisor wrap) — fires when the - // same handshake EOF escapes through a non-supervisor call site. The - // classifier runs on the full anyhow chain, so the shorter form must - // also match. - assert_eq!( - expected_error_kind("TLS error: native-tls error: unexpected EOF during handshake"), - Some(ExpectedErrorKind::NetworkUnreachable) - ); -} - -#[test] -fn classifies_ws_protocol_wrong_http_version_as_network_unreachable() { - // CORE-RUST-DP (~2 events / 24h on `openhuman@0.56.0+e8968077aeb5`, - // self-hosted `core-rust`): tungstenite renders - // `ProtocolError::WrongHttpVersion` as - // `"WebSocket protocol error: HTTP version must be 1.1 or higher"`, - // wrapped by `socket::ws_loop::run_connection` as - // `"WebSocket connect: "` and then by the supervisor's - // sustained-outage escalation as - // `"[socket] Connection failed (sustained outage after N attempts): - // WebSocket connect: WebSocket protocol error: HTTP version must be - // 1.1 or higher"`. - // - // The handshake requires HTTP/1.1; a server or intermediary proxy - // that responds with HTTP/2+ to the upgrade is misconfigured - // upstream — same shape as the existing `"tls handshake"` / - // `"certificate verify failed"` user-environment entries. The - // supervisor already retries with exponential backoff; Sentry has - // no actionable signal to add. - assert_eq!( - expected_error_kind( - "[socket] Connection failed (sustained outage after 5 attempts): \ - WebSocket connect: WebSocket protocol error: HTTP version must be 1.1 or higher" - ), - Some(ExpectedErrorKind::NetworkUnreachable) - ); - - // Bare tungstenite render (no socket-supervisor wrap) — fires when - // the same protocol error escapes through a non-supervisor call - // site. The classifier runs on the full anyhow chain, so the - // shorter form must also match. - assert_eq!( - expected_error_kind("WebSocket protocol error: HTTP version must be 1.1 or higher"), - Some(ExpectedErrorKind::NetworkUnreachable) - ); -} - -#[test] -fn tls_handshake_eof_anchor_does_not_silence_unrelated_log_lines() { - // The anchor is the literal `"unexpected eof during handshake"` - // phrase. A bare data-phase `"unexpected EOF"` (server closed - // mid-stream, parser truncation, …) MUST NOT classify — those are - // outside the handshake stage and may carry actionable signal. Pin - // the rejection contract so a future refactor doesn't loosen the - // substring into a generic `"unexpected eof"` matcher. - for raw in [ - "stream closed: unexpected EOF", - "reqwest: unexpected EOF while reading body", - "json parser: unexpected EOF at byte 1024", - "decoder hit unexpected eof mid-frame", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "non-handshake unexpected-EOF log line must NOT classify: {raw}" - ); - } -} - -#[test] -fn wrong_http_version_anchor_does_not_silence_unrelated_log_lines() { - // The anchor is the literal tungstenite Display string. Adjacent - // log lines that mention HTTP version in any other context - // (`"upgrading from HTTP/1.0 to HTTP/2"`, `"HTTP/1.1 only"`, - // `"server requires HTTP version 2.0"`) MUST NOT classify — those - // are unrelated transport / negotiation traces and may carry - // actionable signal. Pin the rejection contract so a future - // refactor doesn't loosen the substring into a generic - // `"http version"` matcher. - for raw in [ - "[transport] upgrading from HTTP/1.0 to HTTP/2", - "server advertises HTTP version 2.0 (h2 alpn)", - "client supports HTTP/1.1 only", - "version mismatch: requires HTTP/1.2 or higher", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "unrelated HTTP-version log line must NOT classify: {raw}" - ); - } -} - -#[test] -fn classifies_transient_upstream_http_errors() { - // OPENHUMAN-TAURI-5Z: the canonical shape emitted by - // `providers::ops::api_error` and re-raised through `agent.run_single`. - assert_eq!( - expected_error_kind("OpenHuman API error (504 Gateway Timeout): error code: 504"), - Some(ExpectedErrorKind::TransientUpstreamHttp) - ); - - // Every transient code must classify, whether the status renders as - // bare digits or " ". - for raw in [ - "OpenHuman API error (408): request timeout", - "OpenAI API error (429 Too Many Requests): rate limit", - "Anthropic API error (502 Bad Gateway): upstream unhealthy", - "OpenHuman API error (503): service unavailable", - "Provider API error (504): upstream timed out", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "should classify as transient upstream HTTP: {raw}" - ); - } - - // Wrapped in an anyhow chain (as it reaches the agent layer) must - // still classify — `expected_error_kind` is substring-based. - assert_eq!( - expected_error_kind( - "agent turn failed: OpenHuman API error (504 Gateway Timeout): \ - error code: 504" - ), - Some(ExpectedErrorKind::TransientUpstreamHttp) - ); - - // TAURI-RUST-H (~1360 events, 504) / TAURI-RUST-2T (~310 events, 502): - // legacy no-paren wire shape from older `embeddings::openai` / - // `embeddings::cohere` emit-site formats that predate the - // parenthesised `({status})` rendering. Anchored on the trailing - // space after the status code so unrelated digit runs don't match. - for raw in [ - "Embedding API error 504 Gateway Timeout: error code: 504", - "Embedding API error 502 Bad Gateway: error code: 502", - "Cohere embed API error 503 Service Unavailable: error code: 503", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "should classify legacy no-paren transient shape: {raw}" - ); - } -} - -#[test] -fn does_not_classify_unrelated_digit_runs_as_transient() { - // The legacy no-paren matcher anchors `api error ` with a - // trailing space so adjacent digit runs (`api error 5042…`) and - // non-transient codes (400/401/403/404) don't get silenced. - assert_eq!( - expected_error_kind("OpenHuman API error 400 Bad Request: malformed body"), - None - ); - assert_eq!( - expected_error_kind("provider returned api error 5042 (custom internal sentinel)"), - None - ); -} - -#[test] -fn integrations_post_composio_timeout_dropped() { - // OPENHUMAN-TAURI-18 / -G regression guard. The integrations - // client at `crate::openhuman::integrations::client::IntegrationClient::post` - // builds the reqwest error chain and routes it through - // `report_error_or_expected(.., "integrations", "post", &[("failure", - // "transport")])`. The chain text contains the - // `"error sending request for url"` anchor so - // `is_network_unreachable_message` matches first and demotes to - // `NetworkUnreachable` (functionally equivalent to - // `TransientUpstreamHttp` for Sentry suppression — both routes - // skip the report path via `report_expected_message`). - // - // Pinning this exact wire shape catches a future refactor that - // drops the URL anchor (e.g. a chain-flatten helper that strips - // it for "PII safety"), which would silently re-open the leak. - let chain = "error sending request for url \ - (https://api.tinyhumans.ai/agent-integrations/composio/execute) → \ - client error (SendRequest) → connection error → \ - Operation timed out (os error 60)"; - assert_eq!( - expected_error_kind(chain), - Some(ExpectedErrorKind::NetworkUnreachable), - "TAURI-18 chain shape must classify as NetworkUnreachable" - ); - - // If the URL anchor is ever dropped, the transport-phrase - // fallback (`operation timed out` from - // `TRANSIENT_TRANSPORT_PHRASES`) catches it via the message - // classifier helper used at upstream re-emit sites — confirm - // both paths so the regression surface is fully pinned. - assert!( - is_transient_message_failure(chain), - "TAURI-18 chain must also satisfy upstream message classifier \ - (defense-in-depth for sites that lose the URL anchor)" - ); -} - -#[test] -fn channel_supervisor_operation_timed_out_classifies_as_expected() { - // OPENHUMAN-TAURI-EM (128 events) + TAURI-RUST-15/-BB: `channels::runtime::supervision` - // wraps a channel listener failure as - // `format!("Channel {} error: {e:#}; restarting", ch.name())` and - // routes the message through `report_error_or_expected`. The - // newer `ChannelSupervisorRestart` classifier (added for the - // broader 11.4k-event Sentry leak) anchors on the supervisor - // wrapper shape itself — `"Channel error: …; restarting"` - // — and takes precedence over `NetworkUnreachable`. That single - // arm now covers every ETIMEDOUT / WSAETIMEDOUT / hyper-prose - // shape the old narrower anchor pinned, plus OS-localized - // variants the English-only `NetworkUnreachable` would miss. - // - // Demotion tier difference: `ChannelSupervisorRestart` emits at - // `info!` (breadcrumb only, no Sentry event) where - // `NetworkUnreachable` emitted at `warn!` (still captured as a - // Sentry warn event). Sustained outages still page via - // `health.bus` / `FAIL_ESCALATE_THRESHOLD`. - for raw in [ - // macOS (os error 60 = ETIMEDOUT on BSD) - "Channel discord error: IO error: Operation timed out (os error 60); restarting", - // Linux (os error 110 = ETIMEDOUT) - "Channel discord error: IO error: Operation timed out (os error 110); restarting", - // Windows (os error 10060 = WSAETIMEDOUT) - "Channel discord error: IO error: Operation timed out (os error 10060); restarting", - // Same shape on other channels — supervisor wrapper is provider-agnostic. - "Channel slack error: IO error: Operation timed out (os error 60); restarting", - "Channel telegram error: IO error: Operation timed out (os error 110); restarting", - // Bare prose form (no errno suffix) from hyper / tungstenite layers - // that render `std::io::Error` without `raw_os_error()`. - "Channel discord error: WebSocket connect: IO error: Operation timed out; restarting", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ChannelSupervisorRestart), - "channel supervisor timeout shape must classify as ChannelSupervisorRestart \ - (precedence over NetworkUnreachable; got {:?} for {raw:?})", - expected_error_kind(raw) - ); - } -} - -#[test] -fn operation_timed_out_negative_cases_still_report() { - // Counter-case: a configuration/validation message that mentions - // "timeout" as a knob name (not transport state) and has no other - // classifier anchor must still reach Sentry. The substring chosen - // for the new matcher is `"operation timed out"`, not `"timeout"`, - // precisely so unrelated mentions of the word do not collide. - assert_eq!( - expected_error_kind("config rejected: timeout must be a positive integer"), - None, - "config validation noise (no 'operation timed out' anchor) must still reach Sentry" - ); - // Bare empty string — no anchors at all. - assert_eq!(expected_error_kind(""), None); -} - -#[test] -fn channels_dispatch_re_emit_of_provider_502_classifies_as_transient() { - // OPENHUMAN-TAURI-4F (~157 events) / -1C (~87 events) / -8F - // (~39 events): the reliable provider layer retried 5xx, the - // agent re-raised the error, and `channels::runtime::dispatch` - // re-emitted it under `domain="channels", operation="dispatch_llm_error"` - // via raw `report_error` (which skips classification). Switching - // that site to `report_error_or_expected` routes the chain - // through this classifier — but only works if the canonical - // `"OpenHuman API error (NNN ...)"` substring still anchors the - // match through the channels-layer wrapping. - // - // The wrapping shape at the dispatch site is the agent error - // chain rendered via `format!("{e:#}")`. For a backend 502 from - // `providers::ops::api_error`, that resolves to: - // "OpenHuman API error (502 Bad Gateway): error code: 502" - // possibly prepended with a runner / iteration prefix. Both - // shapes must classify as transient so the dispatch re-emit - // gets demoted. - for raw in [ - "OpenHuman API error (502 Bad Gateway): error code: 502", - "agent.provider_chat failed: OpenHuman API error (503 Service Unavailable): retry budget exhausted", - "all providers exhausted: OpenHuman API error (504 Gateway Timeout): error code: 504", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "channels.dispatch re-emit of {raw:?} must classify as transient" - ); - } -} - -#[test] -fn classifies_socket_transient_http_errors() { - // OPENHUMAN-TAURI-5P / -EZ: tungstenite's `WsError::Http(response)` - // surfaces during the WebSocket upgrade handshake when the backend - // load balancer returns 502 / 504. The socket reconnect loop wraps - // it as `format!("WebSocket connect: {e}")`, producing - // `"WebSocket connect: HTTP error: "`. Each - // sustained-outage threshold escalation routes the formatted reason - // through `report_error_or_expected`, which must classify as - // transient so the per-client noise stops reaching Sentry. - for raw in [ - "WebSocket connect: HTTP error: 502 Bad Gateway", - "WebSocket connect: HTTP error: 503 Service Unavailable", - "WebSocket connect: HTTP error: 504 Gateway Timeout", - "[socket] Connection failed (sustained outage after 5 attempts): \ - WebSocket connect: HTTP error: 502 Bad Gateway", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "should classify as transient upstream HTTP (socket shape): {raw}" - ); - } - - // Trailing-colon separator (chained error formatting). - // Note: avoid words like "connection refused" or "timeout" in the - // suffix — those would also match `is_network_unreachable_message` / - // `TRANSIENT_TRANSPORT_PHRASES` and the order in `expected_error_kind` - // would route through `NetworkUnreachable` first, defeating the - // assertion. Both classifications silence the event so production - // behavior is identical, but the test is anchored on the canonical - // socket shape so a future regression in `is_transient_upstream_http_message` - // surfaces here, not behind another classifier. - assert_eq!( - expected_error_kind("WebSocket connect: HTTP error: 502: upstream returned bad gateway"), - Some(ExpectedErrorKind::TransientUpstreamHttp) - ); - - // Trailing-newline separator (multi-line error chain). - assert_eq!( - expected_error_kind("WebSocket connect: HTTP error: 504\nupstream gateway"), - Some(ExpectedErrorKind::TransientUpstreamHttp) - ); -} - -#[test] -fn does_not_classify_unrelated_http_error_text_as_transient_socket() { - // Bare numeric "HTTP error: 5023" (port number, runbook ID) without - // a separator must NOT silence — pin the matcher to space/newline/colon. - assert_eq!(expected_error_kind("HTTP error: 5023"), None); - // Non-transient HTTP statuses must not match — `WsError::Http` for - // a 401 / 403 / 404 is genuinely actionable (auth / routing bug). - for raw in [ - "WebSocket connect: HTTP error: 401 Unauthorized", - "WebSocket connect: HTTP error: 403 Forbidden", - "WebSocket connect: HTTP error: 404 Not Found", - "WebSocket connect: HTTP error: 500 Internal Server Error", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "must NOT silence actionable socket HTTP error: {raw}" - ); - } -} - -#[test] -fn does_not_classify_actionable_provider_errors_as_transient_upstream() { - // 4xx (other than 408/429) and non-transient 5xx must continue to - // reach Sentry — those are real bugs (wrong model name, malformed - // request, internal exception) that need to be triaged. - for raw in [ - "OpenAI API error (400): bad request", - "OpenAI API error (401): unauthorized", - "OpenAI API error (403): forbidden", - "OpenAI API error (404): model not found", - "OpenAI API error (500): internal server error", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "must NOT silence actionable provider error: {raw}" - ); - } - - // A free-form message that merely mentions "504" without the - // `api error (` prefix must not be classified — pin the match to - // the canonical shape from `ops::api_error`. - assert_eq!( - expected_error_kind("see runbook for 504 handling at https://example.com/504"), - None - ); -} - -#[test] -fn classifies_backend_user_error_responses() { - // OPENHUMAN-TAURI-BC: SharePoint authorize 400 because the user - // didn't fill in the required Tenant Name field. After the - // ProviderUserState classifier was added (#1472 wave E), this - // canonical shape now lands in the more specific - // ProviderUserState bucket — `"missing required fields"` wins - // over the generic 4xx matcher. Either expected-kind silences - // Sentry; the dedicated bucket gives operators a finer-grained - // `kind="provider_user_state"` info-log facet for triage. - let bc = "Backend returned 400 Bad Request for POST \ - https://api.tinyhumans.ai/agent-integrations/composio/authorize: \ - Composio authorization failed: 400 \ - {\"error\":{\"message\":\"Missing required fields: Tenant Name\",\ - \"slug\":\"ConnectedAccount_MissingRequiredFields\",\"status\":400}}"; - assert_eq!( - expected_error_kind(bc), - Some(ExpectedErrorKind::ProviderUserState), - "OPENHUMAN-TAURI-BC wire shape must classify as ProviderUserState (the \ - more specific bucket once #1472 wave E added it)" - ); - - // Cover the rest of the 4xx surface produced by integrations / - // composio clients — all user-input / auth-state failures that - // Sentry can't action. - for raw in [ - "Backend returned 400 Bad Request for POST https://api.example.com/x: bad input", - "Backend returned 401 Unauthorized for GET https://api.example.com/x: token expired", - "Backend returned 403 Forbidden for GET https://api.example.com/x: permission denied", - "Backend returned 404 Not Found for GET https://api.example.com/x: missing", - "Backend returned 422 Unprocessable Entity for POST https://api.example.com/x: validation failed", - "Backend returned 451 Unavailable for Legal Reasons for GET https://api.example.com/x: blocked", - // Lowercased context wrapping is irrelevant — substring match is case-insensitive. - "[observability] integrations.post failed: Backend returned 400 Bad Request for POST https://api.tinyhumans.ai/x: detail", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::BackendUserError), - "must classify as backend user-error: {raw}" - ); - } -} - -#[test] -fn does_not_classify_transient_or_server_backend_errors_as_user_error() { - // 408 / 429 are transient — they belong to the - // upstream-transient bucket (or are retried at the caller), not - // the user-error bucket. A sustained 429 (rate limit cliff) MUST - // still surface so we can react. - for raw in [ - "Backend returned 408 Request Timeout for POST https://api.example.com/x: timeout", - "Backend returned 429 Too Many Requests for POST https://api.example.com/x: slow down", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "transient 4xx must NOT be classified as user-error: {raw}" - ); - } - - // 5xx is always actionable — server bugs need to reach Sentry. - for raw in [ - "Backend returned 500 Internal Server Error for POST https://api.example.com/x: oops", - "Backend returned 502 Bad Gateway for POST https://api.example.com/x: upstream down", - "Backend returned 503 Service Unavailable for POST https://api.example.com/x: maintenance", - "Backend returned 504 Gateway Timeout for POST https://api.example.com/x: slow upstream", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "5xx must NOT be classified as user-error: {raw}" - ); - } - - // A free-form message that mentions "400" but doesn't follow the - // `Backend returned ` prefix from the integrations / - // composio clients must not be silenced. - assert_eq!( - expected_error_kind("see HTTP 400 specification at https://example.com/400"), - None - ); - assert_eq!( - expected_error_kind("OpenAI API error (400): bad request"), - None, - "provider-formatted 4xx must keep going through the provider classifier path" - ); -} - -#[test] -fn classifies_trigger_type_not_found_as_provider_user_state() { - // OPENHUMAN-TAURI-3R / -3S: composio enable_trigger when the slug - // isn't in the trigger registry. Backend wraps the upstream - // composio 4xx as 500, so this would otherwise escape the - // 4xx-only `is_backend_user_error_message` matcher. - assert_eq!( - expected_error_kind( - "Backend returned 500 Internal Server Error for POST \ - https://api.tinyhumans.ai/agent-integrations/composio/triggers: \ - Trigger type GITHUB_PUSH_EVENT not found" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); - - // Wrapped by `rpc.invoke_method` / `[composio] sync(toolkit) failed: …` - // — substring match must survive caller context. - assert_eq!( - expected_error_kind( - "rpc.invoke_method failed: Backend returned 500 Internal Server Error \ - for POST /agent-integrations/composio/triggers: \ - Trigger type SLACK_NEW_MESSAGE not found" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); - - // Alternate phrasing observed from the same cluster. - assert_eq!( - expected_error_kind( - "composio: Cannot enable trigger 'GITHUB_PUSH_EVENT': trigger not found in registry" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); -} - -#[test] -fn classifies_toolkit_not_enabled_as_provider_user_state() { - // OPENHUMAN-TAURI-34: 400 from composio because the user hasn't - // enabled the toolkit. Must classify as ProviderUserState (more - // specific) rather than the generic BackendUserError bucket — the - // ordering in `expected_error_kind` enforces that. - let msg = "Backend returned 400 Bad Request for POST \ - https://api.tinyhumans.ai/agent-integrations/composio/execute: \ - Toolkit \"get\" is not enabled"; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState) - ); - - // Wrapped variant (anyhow chain through the agent runtime). - assert_eq!( - expected_error_kind( - "tool.invoke failed: Backend returned 400 Bad Request for POST \ - /agent-integrations/composio/execute: Toolkit \"linear\" is not enabled \ - for this account" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); -} - -#[test] -fn classifies_custom_openai_upstream_bad_request_as_provider_user_state() { - assert_eq!( - expected_error_kind( - "custom_openai API error (400 Bad Request): \ - {\"error\":{\"message\":\"Bad request to upstream provider\",\ - \"type\":\"upstream_error\",\"status\":400}}" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); - - // Wrapped by higher-level callers (`agent.run_single`, - // `rpc.invoke_method`) must still classify. - assert_eq!( - expected_error_kind( - "agent.run_single failed: custom_openai API error (400 Bad Request): \ - {\"error\":{\"message\":\"Bad request to upstream provider\",\ - \"type\":\"upstream_error\",\"status\":400}}" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); -} - -/// Regression for CodeRabbit feedback on PR #2107: the matcher must -/// not demote unrelated errors that happen to contain both -/// "bad request to upstream provider" and "upstream_error" without -/// the `custom_openai API error (400` anchor. -#[test] -fn does_not_silence_unrelated_error_with_only_inner_substrings() { - // No `custom_openai API error (400` prefix → must NOT classify - // as ProviderUserState, otherwise we'd silence actionable bugs. - assert_eq!( - expected_error_kind( - "internal panic in router: bad request to upstream provider \ - (state=upstream_error)" - ), - None, - ); - - // A future hypothetical provider envelope reusing one substring - // also must not classify. - assert_eq!( - expected_error_kind( - "anthropic_api error: upstream_error encountered while \ - forwarding bad request to upstream provider" - ), - None, - ); -} - -#[test] -fn classifies_missing_required_fields_as_provider_user_state() { - // OPENHUMAN-TAURI-97: composio authorize with a blank required - // field. Backend wraps the composio 400 as 500 with the inner - // body embedded as a JSON-stringified error message. - assert_eq!( - expected_error_kind( - "Backend returned 500 Internal Server Error for POST \ - https://api.tinyhumans.ai/agent-integrations/composio/authorize: \ - 400 {\"error\":{\"message\":\"Missing required fields: Your Subdomain\"}}" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); - - // Sibling toolkits surface the same shape with different field names. - for raw in [ - "Backend returned 500 Internal Server Error for POST /authorize: Missing required fields: WABA ID", - "Backend returned 500 Internal Server Error for POST /authorize: Missing required fields: Tenant Name", - "Backend returned 400 Bad Request for POST /authorize: Missing required fields: Domain URL", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ProviderUserState), - "missing-required-fields shape must classify: {raw}" - ); - } -} - -#[test] -fn classifies_insufficient_scopes_as_provider_user_state() { - // OPENHUMAN-TAURI-33: gmail sync surfaced the upstream Google - // OAuth scopes error verbatim through composio. Reaches the RPC - // dispatch site via `[composio] sync(gmail) failed: [composio:gmail] - // GMAIL_FETCH_EMAILS page 0: HTTP 403: Request had insufficient - // authentication scopes.`. - assert_eq!( - expected_error_kind( - "[composio:gmail] GMAIL_FETCH_EMAILS page 0: HTTP 403: \ - Request had insufficient authentication scopes." - ), - Some(ExpectedErrorKind::ProviderUserState) - ); - - // Bare upstream shape (in case any future caller forwards without - // the gmail prefix). - assert_eq!( - expected_error_kind("HTTP 403: Request had insufficient authentication scopes."), - Some(ExpectedErrorKind::ProviderUserState) - ); -} - -#[test] -fn classifies_access_terminated_provider_policy_as_provider_user_state() { - assert_eq!( - expected_error_kind( - "custom_openai API error (403 Forbidden): {\"error\":{\"message\":\"Kimi For Coding is currently only available for Coding Agents such as Kimi CLI, Claude Code, Roo Code, Kilo Code, etc.\",\"type\":\"access_terminated_error\"}}" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); - - assert_eq!( - expected_error_kind( - "agent turn failed: custom_openai API error (403): currently only available for coding agents" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); -} - -#[test] -fn does_not_classify_unrelated_500s_as_provider_user_state() { - // Sanity check: a generic 500 with no provider-user-state body - // shape must continue to reach Sentry as an actionable event. - assert_eq!( - expected_error_kind( - "Backend returned 500 Internal Server Error for POST \ - /agent-integrations/composio/triggers: random panic in handler" - ), - None - ); - assert_eq!( - expected_error_kind( - "Backend returned 500 Internal Server Error for GET /teams: database connection lost" - ), - None - ); - - // Free-form text that mentions "not found" / "is not enabled" out - // of context must not be silenced. - assert_eq!( - expected_error_kind("file not found at /tmp/x.json"), - None, - "bare 'not found' without 'trigger type' anchor must NOT classify" - ); - assert_eq!( - expected_error_kind("the cache is not enabled in this build"), - None, - "bare 'is not enabled' without 'toolkit ' anchor must NOT classify" - ); -} - -#[test] -fn classifies_provider_config_rejection() { - // #2079 — an OpenHuman abstract tier alias leaked to a custom - // provider; raised again by `agent.run_single` / - // `web_channel.run_chat_task` so it escapes the provider-layer - // demotion and reaches `report_error_or_expected` here. - assert_eq!( - expected_error_kind( - "agent.run_single failed: custom_openai API error (400 Bad Request): \ - The supported API model names are deepseek-v4-pro or deepseek-v4-flash, \ - but you passed reasoning-v1." - ), - Some(ExpectedErrorKind::ProviderConfigRejection) - ); - // #2076 — Moonshot Kimi K2 temperature constraint. - assert_eq!( - expected_error_kind( - "custom_openai API error (400): invalid temperature: only 1 is allowed for this model" - ), - Some(ExpectedErrorKind::ProviderConfigRejection) - ); - // #2202 — unknown / stale model pin (OpenAI-compatible body). - assert_eq!( - expected_error_kind( - "custom_openai API error (400): Model 'claude-opus-4-7' is not available. \ - Use GET /openai/v1/models to list available models." - ), - Some(ExpectedErrorKind::ProviderConfigRejection) - ); -} - -#[test] -fn does_not_classify_unrelated_provider_failures_as_config_rejection() { - // Inverted polarity / scope guard: a 5xx or a generic 4xx with no - // config-rejection body must still reach Sentry as actionable. - // (The OpenHuman backend never emits these phrases, so the - // message-level predicate is intrinsically custom-provider scoped; - // the HTTP-layer twin enforces the non-backend guard explicitly.) - assert_eq!( - expected_error_kind("custom_openai API error (500): internal server error"), - None - ); - assert_eq!( - expected_error_kind( - "custom_openai API error (400 Bad Request): missing required field 'messages'" - ), - None, - "generic 4xx without a config-rejection body must NOT demote" - ); -} - -#[test] -fn unrelated_missing_required_fields_classifies_as_accepted_false_positive() { - // Documents the breadth of the `"missing required fields"` arm — - // unlike the trigger/toolkit arms it has no second anchor, so a - // non-composio call site whose error happens to contain the phrase - // will also demote. This is the accepted false-positive surface - // per the classifier doc-comment (every current emit site is - // scoped to composio/integrations envelopes, so a stray collision - // would have to come from a brand-new opt-in call site). - // - // Pinning this assertion locks the breadth in so a future - // narrowing of the matcher surfaces here instead of silently - // re-bucketing the demote path. - assert_eq!( - expected_error_kind("Internal error: missing required fields in config"), - Some(ExpectedErrorKind::ProviderUserState), - "accepted false-positive: bare 'missing required fields' demotes by design" - ); -} - -#[test] -fn provider_user_state_takes_precedence_over_backend_user_error() { - // Critical ordering guarantee: a 4xx body that contains the - // toolkit-not-enabled phrasing must land in `ProviderUserState` - // (more specific) — not in the generic `BackendUserError` bucket. - // Without the ordering in `expected_error_kind`, the 4xx matcher - // would win and the operator would see a different breadcrumb - // kind than intended (and miss the `kind="provider_user_state"` - // tag in info logs). - let msg = "Backend returned 400 Bad Request for POST \ - /agent-integrations/composio/execute: \ - Toolkit \"github\" is not enabled"; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState), - "4xx + toolkit-not-enabled must land in ProviderUserState, not BackendUserError" - ); -} - -// ── TAURI-RUST-X9 (#1166): composio-direct 401 / Invalid API key ──── - -#[test] -fn classifies_composio_direct_invalid_api_key_as_provider_user_state() { - // Canonical Sentry TAURI-RUST-X9 wire shape — the verbatim title - // body from the issue, captured 15,732 times in ~22h on a single - // user with a bad direct-mode key. The classifier must demote - // this to `ProviderUserState` so the polling layer's 5 s retry - // doesn't keep flooding Sentry. - let msg = "[composio-direct] list_connections failed: \ - Composio v3 connected_accounts failed: \ - HTTP 401: Invalid API key: ak_VsUvq*****"; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState), - "composio-direct HTTP 401 + Invalid API key must demote to ProviderUserState" - ); -} - -#[test] -fn classifies_composio_direct_invalid_api_key_for_other_ops() { - // Same arm must cover every op-name the direct branches emit — - // not just `list_connections`. The matcher gates on the - // `[composio-direct]` prefix, not on a specific op string, so - // `list_tools` / `authorize` / `list_connections` all demote. - let shapes = [ - // list_tools prefetch fails before the actual list_tools call - "[composio-direct] list_tools: prefetch connections failed: \ - Composio v3 connected_accounts failed: HTTP 401: Invalid API key: ak_…", - // direct authorize hits the v3 /connected_accounts/link wall - "[composio-direct] authorize failed: \ - Composio v3 connected_accounts/link failed: HTTP 401: Invalid API key: ak_…", - // direct list_tools itself - "[composio-direct] list_tools failed: \ - Composio v3 tools failed: HTTP 401: Invalid API key: ak_…", - // periodic-tick rendering (no "[composio-direct]" prefix because - // periodic.rs wraps differently, but the failure still gets the - // hook — handled by ops.rs's report path, not the - // expected_error_kind body shape, so we only verify the - // composio-direct branch here) - ]; - for msg in shapes { - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState), - "every [composio-direct] op with HTTP 401 / Invalid API key must demote: {msg}" - ); - } -} - -#[test] -fn classifies_composio_direct_with_invalid_api_key_only_no_http_401() { - // The matcher accepts EITHER `HTTP 401` OR `Invalid API key` - // alongside the `[composio-direct]` prefix. Catches the wire - // shape variant where the body anchor lands but the status text - // is rendered differently (e.g. "401 Unauthorized" instead of - // "HTTP 401") — same user-state condition. - let msg = "[composio-direct] list_connections failed: \ - Composio v3 connected_accounts failed: \ - 401 Unauthorized: Invalid API key: ak_…"; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState), - "composio-direct + Invalid API key body must demote even without literal 'HTTP 401'" - ); -} - -#[test] -fn does_not_classify_unrelated_http_401_as_composio_direct_user_state() { - // Discrimination test: a generic 401 that does NOT carry the - // `[composio-direct]` prefix must NOT match this arm. This - // protects against the arm accidentally swallowing backend-mode - // composio 401s, unrelated integration 401s, or any other - // 401-containing message that lacks the direct-mode anchor. - // - // The backend-mode shape is `Backend returned 401 …`; it does - // not contain `[composio-direct]`, so the new arm rightly skips - // it. Backend-mode 401s remain a real Sentry signal (bad - // service-to-service auth, expired token, etc.). - let backend_401 = "[composio] list_connections failed: \ - Backend returned 401 Unauthorized for GET \ - https://api.tinyhumans.ai/agent-integrations/composio/connections: \ - Invalid API key"; - assert_ne!( - expected_error_kind(backend_401), - Some(ExpectedErrorKind::ProviderUserState), - "backend-mode 401 must NOT demote via the composio-direct arm" - ); - - let unrelated_401 = "GitHub API error: HTTP 401: Bad credentials"; - assert_ne!( - expected_error_kind(unrelated_401), - Some(ExpectedErrorKind::ProviderUserState), - "unrelated 401 (no [composio-direct] anchor) must NOT match the composio-direct arm" - ); -} - -#[test] -fn does_not_classify_composio_direct_500_as_user_state() { - // Real bug shapes — a 500 from the direct v3 path with no auth - // body anchor — must still fall through to `None` so Sentry - // sees them. Without this guard the arm could be too permissive - // and silence genuine backend faults. - let msg = "[composio-direct] list_connections failed: \ - Composio v3 connected_accounts failed: HTTP 500"; - assert_eq!( - expected_error_kind(msg), - None, - "composio-direct 500 with no auth body must NOT demote — it is a real bug shape" - ); -} - -// ── TAURI-RUST-322 (#2929): composio-direct 403 (key missing perms) ─ - -#[test] -fn classifies_composio_direct_403_as_provider_user_state() { - // Canonical Sentry TAURI-RUST-322 wire shape — the verbatim - // title body from the issue (1,021 events, multi-release). The - // Composio v3 `/connected_accounts` endpoint returns HTTP 403 - // when the BYO API key exists but lacks `connected_accounts:read` - // permission. This is a user-state condition; Sentry has no - // remediation path. - let msg = "[composio-direct] list_connections failed: \ - Composio v3 connected_accounts failed: HTTP 403"; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState), - "composio-direct HTTP 403 must demote to ProviderUserState (TAURI-RUST-322)" - ); -} - -#[test] -fn classifies_composio_direct_403_for_other_ops() { - // The `[composio-direct]` + `HTTP 403` arm must cover every op - // that can hit a 403 from the Composio v3 tenant (list_tools - // prefetch, authorize, etc.) — not just list_connections. - let shapes = [ - // list_tools prefetch of connections hits the 403 wall - "[composio-direct] list_tools: prefetch connections failed: \ - Composio v3 connected_accounts failed: HTTP 403", - // list_connections itself (the primary source of the leak) - "[composio-direct] list_connections (direct) failed: \ - Composio v3 connected_accounts failed: HTTP 403", - // any future direct-mode op that hits a 403 - "[composio-direct] composio_list_connections (direct) failed: \ - Composio v3 connected_accounts failed: HTTP 403", - ]; - for msg in shapes { - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState), - "every [composio-direct] op with HTTP 403 must demote to ProviderUserState: {msg}" - ); - } -} - -#[test] -fn does_not_classify_unrelated_http_403_as_composio_direct_user_state() { - // Discrimination test: a 403 that does NOT carry the - // `[composio-direct]` prefix must NOT match this arm. Backend-mode - // composio 403s and unrelated 403s must remain visible in Sentry. - let backend_403 = "[composio] list_connections failed: \ - Backend returned 403 Forbidden for GET \ - https://api.tinyhumans.ai/agent-integrations/composio/connections"; - // The backend-mode shape passes through `is_backend_user_error_message` - // (4xx matcher), not this arm. Verify it does NOT match this arm. - assert!( - !lower_contains_composio_direct_auth_wall(backend_403), - "backend-mode 403 must NOT match the composio-direct arm" - ); - - let unrelated_403 = "GitHub API error: HTTP 403: rate limit exceeded"; - assert_ne!( - expected_error_kind(unrelated_403), - Some(ExpectedErrorKind::ProviderUserState), - "unrelated 403 (no [composio-direct] anchor) must NOT match the composio-direct arm" - ); -} - -// Helper used only in the discrimination test above — mirrors the -// exact condition in `is_provider_user_state_message` without -// requiring access to the private function. -fn lower_contains_composio_direct_auth_wall(msg: &str) -> bool { - let lower = msg.to_ascii_lowercase(); - lower.contains("[composio-direct]") - && (lower.contains("http 401") - || lower.contains("http 403") - || lower.contains("invalid api key")) -} - -// ── TAURI-RUST-34H: backend-wrapped Cloudflare anti-bot interstitial ─ - -#[test] -fn classifies_backend_cloudflare_antibot_wrap_as_provider_user_state() { - // Canonical Sentry TAURI-RUST-34H wire shape — the verbatim title - // body from the issue (8,851 events / 14d on self-hosted - // `tauri-rust`). The backend wraps an upstream Cloudflare 403 - // anti-bot challenge as `Backend returned 500 … 403 Just a moment...

Icon for api.tinyhumans.aiapi.tinyhumans.ai

...Powered by Cloudflare..."#; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState), - "backend-wrapped Cloudflare anti-bot interstitial must demote to ProviderUserState" - ); -} - -#[test] -fn classifies_minimal_cloudflare_antibot_body_as_provider_user_state() { - // Strip the wire shape down to just the two anchors — the - // matcher should still fire so future renderings (different - // line breaks, stripped HTML, alternate caller wrappers) still - // demote. - let msg = "Just a moment...\ncloudflare\n"; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState), - "minimal `Just a moment...` + `cloudflare` body must demote" - ); -} - -#[test] -fn does_not_classify_half_anchor_cloudflare_messages_as_user_state() { - // Discrimination test for the double-anchor: either half on its - // own must NOT match. This guards against unrelated bodies that - // happen to use either phrase out of context. - - // Half-anchor 1: `just a moment` without `cloudflare` — e.g. - // a daemon restart spinner blurb. - let half_a = "Just a moment, while we restart the daemon"; - assert_ne!( - expected_error_kind(half_a), - Some(ExpectedErrorKind::ProviderUserState), - "`Just a moment` without `cloudflare` must NOT match the CF anti-bot arm" - ); - - // Half-anchor 2: `cloudflare` without `just a moment...` — e.g. - // a CF Workers footer mention elsewhere. - let half_b = "Powered by Cloudflare"; - assert_ne!( - expected_error_kind(half_b), - Some(ExpectedErrorKind::ProviderUserState), - "`cloudflare` without `Just a moment...` must NOT match the CF anti-bot arm" - ); -} - -#[test] -fn does_not_classify_genuine_backend_500_without_cloudflare_body() { - // Real bug shape — a 500 from the same backend endpoint with no - // Cloudflare interstitial body — must still fall through so - // Sentry sees it. Without this guard the arm could be too - // permissive and silence genuine database / handler faults. - let msg = "Backend returned 500 Internal Server Error for GET \ - https://api.tinyhumans.ai/agent-integrations/composio/connections: \ - database connection pool exhausted"; - assert_eq!( - expected_error_kind(msg), - None, - "genuine backend 500 without Cloudflare body must NOT demote — it is a real bug" - ); -} - -#[test] -fn classifies_list_models_404_as_provider_user_state() { - // OPENHUMAN-TAURI-YJ: `inference/provider/ops.rs::list_models` probed - // a custom-provider's `/models` endpoint and the upstream server - // returned 404 because the base URL is wrong / doesn't host a models - // listing. User-config state — the model-dropdown probe already - // surfaces it inline. Pin the verbatim Sentry payload plus a few - // body-shape variants (different upstreams emit different 404 bodies) - // so the path-agnostic prefix anchor stays the source of truth. - for raw in [ - // Verbatim shape from the Sentry event. - r#"provider returned 404: {"error":"path \"/api/v1/models\" not found"}"#, - // FastAPI-style: `{"detail":"Not Found"}`. - r#"provider returned 404: {"detail":"Not Found"}"#, - // Bare HTML — happens when the user pointed at a non-API origin - // (e.g. the provider's docs site). - "provider returned 404: Not Found", - // After `truncate_with_ellipsis(.., 300)` clips a longer body — - // prefix anchor must still match. - r#"provider returned 404: {"error":{"message":"The requested URL /api/v1/models was not found on this server. Please check the URL or co…"#, - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ProviderUserState), - "OPENHUMAN-TAURI-YJ list_models 404 must classify as ProviderUserState: {raw}" - ); - } -} - -#[test] -fn does_not_classify_non_404_list_models_failures_as_user_state() { - // Discrimination guard: only the 404 prefix demotes. Sibling 4xx / - // 5xx codes from the same `provider returned NNN:` emit site must - // stay actionable in Sentry — they map to BYO-key auth walls (401 / - // 403), client-shape bugs (400), and transient / server faults - // (429 / 5xx) respectively. Pinning each shape here protects the - // #2286 BYO-key 401 contract and prevents the arm from silently - // widening to all 4xx. - for raw in [ - // BYO-key auth wall — must still escalate (`does_not_classify_byo_key_provider_401_as_session_expired` sibling guard). - r#"provider returned 401: {"error":"Invalid API key"}"#, - r#"provider returned 403: {"error":"Forbidden: API key revoked"}"#, - // Request-shape mismatch — likely a bug in our client. - r#"provider returned 400: {"error":"Bad Request"}"#, - // Transient — caught by retry/backoff at the provider layer, - // does NOT belong in the user-state bucket. - r#"provider returned 429: {"error":"rate_limited"}"#, - r#"provider returned 503: upstream temporarily unavailable"#, - // 500 — a real upstream bug; must reach Sentry. - r#"provider returned 500: {"error":"internal_server_error"}"#, - ] { - assert_ne!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ProviderUserState), - "non-404 list_models failure must NOT demote to ProviderUserState: {raw}" - ); - } -} - -#[test] -fn classifies_local_ai_binary_missing_errors() { - // OPENHUMAN-TAURI-9N: `local_ai_tts` returns this exact string - // from `service::speech::tts` when piper isn't on PATH or - // `PIPER_BIN` isn't set. - assert_eq!( - expected_error_kind("piper binary not found. Set PIPER_BIN or install piper."), - Some(ExpectedErrorKind::LocalAiBinaryMissing) - ); - // Sibling shapes from the same service area share the anchor and - // must classify the same way — the user-facing remediation is - // identical (install / configure the binary). - assert_eq!( - expected_error_kind( - "whisper.cpp binary not found. Set WHISPER_BIN or install whisper-cli." - ), - Some(ExpectedErrorKind::LocalAiBinaryMissing) - ); - assert_eq!( - expected_error_kind( - "Ollama binary not found at '/usr/local/bin/ollama'. Provide a valid path to the ollama executable." - ), - Some(ExpectedErrorKind::LocalAiBinaryMissing) - ); - assert_eq!( - expected_error_kind("Ollama installed but binary not found on system"), - Some(ExpectedErrorKind::LocalAiBinaryMissing) - ); - // Wrapped by the RPC dispatcher in production: - // `"rpc.invoke_method failed: piper binary not found. …"`. - // The classifier is substring-based, so caller context must not - // defeat it. - assert_eq!( - expected_error_kind( - "rpc.invoke_method failed: piper binary not found. Set PIPER_BIN or install piper." - ), - Some(ExpectedErrorKind::LocalAiBinaryMissing) - ); -} - -#[test] -fn does_not_classify_unrelated_messages_as_binary_missing() { - // Pin the anchor: messages that talk about binaries in a - // different context (download failures, version mismatches) - // must not be silenced. - assert_eq!( - expected_error_kind("piper binary failed to spawn: permission denied"), - None - ); - assert_eq!( - expected_error_kind("whisper.cpp returned empty transcript"), - None - ); -} - -#[test] -fn classifies_session_expired_messages() { - // OPENHUMAN-TAURI-26: the canonical wire shape that `agent.run_single` - // and `web_channel.run_chat_task` re-emit via `report_error_or_expected` - // when the user's JWT expires mid-conversation. The classifier - // anchors on the literal `"session expired"` substring from the - // OpenHuman backend's 401 body — NOT on the bare `(401 Unauthorized)` - // status, which would also silence BYO-key OpenAI/Anthropic 401s - // that are actionable. - assert_eq!( - expected_error_kind( - r#"OpenHuman API error (401 Unauthorized): {"success":false,"error":"Session expired. Please log in again."}"# - ), - Some(ExpectedErrorKind::SessionExpired) - ); - - // Wrapped by the agent / web-channel report sites in production — - // the classifier is substring-based so caller context must not - // defeat it. - assert_eq!( - expected_error_kind( - r#"run_chat_task failed client_id=abc thread_id=t1 request_id=r1 error=OpenHuman API error (401 Unauthorized): {"success":false,"error":"Session expired. Please log in again."}"# - ), - Some(ExpectedErrorKind::SessionExpired) - ); - - // Sentinel raised by `providers::openhuman_backend::resolve_bearer` - // when the scheduler-gate signed-out override is set - // (OPENHUMAN-TAURI-1T's cascade dampener returns this so callers - // get the same teardown path as a real backend 401). - assert_eq!( - expected_error_kind( - "SESSION_EXPIRED: backend session not active — sign in to resume LLM work" - ), - Some(ExpectedErrorKind::SessionExpired) - ); - - // Local pre-flight guards — OpenHuman-specific phrasing, safe to - // match regardless of caller wrapping. - for raw in [ - "no backend session token; run auth_store_session first", - "session JWT required", - "composio unavailable: no backend session token. Sign in first (auth_store_session).", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::SessionExpired), - "should classify as session-expired: {raw}" - ); - } -} - -/// OPENHUMAN-TAURI-SG (33 events, escalating, release `0.53.43+2b64ea8…`): -/// pre-#1763 leak of the `resolve_bearer` sentinel through -/// `agent.run_single`. PR #1763 (1fb0bef5) wired the `SessionExpired` -/// arm and the existing `classifies_session_expired_messages` test -/// covers the same byte string — this test pins the *Sentry-event -/// verbatim* shape (taken from the OPENHUMAN-TAURI-SG event payload) -/// so a future tweak to `is_session_expired_message` cannot regress -/// this exact wire form without a red test. -#[test] -fn session_expired_sg_wire_shape_matches() { - let msg = "SESSION_EXPIRED: backend session not active — sign in to resume LLM work"; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::SessionExpired), - "OPENHUMAN-TAURI-SG wire shape must classify as SessionExpired — \ - a regression here re-leaks 33+ events/cycle to Sentry" - ); -} - -/// The two sibling `SESSION_EXPIRED:` bail sites in -/// `providers::factory::verify_session_active` emit different message -/// suffixes but the same sentinel prefix. They route through the same -/// classifier as the run_single bail at -/// `providers::openhuman_backend::resolve_bearer`, and any matcher -/// tweak that breaks the family (e.g. moving from `contains` to a -/// stricter prefix/suffix match) would re-leak ALL of them. Pin every -/// variant the codebase actually emits so a future regression on the -/// matcher is caught for the whole family, not just the SG instance. -#[test] -fn session_expired_sibling_family_factory_strings_match() { - // src/openhuman/inference/provider/factory.rs:247 - // (verify_session_active — scheduler_gate signed-out path) - let custom_providers_variant = - "SESSION_EXPIRED: backend session not active — sign in to use custom providers"; - // src/openhuman/inference/provider/factory.rs:266 - // (verify_session_active — empty auth-profile JWT path) - let no_backend_session_variant = - "SESSION_EXPIRED: no backend session — sign in to use OpenHuman"; - - for raw in [custom_providers_variant, no_backend_session_variant] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::SessionExpired), - "factory.rs sibling sentinel must classify as SessionExpired: {raw}" - ); - } -} - -/// OPENHUMAN-TAURI-4P0: the OpenHuman backend rejects an expired/ -/// revoked JWT with the envelope `{"success":false,"error":"Invalid -/// token"}` (vs. the explicit `"Session expired. Please log in again."` -/// body covered by `classifies_session_expired_messages`). Same emit -/// site, same wrapping by `web_channel.run_chat_task`, but the body -/// substring is different. -/// -/// The matcher uses a conjunctive `"OpenHuman API error (401"` + -/// envelope-shaped `"\"error\":\"Invalid token\""` anchor pair so the -/// #2286 contract for bare `"Invalid token"` / BYO-key 401s is -/// preserved — `does_not_classify_byo_key_provider_401_as_session_expired` -/// pins that and must stay green. -#[test] -fn classifies_openhuman_invalid_token_401_as_session_expired() { - // Verbatim wire shape from the OPENHUMAN-TAURI-4P0 event payload. - let msg = r#"run_chat_task failed client_id=lssXhQidBfzGXG9k thread_id=thread-743193ba-f0c1-4008-b665-64d3030d1453 request_id=00696b71-fa05-4574-bcdb-5744a5dac6ea error=OpenHuman API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::SessionExpired), - "OPENHUMAN-TAURI-4P0 verbatim wire shape must classify as SessionExpired" - ); - - // Unwrapped emit shape (without the run_chat_task prefix) — also - // appears at provider/agent layers; the substring matcher must - // catch it regardless of caller wrapping. - assert_eq!( - expected_error_kind( - r#"OpenHuman API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"# - ), - Some(ExpectedErrorKind::SessionExpired), - "unwrapped OpenHuman invalid-token envelope must classify as SessionExpired" - ); -} - -/// TAURI-RUST-4K5 (118 events, escalating on 0.56.0): the embedding -/// client at `src/openhuman/embeddings/openai.rs:139` wraps the same -/// OpenHuman backend `{"success":false,"error":"Invalid token"}` 401 -/// envelope as 4P0, but with the `"Embedding API error"` prefix -/// instead of `"OpenHuman API error"` (different emit-site format -/// string, same underlying session-expired cause — see breadcrumb -/// `[scheduler_gate] signed_out false -> true` immediately preceding -/// the 401 in the event payload). -/// -/// Uses the same conjunctive `" (401"` + envelope-shaped -/// `"\"error\":\"Invalid token\""` anchor pattern as 4P0 so the -/// #2286 / BYO-key contract is preserved — covered by -/// `does_not_classify_byo_key_provider_401_as_session_expired` and -/// `does_not_classify_embedding_byo_key_401_as_session_expired` -/// (below). -#[test] -fn classifies_embedding_api_invalid_token_401_as_session_expired() { - // Verbatim wire shape from the TAURI-RUST-4K5 event payload (Sentry - // issue 5230, latest event 2026-05-27 20:49 on openhuman@0.56.0, - // domain=embeddings operation=openai_embed status=401). - let msg = - r#"Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::SessionExpired), - "TAURI-RUST-4K5 verbatim wire shape must classify as SessionExpired" - ); - - // The substring matcher must survive caller wrapping the same way - // the 4P0 web-channel `run_chat_task` test wraps the body — callers - // that re-emit through a tracing field or another layer prepend - // arbitrary context. - let wrapped = r#"openai_embed failed error=Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; - assert_eq!( - expected_error_kind(wrapped), - Some(ExpectedErrorKind::SessionExpired), - "wrapped 4K5 envelope must still classify as SessionExpired" - ); -} - -/// TAURI-RUST-1EE (Sentry issue 1807, 110 events, 109 on -/// openhuman@0.56.0): the streaming-chat path wraps the same OpenHuman -/// backend `{"success":false,"error":"Invalid token"}` 401 envelope -/// with the `"OpenHuman streaming API error"` prefix (emitted at -/// `inference/provider/compatible.rs:949`) — distinct from the -/// non-streaming `"OpenHuman API error"` prefix (4P0) and the -/// `"Embedding API error"` prefix (4K5). The `streaming` token between -/// `OpenHuman` and `API error` means the 4P0 anchor -/// (`"OpenHuman API error (401"`) does not match it, so it needs its -/// own prefix arm. -#[test] -fn classifies_openhuman_streaming_invalid_token_401_as_session_expired() { - // Verbatim wire shape from the TAURI-RUST-1EE event payload - // (domain=llm_provider operation=streaming_chat status=401 - // provider=OpenHuman model=reasoning-v1). - let msg = r#"OpenHuman streaming API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::SessionExpired), - "TAURI-RUST-1EE verbatim streaming wire shape must classify as SessionExpired" - ); - - // Caller-wrapped (agent.run_single / web_channel.run_chat_task - // re-emit prepends context) must still classify. - let wrapped = r#"run_chat_task failed error=OpenHuman streaming API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; - assert_eq!( - expected_error_kind(wrapped), - Some(ExpectedErrorKind::SessionExpired), - "wrapped 1EE streaming envelope must still classify as SessionExpired" - ); -} - -/// Polarity guard for the 1EE streaming arm — a third-party BYO-key -/// provider's streaming 401 (`"OpenAI streaming API error (401 …): -/// invalid_api_key"`) must STILL reach Sentry as actionable -/// misconfiguration. The `"OpenHuman streaming API error (401"` prefix -/// gate keeps the match OpenHuman-scoped. -#[test] -fn does_not_classify_streaming_byo_key_401_as_session_expired() { - for raw in [ - "OpenAI streaming API error (401 Unauthorized): invalid_api_key", - r#"OpenAI streaming API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#, - "Anthropic streaming API error (401): authentication_error", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "BYO-key streaming 401 must reach Sentry as actionable error: {raw}" - ); - } -} - -/// Polarity guard for the 4K5 arm. The classifier must NOT swallow -/// `"Embedding API error (401 …)"` shapes from third-party BYO-key -/// embedding providers (OpenAI / Voyage / Cohere upstream rejecting -/// the user's own API key). Those are actionable user-config errors -/// that need to reach Sentry — same contract as -/// `does_not_classify_byo_key_provider_401_as_session_expired` for -/// the OpenAI chat API. -#[test] -fn does_not_classify_embedding_byo_key_401_as_session_expired() { - for raw in [ - "Embedding API error (401 Unauthorized): invalid_api_key", - r#"Embedding API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#, - // Wire shape without the OpenHuman envelope — bare provider - // rejection prose. Must reach Sentry as actionable BYO-key - // misconfiguration. - "Embedding API error (401): authentication_error", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "BYO-key embedding 401 must reach Sentry as actionable error: {raw}" - ); - } -} - -#[test] -fn does_not_classify_byo_key_provider_401_as_session_expired() { - // Critical: a BYO-key 401 from OpenAI / Anthropic etc. is an - // actionable misconfiguration (wrong API key) that the user needs - // to fix in settings. It must reach Sentry as an error and must - // NOT be classified as session-expired at the agent layer — the - // strict classifier requires the OpenHuman backend's - // "session expired" body to anchor the match. The JSON-RPC - // dispatch-site classifier uses the same strict rule so these - // scoped provider failures never clear the app session either. - for raw in [ - "OpenAI API error (401 Unauthorized): invalid_api_key", - "Anthropic API error (401 Unauthorized): authentication_error", - "OpenAI API error (401): unauthorized", - r#"OpenAI API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#, - // Generic "invalid token" without OpenHuman session phrasing — - // could mean a third-party provider rejected its own token. - "Invalid token", - "got an invalid token here", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "BYO-key / generic 401 must reach Sentry as actionable error: {raw}" - ); - } -} - -#[test] -fn does_not_classify_unrelated_messages_as_session_expired() { - // Bare numeric 401 (port number, runbook reference) must not be - // silenced. - assert_eq!(expected_error_kind("server returned 401"), None); - assert_eq!( - expected_error_kind("see runbook for 401 handling at https://example.com/401"), - None - ); - // Provider 5xx — must reach Sentry. - assert_eq!( - expected_error_kind("OpenAI API error (500): internal server error"), - None - ); - // Lowercase sentinel must NOT match — the SESSION_EXPIRED sentinel - // is case-sensitive by design (matches the sentinel emitted by - // `providers::openhuman_backend::resolve_bearer` exactly). - assert_eq!(expected_error_kind("session_expired lowercase"), None); -} - -/// TAURI-RUST-E (~1 437 events): billing poll fires `report_error_or_expected` -/// on every refresh cycle once the user's JWT lapses because the -/// `BackendApiError::Unauthorized` typed error was stringified to -/// `"backend rejected session token on GET /payments/stripe/currentPlan"` by -/// `billing::ops::get_authed_value(..).map_err(|e| e.to_string())` before the -/// phrase was added to `is_session_expired_message`. -/// -/// The phrase `"backend rejected session token"` is uniquely produced by -/// `BackendApiError::Unauthorized`'s `Display` impl in `api::rest` — no -/// third-party provider path emits it — so no conjunctive guard is needed. -#[test] -fn classifies_billing_401_as_session_expired() { - // Exact wire shape from `billing_get_current_plan` — the most common - // event in TAURI-RUST-E. - assert_eq!( - expected_error_kind("backend rejected session token on GET /payments/stripe/currentPlan"), - Some(ExpectedErrorKind::SessionExpired), - "TAURI-RUST-E: billing_get_current_plan 401 must classify as SessionExpired" - ); - - // Other billing methods share the same `BackendApiError::Unauthorized` - // display shape — pin them so a wording change in `rest.rs` would catch - // every billing call site. - for path in [ - "/payments/credits/balance", - "/payments/credits/transactions?limit=20&offset=0", - "/payments/credits/auto-recharge", - "/payments/credits/auto-recharge/cards", - "/payments/stripe/purchasePlan", - "/payments/stripe/portal", - "/coupons/me", - ] { - let msg = format!("backend rejected session token on GET {path}"); - assert_eq!( - expected_error_kind(&msg), - Some(ExpectedErrorKind::SessionExpired), - "billing 401 must classify as SessionExpired: {msg}" - ); - } - - // POST / PATCH / DELETE variants are also produced by `authed_json`. - for raw in [ - "backend rejected session token on POST /payments/credits/top-up", - "backend rejected session token on PATCH /payments/credits/auto-recharge", - "backend rejected session token on DELETE /payments/credits/auto-recharge/cards/pm_123", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::SessionExpired), - "TAURI-RUST-E variant must classify as SessionExpired: {raw}" - ); - } -} - -/// `"backend rejected session token"` is scoped to `BackendApiError::Unauthorized` -/// in `api::rest`. Ensure unrelated messages containing the individual -/// words don't accidentally match. -#[test] -fn does_not_classify_unrelated_rejected_messages_as_session_expired() { - // Third-party provider errors that mention a token being rejected but - // do not contain the exact OpenHuman `BackendApiError::Unauthorized` - // display phrase. - for raw in [ - "Discord API error: token rejected by upstream", - "Stripe webhook signature rejected — bad secret", - "API token rejected: please regenerate", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "unrelated token-rejected message must NOT suppress Sentry: {raw}" - ); - } -} - -#[test] -fn report_error_does_not_panic_with_many_tags() { - let err = anyhow::anyhow!("multi-tag"); - report_error( - &err, - "test", - "multi_tag", - &[("a", "1"), ("b", "2"), ("c", "3"), ("d", "4")], - ); -} - -fn event_with_tags(pairs: &[(&str, &str)]) -> sentry::protocol::Event<'static> { - let mut event = sentry::protocol::Event::default(); - let mut tags: std::collections::BTreeMap = std::collections::BTreeMap::new(); - for (k, v) in pairs { - tags.insert((*k).to_string(), (*v).to_string()); - } - event.tags = tags; - event -} - -fn event_with_tags_and_message( - pairs: &[(&str, &str)], - message: &str, -) -> sentry::protocol::Event<'static> { - let mut event = event_with_tags(pairs); - event.message = Some(message.to_string()); - event -} - -#[test] -fn transient_filter_drops_429_408_502_503_504() { - for status in ["429", "408", "502", "503", "504"] { - let event = event_with_tags(&[ - ("domain", "llm_provider"), - ("failure", "non_2xx"), - ("status", status), - ]); - assert!( - is_transient_provider_http_failure(&event), - "status {status} must be classified as transient and filtered" - ); - } -} - -#[test] -fn transient_filter_keeps_permanent_failures() { - for status in ["400", "401", "403", "404", "500"] { - let event = event_with_tags(&[ - ("domain", "llm_provider"), - ("failure", "non_2xx"), - ("status", status), - ]); - assert!( - !is_transient_provider_http_failure(&event), - "status {status} must NOT be filtered — it's actionable" - ); - } -} - -#[test] -fn transient_filter_keeps_aggregate_all_exhausted() { - let event = event_with_tags(&[ - ("domain", "llm_provider"), - ("failure", "all_exhausted"), - ("status", "503"), - ]); - assert!( - !is_transient_provider_http_failure(&event), - "aggregate all_exhausted events must surface (they are the cascade signal)" - ); -} - -#[test] -fn transient_filter_keeps_events_with_no_status_tag() { - let event = event_with_tags(&[("domain", "llm_provider"), ("failure", "non_2xx")]); - assert!( - !is_transient_provider_http_failure(&event), - "missing status tag must not be silently dropped" - ); -} - -// Regression guard: the filter must scope to provider events only. Other -// subsystems emit `failure=non_2xx` (e.g. -// `providers/compatible.rs` uses the same marker for OAI-compatible -// error paths, but every site goes through `report_error(.., -// "llm_provider", ..)` so the domain tag is consistent), but the broader -// point is: any future caller that re-uses the same tag set for a -// different domain must NOT be silently dropped by this filter. -#[test] -fn transient_filter_keeps_events_with_no_domain_tag() { - let event = event_with_tags(&[("failure", "non_2xx"), ("status", "503")]); - assert!( - !is_transient_provider_http_failure(&event), - "missing domain tag means the event isn't provider-originated — must surface" - ); -} - -#[test] -fn transient_filter_keeps_events_from_other_domains() { - let event = event_with_tags(&[ - ("domain", "scheduler"), - ("failure", "non_2xx"), - ("status", "503"), - ]); - assert!( - !is_transient_provider_http_failure(&event), - "non-provider domain must surface even if failure/status tags collide" - ); -} - -#[test] -fn backend_api_filter_drops_transient_statuses() { - for status in TRANSIENT_HTTP_STATUSES { - let event = event_with_tags(&[ - ("domain", "backend_api"), - ("failure", "non_2xx"), - ("status", status), - ]); - assert!( - is_transient_backend_api_failure(&event), - "backend status {status} must be classified as transient" - ); - } -} - -#[test] -fn backend_api_filter_drops_transient_transport_phrases() { - for phrase in TRANSIENT_TRANSPORT_PHRASES { - let event = event_with_tags_and_message( - &[("domain", "backend_api"), ("failure", "transport")], - &format!("GET /teams failed: {phrase}"), - ); - assert!( - is_transient_backend_api_failure(&event), - "backend transport phrase {phrase} must be classified as transient" - ); - } -} - -#[test] -fn backend_api_filter_keeps_non_transient_failures() { - for status in ["404", "500"] { - let event = event_with_tags(&[ - ("domain", "backend_api"), - ("failure", "non_2xx"), - ("status", status), - ]); - assert!( - !is_transient_backend_api_failure(&event), - "backend status {status} must stay visible" - ); - } - - let wrong_domain = event_with_tags(&[ - ("domain", "scheduler"), - ("failure", "non_2xx"), - ("status", "503"), - ]); - assert!( - !is_transient_backend_api_failure(&wrong_domain), - "domain scoping must keep unrelated transient-shaped events visible" - ); - - let non_matching_transport = event_with_tags_and_message( - &[("domain", "backend_api"), ("failure", "transport")], - "GET /teams failed: certificate verify failed", - ); - assert!( - !is_transient_backend_api_failure(&non_matching_transport), - "transport failures without an allowlisted phrase must stay visible" - ); -} - -#[test] -fn integrations_filter_drops_transient_statuses() { - for status in TRANSIENT_HTTP_STATUSES { - let event = event_with_tags(&[ - ("domain", "integrations"), - ("failure", "non_2xx"), - ("status", status), - ]); - assert!( - is_transient_integrations_failure(&event), - "integrations status {status} must be classified as transient" - ); - } -} - -#[test] -fn integrations_filter_drops_transient_transport_phrases() { - for phrase in TRANSIENT_TRANSPORT_PHRASES { - let event = event_with_tags_and_message( - &[("domain", "integrations"), ("failure", "transport")], - &format!("GET /agent-integrations/tools failed: {phrase}"), - ); - assert!( - is_transient_integrations_failure(&event), - "integrations transport phrase {phrase} must be classified as transient" - ); - } -} - -#[test] -fn integrations_filter_keeps_non_transient_failures() { - for status in ["404", "500"] { - let event = event_with_tags(&[ - ("domain", "integrations"), - ("failure", "non_2xx"), - ("status", status), - ]); - assert!( - !is_transient_integrations_failure(&event), - "integrations status {status} must stay visible" - ); - } - - // Sibling-domain check: composio op-layer events MUST be silenced - // by the integrations filter — composio routes through the same - // `IntegrationClient` so the failure shape is identical, but - // op-level reporters that wrap and re-emit with their own domain - // tag would otherwise escape (OPENHUMAN-TAURI-35 / -2H). - let scheduler_domain = event_with_tags(&[ - ("domain", "scheduler"), - ("failure", "non_2xx"), - ("status", "503"), - ]); - assert!( - !is_transient_integrations_failure(&scheduler_domain), - "domain scoping must keep unrelated transient-shaped events visible" - ); - - let non_matching_transport = event_with_tags_and_message( - &[("domain", "integrations"), ("failure", "transport")], - "GET /agent-integrations/tools failed: invalid certificate", - ); - assert!( - !is_transient_integrations_failure(&non_matching_transport), - "transport failures without an allowlisted phrase must stay visible" - ); -} - -#[test] -fn composio_domain_routes_through_integrations_filter() { - // OPENHUMAN-TAURI-35 (~139 events) / -2H (~26 events): - // `[composio] list_connections failed: Backend returned 502 …` — - // composio op-layer wrappers (e.g. `composio_list_connections`) emit - // errors under `domain="composio"` so the original - // `domain="integrations"` filter let them through. Routing the - // composio domain through the same transient classifier closes - // that gap; the underlying transport / non_2xx semantics are - // identical because both layers share the same `IntegrationClient`. - for status in TRANSIENT_HTTP_STATUSES { - let event = event_with_tags(&[ - ("domain", "composio"), - ("failure", "non_2xx"), - ("status", status), - ]); - assert!( - is_transient_integrations_failure(&event), - "composio status {status} must be classified as transient" - ); - } - - // Transport-phrase variant — composio also surfaces reqwest - // transport failures (timeouts, connection resets) once the op - // wrapper has tagged the event with `failure=transport`. - for phrase in TRANSIENT_TRANSPORT_PHRASES { - let event = event_with_tags_and_message( - &[("domain", "composio"), ("failure", "transport")], - &format!("[composio] execute failed: {phrase}"), - ); - assert!( - is_transient_integrations_failure(&event), - "composio transport phrase {phrase} must be classified as transient" - ); - } - - // Non-transient composio statuses (404 / 500) must still surface — - // actionable bugs even when reported under the composio domain. - for status in ["404", "500"] { - let event = event_with_tags(&[ - ("domain", "composio"), - ("failure", "non_2xx"), - ("status", status), - ]); - assert!( - !is_transient_integrations_failure(&event), - "composio status {status} must stay visible" - ); - } -} - -#[test] -fn updater_transient_403_is_dropped() { - let event = event_with_tags_and_message( - &[ - ("domain", "update"), - ("operation", "check_releases"), - ("failure", "non_2xx"), - ("status", "403"), - ], - "[observability] update.check_releases failed: GitHub API error: 403 Forbidden", - ); - assert!( - is_updater_transient_event(&event), - "GitHub 403 updater checks are unactionable transient/rate-limit noise" - ); -} - -#[test] -fn updater_transient_502_is_dropped() { - let event = event_with_tags_and_message( - &[ - ("domain", "update.check_releases"), - ("failure", "non_2xx"), - ("status", "502"), - ], - "GitHub API error: 502 Bad Gateway", - ); - assert!( - is_updater_transient_event(&event), - "GitHub 5xx updater checks must be filtered as transient" - ); -} - -#[test] -fn updater_real_panic_still_reported() { - let event = event_with_tags_and_message( - &[("domain", "update"), ("operation", "check_releases")], - "thread 'main' panicked at src/openhuman/update/core.rs: index out of bounds", - ); - assert!( - !is_updater_transient_event(&event), - "update-domain events without a transient updater shape must still reach Sentry" - ); -} - -#[test] -fn updater_endpoint_non_success_message_is_dropped() { - // TAURI-RUST-CD (~151 events / 9 days, Windows): `tauri-plugin-updater` - // logs `update endpoint did not respond with a successful status code` - // (updater.rs) on any non-2xx response and discards the status, so the - // captured event has NO `domain`/`status` tag — only the bare message. - // It can therefore only be matched via the message fast-path. - assert!(is_updater_transient_message( - "update endpoint did not respond with a successful status code" - )); - - let event = event_with_tags_and_message( - &[], - "update endpoint did not respond with a successful status code", - ); - assert!( - is_updater_transient_event(&event), - "the plugin's status-blind, domain-less non-success log line is unactionable updater noise" - ); -} - -#[test] -fn updater_endpoint_non_success_anchor_does_not_silence_unrelated_errors() { - // The new anchor is the literal plugin string. Other updater failures - // that DO carry an actionable signal (signature/permission failures on - // apply, deserialize errors) and unrelated non-updater errors that - // merely mention a status code MUST NOT be dropped by it. Pin the - // rejection contract so a future refactor doesn't loosen the substring. - for msg in [ - "failed to apply update: signature verification failed", - "failed to deserialize update response: missing field `version`", - "backend request to /agent-integrations failed with status code 500", - "tool exited with non-zero status code 1", - ] { - let event = event_with_tags_and_message(&[], msg); - assert!( - !is_updater_transient_event(&event), - "unrelated/actionable error must still reach Sentry: {msg}" - ); - } -} - -#[test] -fn message_failure_classifier_matches_canonical_status_phrases() { - for msg in [ - "rpc.invoke_method failed: GET /teams failed (502 Bad Gateway)", - "GET /teams/me/usage failed (503 Service Unavailable)", - "downstream returned (504 Gateway Timeout): retry budget exhausted", - "OpenHuman API error (520 ): cf", - "POST /channels/telegram/typing failed (429 Too Many Requests)", - "auth connect failed: 503 Service Unavailable", - ] { - assert!( - is_transient_message_failure(msg), - "{msg:?} must be classified as transient" - ); - } -} - -#[test] -fn message_failure_classifier_matches_transport_phrases() { - for msg in [ - "integrations.get failed: composio/tools → operation timed out", - "GET https://api.example.com → connection forcibly closed (os 10054)", - "POST /v1/foo → tls handshake eof", - "error sending request for url (https://api.example.com)", - ] { - assert!( - is_transient_message_failure(msg), - "{msg:?} must be classified as transient" - ); - } -} - -#[test] -fn message_failure_classifier_keeps_unrelated_messages() { - for msg in [ - "rpc.invoke_method failed: schema validation error", - "process 502 exited unexpectedly", - "GET /teams failed (404 Not Found)", - "GET /teams failed (500 Internal Server Error)", - "unrelated error with port 5023", - "", - ] { - assert!( - !is_transient_message_failure(msg), - "{msg:?} must not be classified as transient" - ); - } -} - -#[test] -fn budget_filter_drops_budget_message_on_tagged_400() { - let event = event_with_tags_and_message( - &[("failure", "non_2xx"), ("status", "400")], - r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"#, - ); - - assert!(is_budget_event(&event)); -} - -#[test] -fn budget_filter_drops_budget_exception_on_tagged_400() { - let mut event = event_with_tags(&[("failure", "non_2xx"), ("status", "400")]); - event.exception.values.push(sentry::protocol::Exception { - value: Some("Budget exceeded — add credits to continue".to_string()), - ..Default::default() - }); - - assert!(is_budget_event(&event)); -} - -#[test] -fn budget_filter_keeps_non_budget_400() { - let event = event_with_tags_and_message( - &[("failure", "non_2xx"), ("status", "400")], - "Bad request: missing field", - ); - - assert!(!is_budget_event(&event)); -} - -#[test] -fn budget_filter_requires_non_2xx_failure_and_400_status_for_loose_phrases() { - // Tier 1 requires both tags for the loose budget phrases ("budget exceeded", - // "add credits", …) that might coincidentally appear in unrelated product - // copy. Tier 2 (text-only) only fires for the exact backend phrase - // "insufficient budget" — so the loose phrases still need both tags. - let message = "Budget exceeded — add credits to continue"; - for tags in [ - vec![("failure", "transport"), ("status", "400")], - vec![("failure", "non_2xx"), ("status", "500")], - vec![("failure", "non_2xx")], - ] { - let event = event_with_tags_and_message(&tags, message); - assert!(!is_budget_event(&event)); - } -} - -/// Tier-2 defense-in-depth: drop the exact "Insufficient budget" phrase -/// the OpenHuman backend returns regardless of which tags are set. -/// Regression guard for OPENHUMAN-CORE-N / TAURI-RUST-1P (GitHub #2935). -#[test] -fn budget_filter_drops_insufficient_budget_without_tags() { - // The exact JSON wire body from the OpenHuman backend. - let message = - r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"#; - - // No tags at all. - let event = event_with_message(message); - assert!( - is_budget_event(&event), - "tier-2 must drop 'Insufficient budget' even without failure/status tags" - ); - - // Wrong status tag only. - let event = event_with_tags_and_message(&[("status", "400")], message); - assert!( - is_budget_event(&event), - "tier-2 must drop 'Insufficient budget' when only status tag is set" - ); - - // Wrong failure tag only. - let event = event_with_tags_and_message(&[("failure", "transport")], message); - assert!( - is_budget_event(&event), - "tier-2 must drop 'Insufficient budget' when failure tag doesn't match" - ); -} - -/// Regression guard: tier-2 must also catch the exception/tracing path -/// (`sentry-tracing` with `attach_stacktrace=true` may populate the -/// exception list rather than `event.message`). -#[test] -fn budget_filter_drops_insufficient_budget_exception_without_tags() { - // Exact wire body from OpenHuman backend wrapped in an exception value. - let event = event_with_exception_value( - r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"INSUFFICIENT BUDGET"}"#, - ); - assert!( - is_budget_event(&event), - "tier-2 must drop exception-path 'Insufficient budget' case-insensitively" - ); - - // Mixed case variant. - let event = event_with_exception_value( - r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient Budget"}"#, - ); - assert!( - is_budget_event(&event), - "tier-2 must drop exception-path 'Insufficient Budget' case-insensitively" - ); - - // Wrong failure/status tags should not prevent tier-2 from matching. - let mut event = event_with_exception_value( - r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"#, - ); - event - .tags - .insert("failure".to_string(), "transport".to_string()); - assert!( - is_budget_event(&event), - "tier-2 must drop exception-path even when failure tag does not match" - ); - - // Unrelated exception values must not match. - let event = event_with_exception_value("retry budget exhausted after 3 attempts"); - assert!( - !is_budget_event(&event), - "tier-2 must not match unrelated exception value" - ); -} - -/// Tier-2 only matches the tight phrase — unrelated 400 errors with "budget" -/// in an unrelated context should not be silently dropped. -#[test] -fn budget_filter_tier2_does_not_match_unrelated_messages() { - for msg in [ - "retry budget exhausted after 3 attempts", - "bad request: missing field", - "budget_id=42 not found", - "", - ] { - let event = event_with_message(msg); - assert!( - !is_budget_event(&event), - "tier-2 must not match unrelated message: {msg:?}" - ); - } -} - -/// CORE-RUST-EK (~827 events): every 401 from the embeddings call path -/// (`domain=embeddings`, `failure=non_2xx`, `status=401`) must be filtered -/// before it reaches Sentry. Covers both the OpenHuman-backend "Invalid -/// token" shape (already handled by the primary `is_session_expired_message` -/// classifier) and third-party provider body shapes (OpenAI -/// `invalid_api_key`, plain `Unauthorized`) that fall through the string -/// classifier. -#[test] -fn embeddings_401_filter_drops_domain_embeddings_status_401() { - // Canonical CORE-RUST-EK wire shape: OpenAI `text-embedding-3-large` - // key is stale. `report_error_or_expected` sets - // domain=embeddings / operation=openai_embed / failure=non_2xx / - // status=401 / model=text-embedding-3-large. - let event = event_with_tags_and_message( - &[ - ("domain", "embeddings"), - ("operation", "openai_embed"), - ("failure", "non_2xx"), - ("status", "401"), - ("model", "text-embedding-3-large"), - ], - r#"Embedding API error (401 Unauthorized): {"error":{"message":"Incorrect API key provided. You can find your API key at https://platform.openai.com/account/api-keys.","type":"invalid_request_error","param":null,"code":"invalid_api_key"}}"#, - ); - assert!( - is_embeddings_api_key_401_event(&event), - "CORE-RUST-EK: domain=embeddings status=401 must be filtered" - ); -} - -/// Any other embedding status (e.g. 429 rate-limit, 500 server error) -/// must not be filtered by the embeddings-401 guard — those have their -/// own handlers (rate-limit filter, general error reporting). -#[test] -fn embeddings_401_filter_passes_other_statuses() { - for status in ["429", "500", "400"] { - let event = event_with_tags_and_message( - &[ - ("domain", "embeddings"), - ("failure", "non_2xx"), - ("status", status), - ], - "Embedding API error", - ); - assert!( - !is_embeddings_api_key_401_event(&event), - "domain=embeddings status={status} must NOT be filtered by the 401 guard" - ); - } -} - -/// Non-embeddings domains must not be filtered even if status=401 — the -/// guard is scoped specifically to `domain=embeddings` so that provider-chat -/// and backend-API 401s remain subject to their own classifiers. -#[test] -fn embeddings_401_filter_passes_non_embeddings_domains() { - for domain in ["llm_provider", "backend_api", "rpc", "composio"] { - let event = event_with_tags_and_message( - &[ - ("domain", domain), - ("failure", "non_2xx"), - ("status", "401"), - ], - "API error (401 Unauthorized): some body", - ); - assert!( - !is_embeddings_api_key_401_event(&event), - "domain={domain} status=401 must NOT be swallowed by the embeddings-401 guard" - ); - } -} - -/// The guard requires both `failure=non_2xx` and `status=401` to be -/// present; missing either tag must cause the filter to pass the event -/// through. -#[test] -fn embeddings_401_filter_requires_failure_and_status_tags() { - let no_failure_tag = event_with_tags_and_message( - &[("domain", "embeddings"), ("status", "401")], - "Embedding API error (401 Unauthorized): unauthorized", - ); - assert!( - !is_embeddings_api_key_401_event(&no_failure_tag), - "missing failure tag must not trigger the guard" - ); - - let no_status_tag = event_with_tags_and_message( - &[("domain", "embeddings"), ("failure", "non_2xx")], - "Embedding API error (401 Unauthorized): unauthorized", - ); - assert!( - !is_embeddings_api_key_401_event(&no_status_tag), - "missing status tag must not trigger the guard" - ); -} - -#[test] -fn report_error_or_expected_does_not_panic() { - report_error_or_expected( - "local ai is disabled", - "rpc", - "invoke_method", - &[("method", "openhuman.inference_prompt")], - ); - report_error_or_expected( - "ollama API key not set", - "agent", - "provider_chat", - &[("provider", "ollama")], - ); - // #2079 / #2076 / #2202 — exercises the expected_error_kind - // ProviderConfigRejection branch AND the report_expected_message - // skip-log arm (the agent/web-channel re-report demotion path). - report_error_or_expected( - "agent.run_single failed: custom_openai API error (400 Bad Request): \ - The supported API model names are deepseek-v4-pro or deepseek-v4-flash, \ - but you passed reasoning-v1.", - "agent", - "native_chat", - &[("provider", "custom_openai")], - ); - report_error_or_expected( - "custom_openai API error (400): invalid temperature: only 1 is allowed for this model", - "web_channel", - "run_chat_task", - &[("provider", "custom_openai")], - ); -} - -fn event_with_message(msg: &str) -> sentry::protocol::Event<'static> { - let mut event = sentry::protocol::Event::default(); - event.message = Some(msg.to_string()); - event -} - -fn event_with_exception_value(value: &str) -> sentry::protocol::Event<'static> { - let mut event = sentry::protocol::Event::default(); - event.exception = vec![sentry::protocol::Exception { - value: Some(value.to_string()), - ..Default::default() - }] - .into(); - event -} - -#[test] -fn max_iterations_filter_matches_message_path() { - // `report_error_message` calls `sentry::capture_message`, which - // populates `event.message`. The filter must see the canonical - // phrase on that field path. - let event = event_with_message("Agent exceeded maximum tool iterations (8)"); - assert!(is_max_iterations_event(&event)); -} - -#[test] -fn max_iterations_filter_matches_exception_path() { - // sentry-tracing with attach_stacktrace=true populates the - // exception list instead of (or in addition to) `event.message`. - // Filter must still catch the noise. - let event = event_with_exception_value( - "agent.run_single failed: Agent exceeded maximum tool iterations (10)", - ); - assert!(is_max_iterations_event(&event)); -} - -#[test] -fn max_iterations_filter_keeps_unrelated_events() { - assert!(!is_max_iterations_event(&event_with_message( - "provider returned 503" - ))); - assert!(!is_max_iterations_event(&event_with_message(""))); - assert!(!is_max_iterations_event(&sentry::protocol::Event::default())); -} - -// ── is_channel_message_not_found_event (TAURI-R7) ──────────────────────── - -fn channel_message_404_event(method: &str) -> sentry::protocol::Event<'static> { - let mut event = sentry::protocol::Event::default(); - event.tags.insert("domain".into(), "backend_api".into()); - event.tags.insert("failure".into(), "non_2xx".into()); - event.tags.insert("status".into(), "404".into()); - event.tags.insert("method".into(), method.into()); - event.message = Some( - "PATCH /channels/telegram/messages/1103 failed (404); response_body_len=172".to_string(), - ); - event -} - -#[test] -fn channel_message_not_found_filter_matches_patch() { - // Canonical TAURI-R7 shape: PATCH 404 on a channel-message path. - assert!(is_channel_message_not_found_event( - &channel_message_404_event("PATCH") - )); -} - -#[test] -fn channel_message_not_found_filter_matches_delete() { - assert!(is_channel_message_not_found_event( - &channel_message_404_event("DELETE") - )); -} - -#[test] -fn channel_message_not_found_filter_ignores_get_404() { - // GET 404 on a channel-message path is NOT an expected state — must keep Sentry signal. - assert!(!is_channel_message_not_found_event( - &channel_message_404_event("GET") - )); -} - -#[test] -fn channel_message_not_found_filter_ignores_non_channel_path() { - let mut event = channel_message_404_event("PATCH"); - event.message = Some("PATCH /auth/profile failed (404); response_body_len=42".to_string()); - assert!(!is_channel_message_not_found_event(&event)); -} - -#[test] -fn channel_message_not_found_filter_ignores_wrong_status() { - let mut event = channel_message_404_event("PATCH"); - event.tags.insert("status".into(), "403".into()); - assert!(!is_channel_message_not_found_event(&event)); -} - -#[test] -fn channel_message_not_found_filter_ignores_wrong_domain() { - let mut event = channel_message_404_event("PATCH"); - event.tags.insert("domain".into(), "channels".into()); - assert!(!is_channel_message_not_found_event(&event)); -} - -#[test] -fn channel_message_not_found_filter_matches_exception_path() { - // sentry-tracing with attach_stacktrace=true populates exception list. - let mut event = sentry::protocol::Event::default(); - event.tags.insert("domain".into(), "backend_api".into()); - event.tags.insert("failure".into(), "non_2xx".into()); - event.tags.insert("status".into(), "404".into()); - event.tags.insert("method".into(), "PATCH".into()); - event.exception = vec![sentry::protocol::Exception { - value: Some("PATCH /channels/discord/messages/abc failed (404): Not Found".to_string()), - ..Default::default() - }] - .into(); - assert!(is_channel_message_not_found_event(&event)); -} - -// ── LoopbackUnavailable (TAURI-R5, TAURI-R6) ───────────────────────────── - -/// Verbatim body shape from OPENHUMAN-TAURI-R5 (~2.5k events): the -/// `integrations.get` site reaches the embedded core's `127.0.0.1:18474` -/// listener during the boot window and reqwest's source chain renders as -/// `error sending request for url (…) → client error (Connect) → tcp -/// connect error → Connection refused (os error 61)`. -const R5_BODY: &str = "error sending request for url \ - (http://127.0.0.1:18474/agent-integrations/composio/connections) \ - → client error (Connect) → tcp connect error → Connection refused (os error 61)"; - -/// Verbatim body shape from OPENHUMAN-TAURI-R6 (~2.5k events): the same -/// transport failure as R5, re-wrapped one frame up by the composio -/// op-layer and re-emitted at the `rpc.invoke_method` site so it lands in -/// Sentry under `domain=rpc` instead of `domain=integrations`. -const R6_BODY: &str = "[composio] list_connections failed: \ - GET http://127.0.0.1:18474/agent-integrations/composio/connections failed: \ - error sending request for url \ - (http://127.0.0.1:18474/agent-integrations/composio/connections) \ - → client error (Connect) → tcp connect error → Connection refused (os error 61)"; - -#[test] -fn classifies_r5_loopback_connect_refused_as_loopback_unavailable() { - assert_eq!( - expected_error_kind(R5_BODY), - Some(ExpectedErrorKind::LoopbackUnavailable), - "R5 body must classify as LoopbackUnavailable, not the broader NetworkUnreachable bucket" - ); -} - -#[test] -fn classifies_r6_rpc_wrapped_loopback_connect_refused_as_loopback_unavailable() { - assert_eq!( - expected_error_kind(R6_BODY), - Some(ExpectedErrorKind::LoopbackUnavailable), - "R6 body (rpc.invoke_method re-wrap) must classify as LoopbackUnavailable" - ); -} - -#[test] -fn classifies_loopback_connect_refused_across_platforms() { - // Linux WSL / native: os error 111. Windows WSAECONNREFUSED: 10061. - // Both must classify so the matcher works regardless of where the - // user's desktop happens to be running. - for raw in [ - "error sending request for url (http://127.0.0.1:18474/x) \ - → tcp connect error → Connection refused (os error 111)", - "error sending request for url (http://localhost:18474/x) \ - → tcp connect error → Connection refused (os error 10061)", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::LoopbackUnavailable), - "should classify as LoopbackUnavailable across platforms: {raw}" - ); - } -} - -#[test] -fn loopback_unavailable_precedence_over_network_unreachable() { - // Precedence guard: a loopback `Connection refused (os error 61)` - // body would ALSO match `is_network_unreachable_message` because the - // broader matcher catches both `error sending request for url` and - // `connection refused`. The ladder must route through the - // loopback-specific bucket first so the two error classes stay - // distinguishable in Sentry. - let kind = expected_error_kind(R5_BODY); - assert_eq!(kind, Some(ExpectedErrorKind::LoopbackUnavailable)); - assert_ne!(kind, Some(ExpectedErrorKind::NetworkUnreachable)); -} - -#[test] -fn does_not_classify_loopback_url_with_different_error_class_as_loopback() { - // A real upstream HTTP failure that happens to hit a developer's - // local proxy on `127.0.0.1:` (e.g. `mitmproxy`, `Charles`, - // `ngrok http`) must NOT be silenced as loopback noise — the body - // shape is a 503 status, not a transport-level connect-refused, and - // is actionable for Sentry. - let raw = "Backend returned 503 Service Unavailable for GET \ - http://127.0.0.1:8080/agent-integrations/composio/connections: \ - upstream timed out"; - assert!( - !matches!( - expected_error_kind(raw), - Some(ExpectedErrorKind::LoopbackUnavailable) - ), - "loopback URL with non-transport error must not classify as LoopbackUnavailable" - ); -} - -#[test] -fn does_not_classify_non_loopback_connect_refused_as_loopback() { - // A `Connection refused` against a non-loopback host (DNS resolved - // to a remote IP, ISP-level block, captive portal) must fall - // through to `NetworkUnreachable`, not into the loopback bucket. - let raw = "error sending request for url \ - (https://api.tinyhumans.ai/agent-integrations/composio/connections) \ - → tcp connect error → Connection refused (os error 61)"; - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::NetworkUnreachable) - ); -} - -#[test] -fn loopback_matcher_requires_both_host_and_errno_anchors() { - // Defense against the matcher being too eager: bodies that satisfy - // only one of the two conjunctive anchors must not classify into the - // loopback bucket. They may still demote via the broader - // `NetworkUnreachable` matcher — that is the correct fall-through — - // but the bucket must stay distinct so Sentry's "what class is - // spiking?" signal is preserved. - let loopback_host_no_errno = - "doctor: probed 127.0.0.1:18474 and got connection refused without errno detail"; - assert_ne!( - expected_error_kind(loopback_host_no_errno), - Some(ExpectedErrorKind::LoopbackUnavailable), - "loopback host without `(os error N)` errno must not classify as LoopbackUnavailable" - ); - - let errno_no_loopback_host = "note: connection refused (os error 61) on retry"; - assert_ne!( - expected_error_kind(errno_no_loopback_host), - Some(ExpectedErrorKind::LoopbackUnavailable), - "errno without loopback host anchor must not classify as LoopbackUnavailable" - ); -} - -#[test] -fn report_error_or_expected_routes_r5_r6_through_expected_path() { - // Smoke test: both verbatim Sentry bodies flow through - // `report_error_or_expected` without panicking. The classifier - // routes them to `report_expected_message` (debug breadcrumb, - // metadata-only) instead of `report_error_message` - // (`sentry::capture_message` at error level). We can't observe the - // Sentry hub from this test, but exercising the call path catches - // any future regression that re-introduces a panic or mis-types - // the arm. - report_error_or_expected( - R5_BODY, - "integrations", - "get", - &[ - ("path", "/agent-integrations/composio/connections"), - ("failure", "transport"), - ], - ); - report_error_or_expected( - R6_BODY, - "rpc", - "invoke_method", - &[("method", "openhuman.composio_list_connections")], - ); -} - -#[test] -fn classifies_channel_supervisor_restart_english_discord_gateway() { - // TAURI-RUST-15 (~11.4k events / 14d on self-hosted `tauri-rust`): - // verbatim wrapper from `channels::runtime::supervision::spawn_supervised_listener` - // around the Discord gateway transport error. The English body - // would otherwise match `is_network_unreachable_message` (which - // demotes to `warn!` — still a Sentry event); the supervisor - // wrap precedence routes it to `ChannelSupervisorRestart` - // (info-only breadcrumb). - let body = "Channel discord error: error sending request for url \ - (https://discord.com/api/v10/gateway/bot); restarting"; - assert_eq!( - expected_error_kind(body), - Some(ExpectedErrorKind::ChannelSupervisorRestart) - ); -} - -#[test] -fn classifies_channel_supervisor_restart_chinese_windows_wsaetimedout() { - // TAURI-RUST-BB (~815 events / 14d): same supervisor wrapper, - // OS-localized inner WSAETIMEDOUT body on Chinese Windows. The - // English-only `is_network_unreachable_message` anchors miss - // this inner message, so without the language-agnostic - // supervisor matcher it would escape classification entirely - // and emit a full Sentry error. The wrapper-anchored predicate - // catches it regardless of OS locale. - let body = "Channel discord error: IO error: \ - 由于连接方在一段时间后没有正确答复或连接的主机没有反应,连接尝试失败。 \ - (os error 10060); restarting"; - assert_eq!( - expected_error_kind(body), - Some(ExpectedErrorKind::ChannelSupervisorRestart) - ); -} - -#[test] -fn channel_supervisor_restart_matches_multiple_channel_names() { - // The wrapper format is `"Channel error: ; restarting"`. - // The name slot varies by provider (discord, slack, telegram, - // whatsapp, gmessages, …). The matcher must classify all of them — - // language-agnostic, name-agnostic. - for raw in [ - "Channel slack error: gateway disconnect; restarting", - "Channel telegram error: tls handshake eof; restarting", - "Channel whatsapp error: connection reset by peer (os error 54); restarting", - "Channel gmessages error: WebSocket connect: HTTP error: 502 Bad Gateway; restarting", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ChannelSupervisorRestart), - "should classify as channel-supervisor-restart: {raw}" - ); - } -} - -#[test] -fn channel_supervisor_restart_precedence_over_network_unreachable() { - // Pin the precedence: a supervisor-wrap body that ALSO contains - // the canonical `"error sending request for url"` anchor (which - // would by itself classify as `NetworkUnreachable`) MUST route - // to `ChannelSupervisorRestart`. The supervisor's own backoff - // handles the condition; `NetworkUnreachable` would demote to - // `warn!` (still a Sentry event), whereas - // `ChannelSupervisorRestart` demotes to `info!` (no event). - let body = "Channel discord error: error sending request for url \ - (https://discord.com/api/v10/gateway/bot); restarting"; - let kind = expected_error_kind(body); - assert_eq!(kind, Some(ExpectedErrorKind::ChannelSupervisorRestart)); - assert_ne!(kind, Some(ExpectedErrorKind::NetworkUnreachable)); -} - -#[test] -fn channel_supervisor_restart_does_not_classify_unrelated_restart_notes() { - // Defense against the matcher being too eager: bodies that - // contain `"; restarting"` but NOT the `"Channel error:"` - // preamble must NOT classify — those are generic restart logs - // from other subsystems where Sentry signal may still be - // actionable. The matcher requires all three anchors together - // (`"channel "` prefix + `" error:"` separator + `"; restarting"` - // trailer). - for raw in [ - // No `Channel ` preamble. - "systemd: docker.service; restarting", - // No `Channel ` preamble even though `; restarting` - // appears. - "Connection refused; restarting", - // The string `channel` appears but not as the leading - // `"Channel error:"` wrapper — must not classify. - "channels::runtime::dispatch failed: error: provider exhausted; restarting", - // The wrapper prefix is present but the trailer is not — - // a half-formed log line must not classify. - "Channel discord error: gateway disconnect", - ] { - assert_ne!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ChannelSupervisorRestart), - "must NOT classify as channel-supervisor-restart: {raw}" - ); - } -} - -#[test] -fn report_error_or_expected_routes_channel_supervisor_restart_through_expected_path() { - // Smoke test: the verbatim TAURI-RUST-15 Sentry body flows through - // `report_error_or_expected` without panicking. The classifier - // routes it to `report_expected_message` (info breadcrumb) instead - // of `report_error_message` (`sentry::capture_message` at error - // level). We can't observe the Sentry hub from this test, but - // exercising the call path catches any future regression that - // re-introduces a panic or mis-types the arm. - report_error_or_expected( - "Channel discord error: error sending request for url \ - (https://discord.com/api/v10/gateway/bot); restarting", - "channels", - "supervised_listener", - &[("channel", "discord")], - ); -} diff --git a/src/main.rs b/src/main.rs index 10fd69eca9..352f710a8a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,21 +66,6 @@ fn main() { if openhuman_core::core::observability::is_budget_event(&event) { return None; } - // CORE-RUST-EK (~827 events): drop all HTTP 401 responses from the - // embeddings call path (domain=embeddings, failure=non_2xx, - // status=401). The primary suppression for the OpenHuman-backend - // "Invalid token" shape lives in `expected_error_kind` / - // `is_session_expired_message`. This is defense-in-depth that also - // catches third-party provider 401s (e.g. OpenAI `invalid_api_key` - // body) that don't carry the OpenHuman envelope and therefore fall - // through the string-based classifier to Sentry. - if openhuman_core::core::observability::is_embeddings_api_key_401_event(&event) { - log::debug!( - "[sentry-embeddings-401-filter] dropping embeddings api-key 401 event_id={:?}", - event.event_id - ); - return None; - } // Defense-in-depth: drop max-tool-iterations cap events that // slipped past the call-site filters in // `agent::harness::session::runtime::run_single`, @@ -98,43 +83,6 @@ fn main() { { return None; } - // Defense-in-depth: upstream rate-limit events that slipped past - // the call-site suppressors in `ops::api_error` (primary guard) - // and `report_error_or_expected` (secondary guard via - // `expected_error_kind`). Catches the three major shapes: - // · `rate_limit_error` type in the JSON body (OPENHUMAN-TAURI-2E, - // OPENHUMAN-TAURI-RQ — ~2 223 events combined) - // · `"upstream rate limit exceeded"` in a 500 body (TAURI-6Y — - // ~19 849 events) - // · `"429 rate limit exceeded"` in a 500 body (TAURI-S — ~6 984 - // events) - // The primary per-attempt suppression lives in - // `openhuman::inference::provider::ops::api_error` (skips - // `report_error` entirely for rate-limit bodies) and in - // `embeddings::openai::embed` (uses `report_error_or_expected` with - // the canonical `"Embedding API error ({status}): …"` format so - // `is_transient_upstream_http_message` catches it). This filter is - // the last line of defense for any future call site that adds a new - // report path without routing through one of those two guards. - { - let direct = event.message.as_deref(); - let from_logentry = event.logentry.as_ref().map(|l| l.message.as_str()); - let from_exception = event.exception.last().and_then(|e| e.value.as_deref()); - let is_rate_limited = [direct, from_logentry, from_exception] - .into_iter() - .flatten() - .map(str::to_ascii_lowercase) - .any(|lower| { - openhuman_core::core::observability::is_upstream_rate_limit_message(&lower) - }); - if is_rate_limited { - log::debug!( - "[sentry-rate-limit-filter] dropping upstream rate-limit event_id={:?}", - event.event_id - ); - return None; - } - } // Defense-in-depth: 404 on PATCH/DELETE to a channel-message path // is an expected state (provider-side delete or backend GC). Primary // suppression lives in `authed_json`; this catches any future call diff --git a/src/openhuman/inference/ops.rs b/src/openhuman/inference/ops.rs index 035ad1f16b..14084b43bb 100644 --- a/src/openhuman/inference/ops.rs +++ b/src/openhuman/inference/ops.rs @@ -29,31 +29,6 @@ fn is_unknown_provider_user_config(err: &str) -> bool { err.contains("no cloud provider with id or slug") } -/// Returns `true` when the error from a provider chat attempt is a known, -/// expected user-state or provider-state condition that already has its own -/// Sentry report (or is deterministically expected and has no remediation -/// path): -/// -/// - **401 Unauthorized** — API key revoked / wrong key. Already reported by -/// the provider layer's `api_error` path. An ops-level duplicate adds noise -/// with no additional context. -/// - **429 Too Many Requests / rate-limit** — Quota exhaustion. Already -/// covered by the `is_upstream_rate_limit_message` classifier in -/// `expected_error_kind`; the reliable-provider layer retries with -/// backoff before propagating. -/// - **Model not found** — User selected a model that doesn't exist for -/// their key. The provider layer already classifies this as a config -/// rejection (TAURI-RUST-68, ~1309 events). -/// -/// The matcher is intentionally broad so the ops-level wrapper stays out -/// of the Sentry funnel for all provider-state failures — the underlying -/// call site (`compatible.rs` / `report_error_or_expected`) is already -/// responsible for the authoritative report. Unclassified failures (5xx, -/// unexpected payloads, network errors) are NOT matched and still escalate. -fn is_expected_chat_failure(err: &str) -> bool { - crate::core::observability::expected_error_kind(err).is_some() -} - #[derive(Debug, Clone, serde::Serialize)] pub struct InferenceTestProviderModelResult { pub reply: String, @@ -204,22 +179,12 @@ pub async fn inference_test_provider_model( output_len = outcome.value.reply.len(), "{LOG_PREFIX} test_provider_model:ok" ), - Err(err) => { - if is_expected_chat_failure(err) { - // Provider-state / user-config failure (401, 429, model not - // found, API key missing, etc.). The underlying provider - // layer already emitted its own Sentry event or classified - // this as expected. An ops-level duplicate adds noise. - // Targets TAURI-RUST-68 (~1,309 events). - warn!( - provider, - error = %err, - "{LOG_PREFIX} test_provider_model:expected-error (no Sentry)" - ); - } else { - error!(error = %err, "{LOG_PREFIX} test_provider_model:error"); - } - } + Err(err) => error!( + workload, + provider, + error = %err, + "{LOG_PREFIX} test_provider_model:error" + ), } result } diff --git a/src/openhuman/inference/ops_tests.rs b/src/openhuman/inference/ops_tests.rs index 38b315b545..a4d1870d9d 100644 --- a/src/openhuman/inference/ops_tests.rs +++ b/src/openhuman/inference/ops_tests.rs @@ -317,74 +317,3 @@ fn is_unknown_provider_user_config_rejects_other_list_models_failures() { ); } } - -// ── is_expected_chat_failure (TAURI-RUST-68) ───────────────────────────── -// -// `inference_test_provider_model` calls `simple_chat` which can fail with -// known provider-state or user-config conditions (401, 429, model not -// found). Before this fix every failure escalated to `error!`, which -// sentry-tracing shipped to Sentry as `"[inference::ops] -// test_provider_model:error"` — 1,309 events — while the same underlying -// errors already had their own report or were classified as expected by -// `expected_error_kind`. The gate demotes them to `warn!` so they stay in -// local logs but don't generate duplicate Sentry noise. -// -// Anchored on the shared `expected_error_kind` classifier so both the unit -// tests here and the production gate stay in sync with the central -// suppression logic in `core::observability`. - -#[test] -fn is_expected_chat_failure_matches_api_key_missing() { - // Provider layer emits this phrase when no API key is configured. - assert!(is_expected_chat_failure("api key not set for openai")); - assert!(is_expected_chat_failure( - "missing api key: openai_api_key is not configured" - )); -} - -#[test] -fn is_expected_chat_failure_matches_rate_limit() { - // 429-style rate-limit phrases emitted by the provider / OpenHuman backend. - assert!(is_expected_chat_failure( - "openai API error (429 Too Many Requests): You exceeded your current quota" - )); - assert!(is_expected_chat_failure( - "openai API error (500): 429 rate limit exceeded" - )); -} - -#[test] -fn is_expected_chat_failure_matches_provider_config_rejection() { - // OpenAI-style model-not-found code in error body. - assert!(is_expected_chat_failure( - r#"custom_openai API error (404 Not Found): {"error":{"message":"The model does not exist or you do not have access","code":"model_not_found"}}"# - )); - // Temperature-unsupported model (e.g. o1/o3/o4 reasoning models). - assert!(is_expected_chat_failure( - "custom_openai API error (400 Bad Request): invalid temperature: only 1 is allowed" - )); - // litellm-style not_found_error envelope. - assert!(is_expected_chat_failure( - r#"custom_openai API error (404 Not Found): {"error":{"message":"model 'gpt-99' not found","type":"not_found_error"}}"# - )); -} - -#[test] -fn is_expected_chat_failure_does_not_match_real_errors() { - // Real errors that must still reach Sentry must NOT be demoted. - for raw in [ - // Genuine 500 server error — actionable, must escalate - "openai API error (500 Internal Server Error): Something went wrong", - // Unexpected JSON from provider — potential provider bug - "openai API returned an unexpected chat-completions payload: missing field", - // Local I/O error — real infrastructure problem - "failed to open config file: permission denied", - // Completely empty string — fallthrough - "", - ] { - assert!( - !is_expected_chat_failure(raw), - "must NOT demote real error: {raw:?}" - ); - } -} diff --git a/src/openhuman/inference/provider/config_rejection.rs b/src/openhuman/inference/provider/config_rejection.rs index fd36dfcb02..9f2aa689e1 100644 --- a/src/openhuman/inference/provider/config_rejection.rs +++ b/src/openhuman/inference/provider/config_rejection.rs @@ -32,15 +32,6 @@ //! from a user OAuth/scope gap) //! - `"not_found_error"` (J2 / J5 / J4 — litellm-compatible envelope //! `type` field carrying "model 'X' not found") -//! - `"does not support tools"` / `"function calling is not supported"` / -//! `"unknown parameter: tools"` / `"unrecognized field \`tools\`"` / -//! `"unsupported parameter: tools"` (TAURI-RUST-4K7 — Ollama models such -//! as `gemma3:1b-it-qat` and `huihui_ai/deepseek-r1-abliterated:8b` -//! reject tool-enabled requests with HTTP 400. The compatible provider -//! already retries without tools, so the initial 400 is not a -//! bug — it's expected discovery of the model's capability boundary. -//! Sentry noise suppressed here; the retry path in `compatible.rs` runs -//! unchanged.) //! //! These are **deterministic user-configuration state**, not bugs the //! maintainers can act on: the user pointed OpenHuman at a custom @@ -171,54 +162,33 @@ pub fn is_provider_config_rejection_message(body: &str) -> bool { // this is the `type` field used by litellm/Anthropic-style // envelopes for the same class of user-state error. "not_found_error", - // TAURI-RUST-4NM — nvidia-nim (and some other providers) return - // `{"error":{"message":"model field is required","type":"invalid_request_error","code":"missing_required_field"}}` - // when the `model` key is absent or empty in the request body. - // This is a user-configuration error (provider string has no model - // component, e.g. `nvidia-nim:` with empty model), not a product - // regression. Demote from Sentry; the factory now validates this - // up-front so in practice this phrase should no longer appear. - "model field is required", - // TAURI-RUST-4XK — Ollama 403 when the requested model requires a - // paid Ollama subscription. Body carries the upgrade URL. User must - // switch to a free model or upgrade their Ollama account. - "requires a subscription", - // TAURI-RUST-2G / TAURI-RUST-2F — DeepSeek / compatible providers - // that use extended thinking reject tool-call turns when the - // `reasoning_content` block from a prior assistant turn is not - // threaded back. This is user-config state (model requires the - // caller to replay the thinking block; the frontend replay logic in - // `turn.rs` handles it for subsequent turns, so the first-turn 400 - // is expected capability-discovery, not a regression). - "in the thinking mode must be passed back", - // TAURI-RUST-35 / TAURI-RUST-4K7 / TAURI-RUST-4Z0 — Ollama models - // (e.g. gemma3, phi3, deepseek-r1) that do not support function - // calling return HTTP 400 with this phrase. The compatible provider - // retries without tools on 400, so the initial rejection is expected - // capability-discovery. Sentry noise suppressed here. - "does not support tools", - // TAURI-RUST-4K7-d — alternative phrasing used by some Ollama model - // versions for the same tool-unsupported condition. - "function calling is not supported", - // TAURI-RUST-4K7-e — litellm / OpenAI-compatible proxies reject the - // `tools` key in the request body when the backing model does not - // support tool use (e.g. local Ollama via LiteLLM gateway). - "unknown parameter: tools", - // TAURI-RUST-4K7-f — Ollama native API surface rejects the field - // outright when the model has no function-calling capability. - "unrecognized field `tools`", - // TAURI-RUST-4K7-g — another litellm / proxy variant of the same - // tool-unsupported condition. - "unsupported parameter: tools", // TAURI-RUST-4NM — nvidia-nim (and compatible providers) return // `{"error":{"message":"model field is required","code":"missing_required_field"}}` // when the request body contains an empty `"model":""` field. "model field is required", // TAURI-RUST-2G (~2684 events) / TAURI-RUST-2F (~950 events) — - // thinking-mode model rejects a follow-up turn that doesn't echo - // the prior assistant's `reasoning_content` field. + // thinking-mode model (DeepSeek-R1 / Moonshot K2-thinking on + // `provider=cloud` custom_openai) rejects a follow-up turn that + // doesn't echo the prior assistant's `reasoning_content` field. + // Body shape (backtick-quoted JSON literal in the upstream body): + // `{"error":{"message":"The `reasoning_content` in the thinking + // mode must be passed back to the API.",...}}`. The + // provider-contract gap is on our side, but until the thinking- + // mode round-tripping ships in the inference layer, every affected + // turn fires a fresh Sentry event — and the UI already surfaces + // the actionable error to the user. Anchor on the unique + // `thinking mode must be passed back` substring so the match + // doesn't depend on the upstream's backtick-quoting around + // `reasoning_content` (some provider versions ship without them). "thinking mode must be passed back", // TAURI-RUST-4XK (~649 events) — Ollama Cloud subscription gate. + // Body: `{"error":"this model requires a subscription, upgrade for + // access: https://ollama.com/upgrade (ref: )"}` on a 403 + // Forbidden from `compatible::OpenAiCompatibleProvider` with + // `name = "ollama"`. User-state: the model picked in Settings is + // a paid-tier Ollama Cloud model the user's account doesn't + // cover. The UI surfaces an actionable upgrade link in the + // remediation message itself. "requires a subscription, upgrade for access", // TAURI-RUST-1V / OPENHUMAN-TAURI-4JS — // `reliable.rs::format_failure_aggregate` (no-configured-fallbacks @@ -426,103 +396,6 @@ mod tests { } } - /// TAURI-RUST-35 family — model picked by the user doesn't implement - /// tool calling. The agent harness tries to send a tool spec and the - /// upstream (Ollama / cloud Ollama relay / hosted OpenAI-compatible) - /// rejects with `{"error":{"message":" does not support tools", - /// "type":"invalid_request_error",...}}`. Pure user-config — the user - /// has to pick a tool-capable model (or run a non-agent flow). No - /// remediation path through Sentry, and the long tail is large: each - /// distinct model id + provider prefix combo creates a new Sentry - /// fingerprint, so the same root cause is currently split across at - /// least 10 unresolved Sentry issues (458 events total as of - /// 2026-05-28): - /// - /// | shortId | events | provider prefix | - /// |---|---|---| - /// | TAURI-RUST-35 | 307 | cloud | - /// | TAURI-RUST-DF | 83 | cloud | - /// | TAURI-RUST-123 | 25 | cloud | - /// | TAURI-RUST-4K7 | 19 | ollama | - /// | TAURI-RUST-4FS | 10 | cloud | - /// | TAURI-RUST-4F6 | 5 | cloud | - /// | TAURI-RUST-2YA | 4 | cloud | - /// | TAURI-RUST-4KR | 3 | ollama | - /// | TAURI-RUST-4KH | 1 | cloud | - /// | TAURI-RUST-4KY | 1 | ollama | - /// - /// Anchored on the exact `"does not support tools"` substring (the - /// message body's stable token — the model id varies per user). The - /// `streaming API error` / `API error` wrappers and the - /// `cloud` / `ollama` / `custom_openai` provider prefixes all share - /// this body, so a single phrase covers every variant. - #[test] - fn detects_does_not_support_tools_family() { - for (sentry_id, body) in [ - // TAURI-RUST-35 — verbatim from latest issue 168 event - // (model=`gemma3:1b-it-qat`, provider=cloud). - ( - "35", - r#"cloud streaming API error (400 Bad Request): {"error":{"message":"registry.ollama.ai/library/gemma3:1b-it-qat does not support tools","type":"invalid_request_error","param":null,"code":null}}"#, - ), - // TAURI-RUST-4K7 — ollama prefix, different upstream wrapper. - ( - "4K7", - r#"ollama streaming API error (400 Bad Request): {"error":{"message":"some-local-model does not support tools","type":"invalid_request_error","param":null,"code":null}}"#, - ), - // Non-streaming sibling — `API error` (no `streaming` token) - // for hosted providers that aren't using the streaming endpoint. - ( - "non-streaming", - r#"cloud API error (400 Bad Request): {"error":{"message":"registry.ollama.ai/library/qwen2.5:0.5b does not support tools","type":"invalid_request_error"}}"#, - ), - // Bare body (no wrapper) — what `expected_error_kind` would - // see if the body got extracted from the envelope upstream. - ( - "bare", - r#"{"error":{"message":"phi3.5:mini does not support tools","type":"invalid_request_error"}}"#, - ), - // TAURI-RUST-4Z0 — verbatim from issue 5664 (model=`deepseek-r1:8b`, - // provider=ollama). The envelope carries `"type":"api_error"` - // rather than `"invalid_request_error"` — pin it so the matcher - // can never be narrowed to require a specific `type` token; the - // `"does not support tools"` body substring is the only anchor. - ( - "4Z0", - r#"ollama streaming API error (400 Bad Request): {"error":{"message":"registry.ollama.ai/library/deepseek-r1:8b does not support tools","type":"api_error","param":null,"code":null}}"#, - ), - ] { - assert!( - is_provider_config_rejection_message(body), - "TAURI-RUST-{sentry_id} body must classify as provider config-rejection: {body:?}" - ); - } - } - - /// Polarity guard for the does-not-support-tools arm. The phrase is - /// scoped enough that no real bug-class body should accidentally - /// match — but pin a few near-miss shapes so a future loosening of - /// the matcher can't silently re-classify them. - #[test] - fn does_not_classify_unrelated_tools_phrases_as_config_rejection() { - for body in [ - // Tool-call dispatch failure (real bug) — must reach Sentry. - "tool execution failed: shell returned exit 1", - // Generic "tools" mention without the does-not-support phrase. - "agent ran with 0 tools available", - // Reversed phrasing — provider says they DO support tools but - // the call shape is wrong. Still actionable for triage. - "supports tools but received malformed tool_calls array", - // Empty body. - "", - ] { - assert!( - !is_provider_config_rejection_message(body), - "{body:?} must NOT classify as a provider config-rejection" - ); - } - } - #[test] fn detects_reliable_aggregate_no_fallbacks_envelope() { // OPENHUMAN-TAURI-4JS — `reliable::format_failure_aggregate` @@ -724,64 +597,4 @@ mod tests { ); } } - - /// TAURI-RUST-4K7 — Ollama models that don't support tool calling - /// (e.g. `gemma3:1b-it-qat`, `huihui_ai/deepseek-r1-abliterated:8b`) - /// return HTTP 400 with one of several tool-rejection phrases. - /// The compatible provider retries without tools, so the 400 is expected - /// capability-discovery rather than a product bug. These phrases must be - /// classified as config-rejections so Sentry is not flooded on every turn. - #[test] - fn detects_ollama_tool_unsupported_bodies() { - for (sentry_id, body) in [ - ( - "4K7-a", - r#"{"error":"gemma3:1b-it-qat does not support tools"}"#, - ), - ( - "4K7-b", - r#"{"error":"huihui_ai/deepseek-r1-abliterated:8b does not support tools"}"#, - ), - ( - "4K7-c", - r#"ollama streaming API error (400 Bad Request): {"error":"phi3:mini does not support tools"}"#, - ), - ( - "4K7-d", - r#"{"error":"function calling is not supported by this model"}"#, - ), - ( - "4K7-e", - r#"{"error":{"message":"unknown parameter: tools","type":"invalid_request_error"}}"#, - ), - ( - "4K7-f", - r#"{"error":"unrecognized field `tools` in request body"}"#, - ), - ( - "4K7-g", - r#"{"error":{"message":"unsupported parameter: tools","type":"invalid_request_error"}}"#, - ), - ] { - assert!( - is_provider_config_rejection_message(body), - "TAURI-RUST-{sentry_id} body must classify as provider config-rejection (tool-unsupported): {body:?}" - ); - } - } - - #[test] - fn detects_ollama_tool_unsupported_bodies_case_insensitive() { - // Ollama error messages should match regardless of casing. - for body in [ - "Model 'gemma3:1b-it-qat' DOES NOT SUPPORT TOOLS", - "Function Calling Is Not Supported By This Model", - "Unknown Parameter: Tools", - ] { - assert!( - is_provider_config_rejection_message(body), - "{body:?} must classify as config-rejection regardless of case" - ); - } - } } diff --git a/src/openhuman/inference/provider/ops.rs b/src/openhuman/inference/provider/ops.rs index 441bf37e22..aa11eeef13 100644 --- a/src/openhuman/inference/provider/ops.rs +++ b/src/openhuman/inference/provider/ops.rs @@ -144,22 +144,6 @@ async fn list_configured_models_from_config( let status = response.status(); if !status.is_success() { - // A 404 from the /models endpoint means the provider does not support model - // listing — this is expected for many OpenAI-compatible providers (e.g. DeepSeek, - // Moonshot, Kimi, custom proxies). Return an empty model list so the caller can - // proceed normally instead of surfacing a spurious error / Sentry event. - // (Sentry issue TAURI-RUST-1Z — 819 events from this path alone.) - if status == reqwest::StatusCode::NOT_FOUND { - log::debug!( - "[providers][list_models] slug={} returned 404 — provider does not support /models listing; returning empty list", - entry.slug - ); - return Ok(crate::rpc::RpcOutcome::new( - serde_json::json!({ "models": serde_json::Value::Array(vec![]), "unsupported": true }), - vec!["provider does not support model listing (404)".to_string()], - )); - } - let body = response.text().await.unwrap_or_default(); let sanitized = sanitize_api_error(&body); let truncated = crate::openhuman::util::truncate_with_ellipsis(&sanitized, 300); @@ -985,38 +969,16 @@ pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::E } else if is_context_window_exceeded { log_context_window_exceeded("api_error", provider, None, status); } else if should_report_provider_http_failure(status) { - // Defense-in-depth: some backends (e.g. OpenHuman) wrap an upstream - // provider 429 as an HTTP 500 with a rate-limit phrase in the body - // (`"429 rate limit exceeded"`, `"upstream rate limit exceeded"`). - // `should_report_provider_http_failure(500)` would otherwise let this - // through to Sentry — suppress it here before the report fires so the - // noise stays off Sentry (OPENHUMAN-TAURI-S: ~6 984 events). - // The `expected_error_kind` classifier in `report_error_or_expected` - // catches the same shape at re-report sites (agent / web_channel). - let lower_body = body.to_ascii_lowercase(); - let is_rate_limit_body = - crate::core::observability::is_upstream_rate_limit_message(&lower_body); - if is_rate_limit_body { - tracing::warn!( - domain = "llm_provider", - operation = "api_error", - provider = provider, - status = status_str.as_str(), - "[llm_provider] api_error: skipping Sentry report — rate-limit body in \ - non-429 response ({status})" - ); - } else { - crate::core::observability::report_error( - message.as_str(), - "llm_provider", - "api_error", - &[ - ("provider", provider), - ("status", status_str.as_str()), - ("failure", "non_2xx"), - ], - ); - } + crate::core::observability::report_error( + message.as_str(), + "llm_provider", + "api_error", + &[ + ("provider", provider), + ("status", status_str.as_str()), + ("failure", "non_2xx"), + ], + ); } anyhow::anyhow!(message) } diff --git a/src/openhuman/inference/provider/ops_tests.rs b/src/openhuman/inference/provider/ops_tests.rs index c3f5dd9851..0e3d2b1166 100644 --- a/src/openhuman/inference/provider/ops_tests.rs +++ b/src/openhuman/inference/provider/ops_tests.rs @@ -288,69 +288,6 @@ async fn openrouter_key_is_trimmed_for_validation_and_catalog_probe() { ); } -/// Spawn a minimal axum server that always returns 404 for the /models endpoint. -/// Used to verify that providers without model listing return an empty list, -/// not an error (Sentry issue TAURI-RUST-1Z). -async fn spawn_models_404_server() -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind"); - let addr = listener.local_addr().expect("local_addr"); - let app = axum::Router::new().route( - "/models", - axum::routing::get(|| async { - (axum::http::StatusCode::NOT_FOUND, "Not Found").into_response() - }), - ); - tokio::spawn(async move { - axum::serve(listener, app).await.expect("serve"); - }); - format!("http://{addr}") -} - -#[tokio::test] -async fn models_404_returns_empty_list_not_error() { - // Providers that return 404 on /models (e.g. DeepSeek, Kimi, custom proxies) - // must yield an empty model list, not an Err. Returning an Err was firing a - // Sentry error for every `inference_list_models` call (TAURI-RUST-1Z, 819 events). - let tmp = tempfile::tempdir().expect("tempdir"); - let endpoint = spawn_models_404_server().await; - - let mut config = Config { - config_path: tmp.path().join("config.toml"), - workspace_dir: tmp.path().join("workspace"), - action_dir: tmp.path().join("workspace"), - ..Config::default() - }; - config.secrets.encrypt = false; - config.cloud_providers.push(CloudProviderCreds { - id: "p_custom_test".to_string(), - slug: "custom-no-models".to_string(), - label: "Custom (no /models)".to_string(), - endpoint, - auth_style: AuthStyle::Bearer, - legacy_type: None, - default_model: None, - }); - - let outcome = list_configured_models_from_config("custom-no-models", &config) - .await - .expect("404 from /models must succeed with an empty list"); - - let models = outcome.value["models"] - .as_array() - .expect("response must have a `models` array"); - assert!( - models.is_empty(), - "expected empty model list for a 404 /models endpoint, got: {models:?}" - ); - assert_eq!( - outcome.value["unsupported"], - serde_json::Value::Bool(true), - "unsupported flag must be set to true for 404 providers" - ); -} - #[test] fn factory_backend() { assert!(create_backend_inference_provider( @@ -455,61 +392,6 @@ mod budget_exhausted_suppression { } } -// Tests for the rate-limit body suppression guard added to `api_error`. -// Exercises `is_upstream_rate_limit_message` with the exact body shapes that -// produced OPENHUMAN-TAURI-S (~6 984 events from HTTP 500 wrapping a -// "429 rate limit exceeded" body) and OPENHUMAN-TAURI-6Y / -2E. -mod rate_limit_body_suppression { - use crate::core::observability::is_upstream_rate_limit_message; - - /// HTTP 500 with a `"429 rate limit exceeded"` body must be detected - /// as a rate-limit signal so the guard in `api_error` can skip the - /// Sentry report (OPENHUMAN-TAURI-S). - #[test] - fn http_500_with_429_body_phrase_is_rate_limited() { - let body = r#"{"success":false,"error":"429 rate limit exceeded, please try again later"}"# - .to_ascii_lowercase(); - assert!( - is_upstream_rate_limit_message(&body), - "500-body with '429 rate limit exceeded' must be detected as rate-limited" - ); - } - - /// HTTP 500 with an `"upstream rate limit exceeded"` body - /// (OPENHUMAN-TAURI-6Y shape). - #[test] - fn http_500_with_upstream_rate_limit_body_is_rate_limited() { - let body = r#"{"success":false,"error":"Upstream rate limit exceeded for model 'summarization-v1'. Please retry shortly.","details":{"provider":"gmi"}}"# - .to_ascii_lowercase(); - assert!( - is_upstream_rate_limit_message(&body), - "500-body with 'upstream rate limit exceeded' must be detected" - ); - } - - /// OpenAI / Anthropic `"rate_limit_error"` type body. - #[test] - fn body_with_rate_limit_error_type_is_rate_limited() { - let body = r#"{"error":{"message":"Rate limit exceeded. Please retry after a brief wait.","type":"rate_limit_error"}}"# - .to_ascii_lowercase(); - assert!( - is_upstream_rate_limit_message(&body), - "body with 'rate_limit_error' type must be detected" - ); - } - - /// Unrelated 500 body must NOT be detected as rate-limited. - #[test] - fn http_500_unrelated_body_is_not_rate_limited() { - let body = r#"{"success":false,"error":"internal server error: database unavailable"}"# - .to_ascii_lowercase(); - assert!( - !is_upstream_rate_limit_message(&body), - "unrelated 500 body must not be detected as rate-limited" - ); - } -} - mod provider_access_policy_suppression { use super::*; diff --git a/src/openhuman/memory_store/factories.rs b/src/openhuman/memory_store/factories.rs index 5b745df97d..8e5f056199 100644 --- a/src/openhuman/memory_store/factories.rs +++ b/src/openhuman/memory_store/factories.rs @@ -51,13 +51,13 @@ fn report_ollama_health_gate_once(base_url: &str, model: &str) -> bool { let sentry_message = format!( "ollama embeddings opted-in but daemon unreachable at {base_url}; falling back to cloud embeddings for this session" ); - // Route through `report_error_or_expected` so the `expected_error_kind` - // classifier runs. The wire shape `"ollama embeddings opted-in but daemon - // unreachable at …"` matches `is_ollama_user_config_rejection` → routes to - // `ExpectedErrorKind::ProviderUserState` → demoted to a warn breadcrumb, - // NOT a Sentry error event. Using `report_error_message` directly would - // bypass the classifier and fire `sentry::capture_message(…, Level::Error)` - // unconditionally — the root cause of TAURI-RUST-B (472 events). + // Route through `report_error_or_expected` so the GX arm of + // `is_ollama_user_config_rejection` in `expected_error_kind` demotes + // the message to an info breadcrumb (user-state: ollama daemon not + // running). Direct `report_error_message` here bypassed the classifier + // and produced TAURI-RUST-B (~409 events). The `&str` input avoids + // the `format!("{:#}")` round-trip that `report_error` would do on an + // anyhow chain — the wire shape stays bit-identical. crate::core::observability::report_error_or_expected( sentry_message.as_str(), "memory", @@ -766,25 +766,4 @@ mod tests { "different URL also suppressed — gate is process-scoped, not per-URL" ); } - - /// TAURI-RUST-B (issue #2921): the exact wire shape produced by - /// `report_ollama_health_gate_once` must classify as - /// `ExpectedErrorKind::ProviderUserState` so `report_error_or_expected` - /// routes it to a warn breadcrumb rather than a Sentry error event. - /// - /// Previously the gate called `report_error_message` directly, bypassing - /// the classifier and firing `sentry::capture_message(…, Level::Error)` - /// unconditionally for every process restart where Ollama was opted-in but - /// not running (472 events at time of fix). The fix routes through - /// `report_error_or_expected` which checks `expected_error_kind` first. - #[test] - fn tauri_rust_b_wire_shape_classifies_as_expected() { - // Canonical format produced by `report_ollama_health_gate_once`. - let msg = "ollama embeddings opted-in but daemon unreachable at http://localhost:11434; falling back to cloud embeddings for this session"; - assert_eq!( - crate::core::observability::expected_error_kind(msg), - Some(crate::core::observability::ExpectedErrorKind::ProviderUserState), - "TAURI-RUST-B — daemon-unreachable health-gate message must demote to ProviderUserState, not fire as Sentry error" - ); - } } diff --git a/src/openhuman/subconscious/store.rs b/src/openhuman/subconscious/store.rs index 0e2a850ae1..23b3df9c5b 100644 --- a/src/openhuman/subconscious/store.rs +++ b/src/openhuman/subconscious/store.rs @@ -2,17 +2,6 @@ //! //! Follows the cron module's `with_connection` pattern: opens the database, //! runs DDL on every connection, and provides pure functions. -//! -//! ## Init-failure noise suppression (TAURI-RUST-A) -//! -//! `with_connection` runs the schema DDL on every call. Transient -//! `SQLITE_BUSY` / `SQLITE_LOCKED` errors (e.g. concurrent in-process RPC -//! calls, antivirus hold, network-drive WAL rejection) are handled by a -//! per-connection busy timeout (5 s) plus an application-level retry loop -//! (3 retries, 100 / 300 / 900 ms backoff). Only `DatabaseBusy` / -//! `DatabaseLocked` errors are retried — schema or corruption errors fail -//! through immediately so Sentry captures a real root cause rather than a -//! transient noise event. use anyhow::{Context, Result}; use rusqlite::{Connection, OptionalExtension}; @@ -72,8 +61,9 @@ pub fn with_connection( let db_path = workspace_dir.join("subconscious").join("subconscious.db"); if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent) - .with_context(|| format!("create subconscious dir: {}", parent.display()))?; + .with_context(|| format!("failed to create subconscious dir: {}", parent.display()))?; } + let conn = open_and_initialize_with_retry(&db_path)?; f(&conn) } diff --git a/tests/inference_agent_raw_coverage_e2e.rs b/tests/inference_agent_raw_coverage_e2e.rs index 5a7744b02f..18216ee938 100644 --- a/tests/inference_agent_raw_coverage_e2e.rs +++ b/tests/inference_agent_raw_coverage_e2e.rs @@ -1971,7 +1971,6 @@ async fn inference_provider_factory_and_classifiers_cover_user_state_edges() { "The supported API model names are native-a or native-b", "ModelNotAllowed", "invalid_authentication_error", - "unknown parameter: tools", "requires a subscription, upgrade for access", "No active credentials for provider: openai", ] { @@ -1983,6 +1982,12 @@ async fn inference_provider_factory_and_classifiers_cover_user_state_edges() { assert!(is_openai_compatible_unknown_model_message( "Model `gpt-unknown` is not available. Use GET /openai/v1/models to list available models." )); + // PR #2959 reverted the "unknown parameter: tools" suppression: this shape + // is no longer demoted to user-config state, so it fires to Sentry again + // (root cause to be fixed separately). + assert!(!is_provider_config_rejection_message( + "unknown parameter: tools" + )); assert!(!is_provider_config_rejection_message( "internal server error while streaming tokens" )); diff --git a/tests/inference_compatible_admin_leftovers_raw_coverage_e2e.rs b/tests/inference_compatible_admin_leftovers_raw_coverage_e2e.rs index fe32e7143f..3d3202f254 100644 --- a/tests/inference_compatible_admin_leftovers_raw_coverage_e2e.rs +++ b/tests/inference_compatible_admin_leftovers_raw_coverage_e2e.rs @@ -340,11 +340,17 @@ async fn provider_ops_leftovers_cover_model_listing_error_shapes_and_auth_styles .expect_err("unknown provider id"); assert!(unknown.contains("no cloud provider")); - let unsupported = list_configured_models("not-found") + // PR #2959 reverted the list_models 404 suppression: a 404 from the + // /models endpoint no longer returns a synthetic `{models: [], unsupported: + // true}` success — it surfaces as a real error so the failure fires to + // Sentry and gets a root-cause fix (e.g. a wrong base URL). + let not_found_err = list_configured_models("not-found") .await - .expect("404 models unsupported"); - assert_eq!(unsupported.value["models"].as_array().unwrap().len(), 0); - assert_eq!(unsupported.value["unsupported"], true); + .expect_err("404 list_models now surfaces as an error"); + assert!( + not_found_err.contains("provider returned 404"), + "404 list_models error should surface the status: {not_found_err:?}" + ); let missing_data = list_configured_models("missing-data") .await diff --git a/tests/inference_provider_raw_coverage_e2e.rs b/tests/inference_provider_raw_coverage_e2e.rs index bd64f8b630..511a8d7f16 100644 --- a/tests/inference_provider_raw_coverage_e2e.rs +++ b/tests/inference_provider_raw_coverage_e2e.rs @@ -310,11 +310,16 @@ async fn provider_factory_and_model_listing_cover_cloud_local_and_invalid_shapes .value; assert_eq!(local_listed["models"][0]["id"], "demo-chat"); - let unsupported = list_configured_models("missing") + // PR #2959 reverted the list_models 404 suppression: a 404 from /models + // now surfaces as a real error instead of a synthetic `unsupported: true` + // success, so the failure fires to Sentry for a root-cause fix. + let missing_err = list_configured_models("missing") .await - .expect("404 unsupported") - .value; - assert_eq!(unsupported["unsupported"], true); + .expect_err("404 list_models now surfaces as an error"); + assert!( + missing_err.contains("provider returned 404"), + "404 list_models error should surface the status: {missing_err:?}" + ); let openrouter = list_configured_models("openrouter") .await diff --git a/tests/worker_b_raw_coverage_e2e.rs b/tests/worker_b_raw_coverage_e2e.rs index 9e19b8b0f0..ce8e7440f5 100644 --- a/tests/worker_b_raw_coverage_e2e.rs +++ b/tests/worker_b_raw_coverage_e2e.rs @@ -393,18 +393,19 @@ async fn inference_provider_success_paths_use_mock_models_and_chat() { "mock model should round-trip through provider /models: {models}" ); - let unsupported = rpc( + // PR #2959 reverted the list_models 404 suppression: a 404 from /models + // now surfaces as a JSON-RPC error instead of a synthetic `unsupported: + // true` success, so the failure fires to Sentry for a root-cause fix. + let models_404 = rpc( &harness.rpc_base, 102, "openhuman.inference_list_models", json!({ "provider_id": "mock-404" }), ) .await; - assert_eq!( - payload(&unsupported, "inference_list_models 404") - .get("unsupported") - .and_then(Value::as_bool), - Some(true) + assert!( + error_message(&models_404, "inference_list_models 404").contains("provider returned 404"), + "404 list_models should surface as an error: {models_404}" ); let reply = rpc(