diff --git a/crates/openab-core/src/acp/agentcore.rs b/crates/openab-core/src/acp/agentcore.rs index f7a923e09..d0f847644 100644 --- a/crates/openab-core/src/acp/agentcore.rs +++ b/crates/openab-core/src/acp/agentcore.rs @@ -271,9 +271,12 @@ where .as_millis(); let runtime_sid = format!("oab-reconnect-{ts:020}-{ts:013x}"); match self.open_shell(&runtime_sid).await { - Ok(handle) => { self.sessions.insert(acp_sid.clone(), handle); } + Ok(handle) => { + self.sessions.insert(acp_sid.clone(), handle); + } Err(e) => { - self.write_error(id, -32000, &format!("reconnect failed: {e}")).await?; + self.write_error(id, -32000, &format!("reconnect failed: {e}")) + .await?; return Ok(()); } } @@ -281,7 +284,9 @@ where // Allocate ID before borrowing sessions let kiro_id = self.alloc_id(); - let kiro_sid = self.sessions.get(&acp_sid) + let kiro_sid = self + .sessions + .get(&acp_sid) .map(|s| s.kiro_session_id.clone()) .unwrap_or_default(); let mut fwd_params = params.clone(); @@ -425,7 +430,8 @@ where } async fn open_shell(&self, session_id: &str) -> Result { - let (request, host) = build_signed_request(&self.runtime_arn, session_id, &self.region).await?; + let (request, host) = + build_signed_request(&self.runtime_arn, session_id, &self.region).await?; // Manual TLS connection — gives us full control, avoids connect_async host override let tcp = tokio::net::TcpStream::connect(format!("{host}:443")) @@ -564,7 +570,9 @@ where Ok(Some(line)) => { // Check if this is the initialize response (has "id":0 or "id": 0) if let Ok(v) = serde_json::from_str::(&line) { - if v.get("id").and_then(|i| i.as_u64()) == Some(0) && v.get("result").is_some() { + if v.get("id").and_then(|i| i.as_u64()) == Some(0) + && v.get("result").is_some() + { info!(attempt, "agent initialized"); initialized = true; break; @@ -580,10 +588,14 @@ where } } } - if initialized { break; } + if initialized { + break; + } } if !initialized { - return Err(anyhow!("agent failed to respond to initialize after 5 attempts")); + return Err(anyhow!( + "agent failed to respond to initialize after 5 attempts" + )); } // Send session/new to kiro-cli to create a session @@ -616,7 +628,8 @@ where Ok(Some(line)) => { if let Ok(v) = serde_json::from_str::(&line) { if v.get("id").and_then(|i| i.as_u64()) == Some(1) { - sid = v.pointer("/result/sessionId") + sid = v + .pointer("/result/sessionId") .and_then(|s| s.as_str()) .unwrap_or("default") .to_string(); @@ -710,7 +723,10 @@ async fn build_signed_request( .header("connection", "Upgrade") .header("upgrade", "websocket") .header("sec-websocket-version", "13") - .header("sec-websocket-key", tokio_tungstenite::tungstenite::handshake::client::generate_key()); + .header( + "sec-websocket-key", + tokio_tungstenite::tungstenite::handshake::client::generate_key(), + ); // Add SigV4 auth headers (x-amz-date, authorization) for (name, value) in instructions.headers() { diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index 86b2ee989..7784b4e36 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -112,7 +112,12 @@ fn classify_hung( /// either resumes the session, so both are credentials. Extracted from the loop in `cleanup_idle` /// so the redaction can be exercised by a test for real — R1 redacted the sites it enumerated and /// this force-evict site was outside that list, logging both ids raw. -fn warn_force_evicting_hung(key: &str, session_id: Option<&str>, age_secs: u64, threshold_secs: u64) { +fn warn_force_evicting_hung( + key: &str, + session_id: Option<&str>, + age_secs: u64, + threshold_secs: u64, +) { warn!( thread_id = %crate::redact::redact_session_ids(key), session_id = %session_id.map(crate::redact::redact_session_ids).unwrap_or_default(), @@ -692,7 +697,12 @@ impl SessionPool { // supersedes under the same key (its guard cannot fire if that predecessor is hung). F3. #[cfg(feature = "acp-mcp")] if let Some(token) = session_token { - install_facade_token(&mut state, thread_id, token, self.session_registrar.as_ref()); + install_facade_token( + &mut state, + thread_id, + token, + self.session_registrar.as_ref(), + ); } self.save_mapping(&state.persisted); @@ -727,11 +737,12 @@ impl SessionPool { { let conn = { let state = self.state.read().await; - state - .active - .get(thread_id) - .cloned() - .ok_or_else(|| anyhow!("no connection for thread {}", crate::redact::redact_session_ids(thread_id)))? + state.active.get(thread_id).cloned().ok_or_else(|| { + anyhow!( + "no connection for thread {}", + crate::redact::redact_session_ids(thread_id) + ) + })? }; let mut conn = conn.lock().await; @@ -759,11 +770,12 @@ impl SessionPool { ) -> Result> { let conn = { let state = self.state.read().await; - state - .active - .get(thread_id) - .cloned() - .ok_or_else(|| anyhow!("no connection for thread {}", crate::redact::redact_session_ids(thread_id)))? + state.active.get(thread_id).cloned().ok_or_else(|| { + anyhow!( + "no connection for thread {}", + crate::redact::redact_session_ids(thread_id) + ) + })? }; let mut conn = conn.lock().await; conn.set_config_option(config_id, value).await @@ -775,11 +787,12 @@ impl SessionPool { pub async fn get_usage(&self, thread_id: &str) -> Result { let conn = { let state = self.state.read().await; - state - .active - .get(thread_id) - .cloned() - .ok_or_else(|| anyhow!("no connection for thread {}", crate::redact::redact_session_ids(thread_id)))? + state.active.get(thread_id).cloned().ok_or_else(|| { + anyhow!( + "no connection for thread {}", + crate::redact::redact_session_ids(thread_id) + ) + })? }; let mut conn = conn.lock().await; conn.get_usage().await @@ -794,7 +807,12 @@ impl SessionPool { .cancel_handles .get(thread_id) .cloned() - .ok_or_else(|| anyhow!("no session for thread {}", crate::redact::redact_session_ids(thread_id)))? + .ok_or_else(|| { + anyhow!( + "no session for thread {}", + crate::redact::redact_session_ids(thread_id) + ) + })? }; let data = serde_json::to_string(&serde_json::json!({ "jsonrpc": "2.0", @@ -852,7 +870,10 @@ impl SessionPool { info!(thread_id = %crate::redact::redact_session_ids(thread_id), "session reset"); Ok(()) } else { - Err(anyhow!("no session for thread {}", crate::redact::redact_session_ids(thread_id))) + Err(anyhow!( + "no session for thread {}", + crate::redact::redact_session_ids(thread_id) + )) } } @@ -1084,11 +1105,28 @@ mod tests { let mut state = empty_pool_state(); // Predecessor registers, then a successor takes over the SAME key. - super::install_facade_token(&mut state, "discord:acp_x", "T_pred".into(), Some(®istrar)); - assert!(reg.revoked().is_empty(), "nothing to revoke on the first install"); - super::install_facade_token(&mut state, "discord:acp_x", "T_succ".into(), Some(®istrar)); + super::install_facade_token( + &mut state, + "discord:acp_x", + "T_pred".into(), + Some(®istrar), + ); + assert!( + reg.revoked().is_empty(), + "nothing to revoke on the first install" + ); + super::install_facade_token( + &mut state, + "discord:acp_x", + "T_succ".into(), + Some(®istrar), + ); - assert_eq!(reg.revoked(), vec!["T_pred"], "the predecessor token must be revoked"); + assert_eq!( + reg.revoked(), + vec!["T_pred"], + "the predecessor token must be revoked" + ); assert_eq!( state.facade_tokens.get("discord:acp_x").map(String::as_str), Some("T_succ"), @@ -1105,14 +1143,25 @@ mod tests { let reg = Arc::new(CountingRegistrar::default()); let registrar: Arc = reg.clone(); let mut state = empty_pool_state(); - state.facade_tokens.insert("discord:acp_x".into(), "T_hung".into()); + state + .facade_tokens + .insert("discord:acp_x".into(), "T_hung".into()); // A different session's token must be untouched. - state.facade_tokens.insert("discord:acp_y".into(), "T_other".into()); + state + .facade_tokens + .insert("discord:acp_y".into(), "T_other".into()); super::revoke_facade_token_for_key(&mut state, "discord:acp_x", Some(®istrar)); - assert_eq!(reg.revoked(), vec!["T_hung"], "only the evicted session's token is revoked"); - assert!(!state.facade_tokens.contains_key("discord:acp_x"), "and it is forgotten"); + assert_eq!( + reg.revoked(), + vec!["T_hung"], + "only the evicted session's token is revoked" + ); + assert!( + !state.facade_tokens.contains_key("discord:acp_x"), + "and it is forgotten" + ); assert_eq!( state.facade_tokens.get("discord:acp_y").map(String::as_str), Some("T_other"), @@ -1319,11 +1368,23 @@ mod tests { }); let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap(); - assert!(out.contains("force-evicting hung session"), "the warning must fire: {out}"); + assert!( + out.contains("force-evicting hung session"), + "the warning must fire: {out}" + ); assert!(!out.contains(uuid), "no raw uuid may reach the log: {out}"); - assert!(!out.contains("acp_") && !out.contains("sess_"), "no raw id prefix either: {out}"); - assert!(out.contains('#'), "the redaction tag must be present: {out}"); - assert!(out.contains("discord"), "the readable platform half must survive: {out}"); + assert!( + !out.contains("acp_") && !out.contains("sess_"), + "no raw id prefix either: {out}" + ); + assert!( + out.contains('#'), + "the redaction tag must be present: {out}" + ); + assert!( + out.contains("discord"), + "the readable platform half must survive: {out}" + ); } #[test] diff --git a/crates/openab-core/src/acp/protocol.rs b/crates/openab-core/src/acp/protocol.rs index 456245cae..217e774ed 100644 --- a/crates/openab-core/src/acp/protocol.rs +++ b/crates/openab-core/src/acp/protocol.rs @@ -284,9 +284,15 @@ pub fn parse_turn_result(result: &Value) -> TurnResult { .and_then(|v| v.as_str()) .map(String::from); let usage = result.get("usage"); - let input_tokens = usage.and_then(|u| u.get("inputTokens")).and_then(|v| v.as_u64()); - let output_tokens = usage.and_then(|u| u.get("outputTokens")).and_then(|v| v.as_u64()); - let total_tokens = usage.and_then(|u| u.get("totalTokens")).and_then(|v| v.as_u64()); + let input_tokens = usage + .and_then(|u| u.get("inputTokens")) + .and_then(|v| v.as_u64()); + let output_tokens = usage + .and_then(|u| u.get("outputTokens")) + .and_then(|v| v.as_u64()); + let total_tokens = usage + .and_then(|u| u.get("totalTokens")) + .and_then(|v| v.as_u64()); TurnResult { stop_reason, input_tokens, @@ -335,7 +341,11 @@ pub struct UsageReport { /// Returns `None` when `success` is not true or the data shape is missing — /// callers should treat that as "usage not supported by this agent". pub fn parse_usage_report(result: &Value) -> Option { - if !result.get("success").and_then(|v| v.as_bool()).unwrap_or(false) { + if !result + .get("success") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { return None; } let data = result.get("data")?; @@ -361,9 +371,7 @@ pub fn parse_usage_report(result: &Value) -> Option { let has_limit = b .get("hasLimit") .and_then(|v| v.as_bool()) - .unwrap_or_else(|| { - b.get("limit").and_then(|v| v.as_f64()).is_some() - }); + .unwrap_or_else(|| b.get("limit").and_then(|v| v.as_f64()).is_some()); Some(UsageBreakdown { display_name: b.get("displayName")?.as_str()?.to_string(), used: b.get("used")?.as_f64()?, diff --git a/crates/openab-core/src/acp_mcp.rs b/crates/openab-core/src/acp_mcp.rs index ab82c3091..0921eae8c 100644 --- a/crates/openab-core/src/acp_mcp.rs +++ b/crates/openab-core/src/acp_mcp.rs @@ -35,7 +35,6 @@ pub trait AcpMcpTunnel: Send + Sync { params: Option, ) -> Result; - /// Resolve a declared server NAME to the `server_id` the registry keys that tunnel by, for one /// channel. /// @@ -70,10 +69,6 @@ pub trait AcpMcpTunnel: Send + Sync { fn attached_server_names(&self, channel_id: &str) -> Vec; } - - - - /// Write `value` to `path` atomically, owner-only. /// /// Same-directory temp file created `0600` *before* any bytes reach it, then `rename`. Writing in @@ -96,7 +91,9 @@ async fn write_json_atomic(path: &std::path::Path, value: &Value) -> std::io::Re static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let tmp = dir.join(format!( ".{}.openab-tmp.{}.{}", - path.file_name().and_then(|n| n.to_str()).unwrap_or("mcp.json"), + path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("mcp.json"), std::process::id(), TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) )); @@ -150,9 +147,6 @@ async fn write_json_atomic(path: &std::path::Path, value: &Value) -> std::io::Re } } - - - /// Author the static `openab` facade entry into the one file openab owns /// (Facade mode). The entry is byte-identical for every session — the bridge it used to be /// compared against here was removed with the rest of Option C, so the comparison is gone @@ -191,10 +185,11 @@ pub async fn write_facade_mcp_config(workdir: &str, facade_url: &str) -> std::io /// pass it and identifies no vendor at spawn time (D-21). openab never puts the entry in /// place itself either (D-15). pub fn facade_config_path(workdir: &str) -> std::path::PathBuf { - std::path::Path::new(workdir).join(".openab").join("mcp-facade.json") + std::path::Path::new(workdir) + .join(".openab") + .join("mcp-facade.json") } - /// Broker-side session credential hook (Facade mode). Implemented by the root /// (closing over the facade's `SessionTokens` registry — core stays free of /// the openab-mcp dependency); the pool calls it at session spawn/evict. @@ -263,8 +258,6 @@ pub fn report_facade_status(mcp_configured: bool, workdir: &str) { } } - - /// The destructive cases for the facade config writer — the one file openab owns, /// `.openab/mcp-facade.json`. It touches a user's `mcp.json` nowhere at all any more; the /// boundary test below is what asserts that. @@ -293,9 +286,11 @@ mod facade_config_writer { let path = facade_config_path(wd.to_str().unwrap()); assert_eq!(path, wd.join(".openab").join("mcp-facade.json")); - let v: Value = - serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()).unwrap(); - assert_eq!(v["mcpServers"]["openab"]["url"], json!("http://127.0.0.1:8848/mcp")); + let v: Value = serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()).unwrap(); + assert_eq!( + v["mcpServers"]["openab"]["url"], + json!("http://127.0.0.1:8848/mcp") + ); // The token is never in the file — the literal is, and the agent's env supplies the value. assert_eq!( v["mcpServers"]["openab"]["headers"]["Authorization"], @@ -320,7 +315,9 @@ mod facade_config_writer { let kiro = wd.join(".kiro").join("settings").join("mcp.json"); let agent = wd.join(".kiro").join("agents").join("terra.json"); for f in [&cursor, &kiro, &agent] { - tokio::fs::create_dir_all(f.parent().unwrap()).await.unwrap(); + tokio::fs::create_dir_all(f.parent().unwrap()) + .await + .unwrap(); } // Deliberately including a `//` comment: the shape that the old merge path destroyed. let cursor_body = "{\n // mine\n \"mcpServers\": {\"mine\": {\"command\": \"x\"}}\n}"; @@ -338,7 +335,10 @@ mod facade_config_writer { // longer retires those — see the PR body: that cleanup became operator-performed, and it // is a policy bypass rather than a convenience, so it is stated rather than silently // dropped. - assert_eq!(tokio::fs::read_to_string(&cursor).await.unwrap(), cursor_body); + assert_eq!( + tokio::fs::read_to_string(&cursor).await.unwrap(), + cursor_body + ); assert_eq!(tokio::fs::read_to_string(&kiro).await.unwrap(), kiro_body); assert_eq!(tokio::fs::read_to_string(&agent).await.unwrap(), agent_body); let _ = tokio::fs::remove_dir_all(&wd).await; @@ -379,7 +379,10 @@ mod facade_config_writer { let v: Value = serde_json::from_slice(&tokio::fs::read(facade_config_path(&w)).await.unwrap()) .unwrap(); - assert_eq!(v["mcpServers"]["openab"]["url"], json!("http://127.0.0.1:8848/mcp")); + assert_eq!( + v["mcpServers"]["openab"]["url"], + json!("http://127.0.0.1:8848/mcp") + ); let _ = tokio::fs::remove_dir_all(&wd).await; } @@ -393,24 +396,22 @@ mod facade_config_writer { .await .unwrap(); let path = facade_config_path(wd.to_str().unwrap()); - let mode = tokio::fs::metadata(&path).await.unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600, "0600 must be set at creation, not chmod'd after"); + let mode = tokio::fs::metadata(&path) + .await + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o600, + "0600 must be set at creation, not chmod'd after" + ); let _ = tokio::fs::remove_dir_all(&wd).await; } } #[cfg(test)] mod tests { - use super::{ - }; - - - - - - - - /// Unique throwaway workdir with a `.kiro/agents/` tree. async fn tmp_workdir(tag: &str) -> std::path::PathBuf { @@ -425,19 +426,5 @@ mod tests { dir } - - - - - - - - - - - - const INIT_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#; - - } diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index e899a586e..9aabb3629 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -4,7 +4,9 @@ use serde::Serialize; use std::sync::Arc; use tracing::{error, warn}; -use crate::acp::{classify_notification, parse_turn_result, AcpEvent, ContentBlock, SessionPool, TurnResult}; +use crate::acp::{ + classify_notification, parse_turn_result, AcpEvent, ContentBlock, SessionPool, TurnResult, +}; use crate::config::{ReactionsConfig, ToolDisplay}; use crate::error_display::{format_coded_error, format_user_error}; use crate::format; @@ -72,7 +74,12 @@ pub fn parse_output_directives(content: &str) -> (OutputDirectives, String) { "reply_to" => { let v = value.trim(); // Validate: non-empty, reasonable length, no whitespace/control chars - if !v.is_empty() && v.len() <= 64 && v.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') { + if !v.is_empty() + && v.len() <= 64 + && v.chars().all(|c| { + c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' + }) + { directives.reply_to = Some(v.to_string()); } } @@ -331,6 +338,10 @@ pub struct SenderContext { // --- ChatAdapter trait --- +// `#[async_trait]` stamps a bare `#[must_use]` on every method it expands to, +// which `clippy::double_must_use` flags even though the attribute is generated +// and cannot carry a message. +#[allow(clippy::double_must_use)] #[async_trait] pub trait ChatAdapter: Send + Sync + 'static { /// Platform name for logging and session key namespacing. @@ -1388,16 +1399,17 @@ fn contains_bot_mention(content: &str) -> bool { while i + 2 < bytes.len() { if bytes[i] == b'<' && bytes[i + 1] == b'@' { // Skip optional '!' (nickname mention) or '&' (role mention) - let start = if i + 2 < bytes.len() - && (bytes[i + 2] == b'!' || bytes[i + 2] == b'&') - { + let start = if i + 2 < bytes.len() && (bytes[i + 2] == b'!' || bytes[i + 2] == b'&') { i + 3 } else { i + 2 }; if start < bytes.len() && bytes[start].is_ascii_digit() { if let Some(end) = content[start..].find('>') { - if content[start..start + end].chars().all(|c| c.is_ascii_digit()) { + if content[start..start + end] + .chars() + .all(|c| c.is_ascii_digit()) + { return true; } } @@ -1626,8 +1638,7 @@ fn compose_display( // matches the sibling finished-fallback summary and // the pre-PR raw-count behaviour that users are used // to. A hidden group of `a×2` contributes 2, not 1. - let hidden_groups = - running_groups.len() - TOOL_COLLAPSE_THRESHOLD; + let hidden_groups = running_groups.len() - TOOL_COLLAPSE_THRESHOLD; let hidden_calls: usize = running_groups .iter() .take(hidden_groups) @@ -1736,7 +1747,11 @@ fn propagate_mentions_to_chunks( } else { let footer = format!( "\n{}", - missing.iter().map(|m| m.as_str()).collect::>().join(" ") + missing + .iter() + .map(|m| m.as_str()) + .collect::>() + .join(" ") ); if chunk.chars().count() + footer.chars().count() <= limit { format!("{chunk}{footer}") @@ -1761,7 +1776,10 @@ mod tests { assert_eq!(reply_message_limit("slack", 4096), 4096); // and a long reply under the ACP limit is a single chunk (delivered whole) let long = "x".repeat(50_000); - assert_eq!(crate::format::split_message(&long, reply_message_limit("acp", 4096)).len(), 1); + assert_eq!( + crate::format::split_message(&long, reply_message_limit("acp", 4096)).len(), + 1 + ); } #[test] @@ -2101,7 +2119,10 @@ mod tests { tool("3", "grep", ToolState::Completed), ]; let out = compose_display(&tools, "done", false, ToolDisplay::Full); - assert!(!out.contains("(×"), "should not collapse across order: {out}"); + assert!( + !out.contains("(×"), + "should not collapse across order: {out}" + ); assert_eq!(out.matches("`grep`").count(), 2, "output: {out}"); assert_eq!(out.matches("`curl`").count(), 1, "output: {out}"); } diff --git a/crates/openab-core/src/ambient.rs b/crates/openab-core/src/ambient.rs index 57f9e3213..8daca550b 100644 --- a/crates/openab-core/src/ambient.rs +++ b/crates/openab-core/src/ambient.rs @@ -346,7 +346,9 @@ impl AmbientDispatcher { /// Check if ambient mode is active and this channel is in the allowlist. pub fn is_ambient_channel(&self, channel_id: u64) -> bool { - self.config.enabled && !self.enabled_channels.is_empty() && self.enabled_channels.contains(&channel_id) + self.config.enabled + && !self.enabled_channels.is_empty() + && self.enabled_channels.contains(&channel_id) } /// Decide whether a message should be ambient-buffered. @@ -431,11 +433,7 @@ impl AmbientDispatcher { let state = channels.get(channel_id).unwrap(); // Non-blocking try_send — if buffer is full (hard_cap), drop the message. if let Err(e) = state.tx.try_send(msg) { - debug!( - channel_id, - "ambient buffer full, dropping message: {}", - e - ); + debug!(channel_id, "ambient buffer full, dropping message: {}", e); } } @@ -448,7 +446,10 @@ impl AmbientDispatcher { if let Some(state) = channels.get(channel_id) { // Cancel any in-flight post — consumer discards current batch on next check. state.post_guard.cancel(); - debug!(channel_id, "ambient buffer discard requested (mention arrived)"); + debug!( + channel_id, + "ambient buffer discard requested (mention arrived)" + ); } } @@ -504,7 +505,8 @@ async fn ambient_consumer_loop( let base = Duration::from_secs(base_secs); let jitter_range = base.as_millis() as f64 * 0.2; let jitter_ms = rand::thread_rng().gen_range(-jitter_range..jitter_range) as i64; - let interval = Duration::from_millis((base.as_millis() as i64 + jitter_ms).max(1000) as u64); + let interval = + Duration::from_millis((base.as_millis() as i64 + jitter_ms).max(1000) as u64); let deadline = tokio::time::Instant::now() + interval; let mut batch = vec![first]; @@ -569,8 +571,16 @@ async fn ambient_consumer_loop( // Debug mode: notify channel that a flush is happening. if config.debug { let senders: Vec<&str> = batch.iter().map(|m| m.sender_name.as_str()).collect(); - let transcript = batch.iter() - .map(|m| format!("{} (<@{}>): {}", m.sender_name, m.sender_id, m.prompt.replace('`', "'"))) + let transcript = batch + .iter() + .map(|m| { + format!( + "{} (<@{}>): {}", + m.sender_name, + m.sender_id, + m.prompt.replace('`', "'") + ) + }) .collect::>() .join("\n"); let mut debug_msg = format!( @@ -750,7 +760,8 @@ mod tests { #[test] fn payload_transcript_includes_sender_uid() { - let blocks = build_ambient_payload(&[msg("hello")], super::DEFAULT_AMBIENT_SYSTEM_INSTRUCTION); + let blocks = + build_ambient_payload(&[msg("hello")], super::DEFAULT_AMBIENT_SYSTEM_INSTRUCTION); let transcript = match &blocks[1] { ContentBlock::Text { text } => text.as_str(), #[allow(unreachable_patterns)] diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index fdd89dcb3..191788afd 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -133,7 +133,8 @@ fn default_mcp_listen() -> String { } #[derive(Debug, Clone, Deserialize)] -pub struct AgentCoreConfig { /// AgentCore Runtime ARN (required) +pub struct AgentCoreConfig { + /// AgentCore Runtime ARN (required) pub runtime_arn: String, /// ACP agent command to run in the PTY shell (default: kiro-cli acp --trust-all-tools) #[serde(default = "default_agentcore_shell_command")] @@ -3016,9 +3017,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] // --- use_adc: config value resolves without touching env --- let r = GoogleChatConfig { use_adc: Some(true), - adc_target_service_account: Some( - "chat-bot@project.iam.gserviceaccount.com".into(), - ), + adc_target_service_account: Some("chat-bot@project.iam.gserviceaccount.com".into()), ..Default::default() } .resolve(); diff --git a/crates/openab-core/src/cron.rs b/crates/openab-core/src/cron.rs index 40e9a67ca..73edd0b7b 100644 --- a/crates/openab-core/src/cron.rs +++ b/crates/openab-core/src/cron.rs @@ -251,10 +251,12 @@ fn should_create_cron_thread(job: &CronJobConfig) -> bool { } fn cron_sender_thread_id(channel: &ChannelRef) -> Option { - channel - .thread_id - .clone() - .or_else(|| channel.parent_id.as_ref().map(|_| channel.channel_id.clone())) + channel.thread_id.clone().or_else(|| { + channel + .parent_id + .as_ref() + .map(|_| channel.channel_id.clone()) + }) } /// Validate all cronjob configs (fail-fast on bad cron expressions or timezones). @@ -647,8 +649,7 @@ async fn fire_cronjob( DisableOnSuccessResult::NotAchieved(reason) => { info!( id = job.id.as_deref().unwrap_or(""), - reason, - "disable_on_success not achieved, firing cronjob normally" + reason, "disable_on_success not achieved, firing cronjob normally" ); } } @@ -725,7 +726,7 @@ async fn fire_cronjob( thread_id: cron_sender_thread_id(&reply_channel), is_bot: true, timestamp: Some(Utc::now().to_rfc3339()), - message_id: None, // cron jobs don't originate from a message + message_id: None, // cron jobs don't originate from a message receiver_id: None, // cron jobs are self-triggered, no external receiver }; let sender_json = match serde_json::to_string(&sender) { @@ -1250,7 +1251,7 @@ message = "hello" "#; let cfg: UsercronFile = toml::from_str(toml_str).unwrap(); let job = &cfg.jobs[0]; - assert_eq!(job.enabled, true); + assert!(job.enabled); assert_eq!(job.platform, "discord"); assert_eq!(job.sender_name, "openab-cron"); assert_eq!(job.timezone, "UTC"); @@ -1272,7 +1273,7 @@ channel = "123" message = "hello" "#; let cfg: UsercronFile = toml::from_str(toml_str).unwrap(); - assert_eq!(cfg.jobs[0].enabled, false); + assert!(!cfg.jobs[0].enabled); } #[test] @@ -1630,7 +1631,10 @@ message = "a" origin_event_id: None, }; - assert_eq!(cron_sender_thread_id(&channel).as_deref(), Some("thread-456")); + assert_eq!( + cron_sender_thread_id(&channel).as_deref(), + Some("thread-456") + ); } // --- validate_cronjobs tests --- diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index 2ea8b28c0..c16da2c3c 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -17,7 +17,9 @@ use serenity::builder::{ }; use serenity::http::Http; use serenity::model::application::ButtonStyle; -use serenity::model::application::{Command, CommandOptionType, ComponentInteractionDataKind, Interaction}; +use serenity::model::application::{ + Command, CommandOptionType, ComponentInteractionDataKind, Interaction, +}; use serenity::model::channel::{AutoArchiveDuration, Message, MessageType, Reaction, ReactionType}; use serenity::model::gateway::Ready; use serenity::model::id::{ChannelId, MessageId, UserId}; @@ -212,7 +214,11 @@ impl ChatAdapter for DiscordAdapter { let ch_id: u64 = Self::resolve_channel(channel).parse()?; // Truncate at char boundary to avoid panic on multi-byte chars (中文/Emoji). let truncated: &str = if title.chars().count() > 100 { - let end = title.char_indices().nth(100).map(|(i, _)| i).unwrap_or(title.len()); + let end = title + .char_indices() + .nth(100) + .map(|(i, _)| i) + .unwrap_or(title.len()); &title[..end] } else { title @@ -356,7 +362,9 @@ impl EventHandler for Handler { let key = msg.channel_id.to_string(); { let mut cache = self.multibot_threads.lock().await; - cache.entry(key.clone()).or_insert_with(tokio::time::Instant::now); + cache + .entry(key.clone()) + .or_insert_with(tokio::time::Instant::now); } // Persist to disk — multibot is irreversible self.multibot_cache.mark_multibot(&key).await; @@ -499,11 +507,14 @@ impl EventHandler for Handler { // non-allowed channels. Moved before bot gating so ambient context // can be resolved early — bot messages in ambient contexts must bypass // discord-level bot gating (#1197). - let (in_thread, bot_owns_thread, thread_parent_id, is_dm, is_structural_thread, structural_parent_id) = match msg - .channel_id - .to_channel(&ctx.http) - .await - { + let ( + in_thread, + bot_owns_thread, + thread_parent_id, + is_dm, + is_structural_thread, + structural_parent_id, + ) = match msg.channel_id.to_channel(&ctx.http).await { Ok(serenity::model::channel::Channel::Guild(gc)) => { let parent = gc.parent_id.map(|id| id.get().to_string()); let has_thread_metadata = gc.thread_metadata.is_some(); @@ -532,7 +543,11 @@ impl EventHandler for Handler { if has_thread_metadata { parent } else { None }, false, has_thread_metadata, - if has_thread_metadata { parent_u64 } else { None }, + if has_thread_metadata { + parent_u64 + } else { + None + }, ) } Ok(serenity::model::channel::Channel::Private(_)) => { @@ -552,7 +567,12 @@ impl EventHandler for Handler { // Check if message is in an ambient context (resolved early so bot // messages destined for ambient can bypass discord-level bot gating). let in_ambient_context = self.ambient.as_ref().is_some_and(|ambient| { - ambient.should_buffer(channel_id, is_structural_thread, bot_owns_thread, structural_parent_id) + ambient.should_buffer( + channel_id, + is_structural_thread, + bot_owns_thread, + structural_parent_id, + ) }); // --- Ambient early-route for bot messages --- @@ -599,13 +619,15 @@ impl EventHandler for Handler { let target = Arc::clone(&self.router) as Arc; debug!(channel_id = %msg.channel_id, bot_id = %msg.author.id, "ambient early-route: bot msg buffered"); - ambient.submit( - &channel_id.to_string(), - channel_ref, - adapter.clone(), - target, - ambient_msg, - ).await; + ambient + .submit( + &channel_id.to_string(), + channel_ref, + adapter.clone(), + target, + ambient_msg, + ) + .await; } } return; @@ -761,13 +783,15 @@ impl EventHandler for Handler { }; let target = Arc::clone(&self.router) as Arc; - ambient.submit( - &channel_id.to_string(), - channel_ref, - adapter.clone(), - target, - ambient_msg, - ).await; + ambient + .submit( + &channel_id.to_string(), + channel_ref, + adapter.clone(), + target, + ambient_msg, + ) + .await; return; } } @@ -918,7 +942,8 @@ impl EventHandler for Handler { // be uploaded to S3, not inlined). let attachment_size = u64::from(attachment.size); #[cfg(feature = "filestore")] - let skip_cap = self.filestore.is_some() && attachment_size > crate::media::TEXT_INLINE_LIMIT; + let skip_cap = + self.filestore.is_some() && attachment_size > crate::media::TEXT_INLINE_LIMIT; #[cfg(not(feature = "filestore"))] let skip_cap = false; if !skip_cap && text_file_bytes + attachment_size > TEXT_TOTAL_CAP { @@ -999,7 +1024,9 @@ impl EventHandler for Handler { attachment.content_type.as_deref(), None, fs, - ).await { + ) + .await + { extra_blocks.push(block); } } @@ -1064,10 +1091,11 @@ impl EventHandler for Handler { let trigger_msg = discord_msg_ref(&msg); // Per-thread streaming: check if another bot is present in this thread - let other_bot_present_flag = { - let cache = self.multibot_threads.lock().await; - cache.contains_key(&msg.channel_id.to_string()) - } || self.multibot_cache.is_multibot(&msg.channel_id.to_string()); + let other_bot_present_flag = + { + let cache = self.multibot_threads.lock().await; + cache.contains_key(&msg.channel_id.to_string()) + } || self.multibot_cache.is_multibot(&msg.channel_id.to_string()); // Backfill thread_id: when OAB just created a new thread, the sender // was built before the thread existed. Patch it so the agent sees @@ -1236,24 +1264,30 @@ impl EventHandler for Handler { if !in_allowed_thread { return; } - (ChannelRef { - platform: "discord".into(), - channel_id: channel_id.get().to_string(), - thread_id: None, - parent_id: parent.map(|p| p.to_string()), - origin_event_id: None, - }, true) + ( + ChannelRef { + platform: "discord".into(), + channel_id: channel_id.get().to_string(), + thread_id: None, + parent_id: parent.map(|p| p.to_string()), + origin_event_id: None, + }, + true, + ) } else { if !in_allowed_channel { return; } - (ChannelRef { - platform: "discord".into(), - channel_id: channel_id.get().to_string(), - thread_id: None, - parent_id: None, - origin_event_id: None, - }, false) + ( + ChannelRef { + platform: "discord".into(), + channel_id: channel_id.get().to_string(), + thread_id: None, + parent_id: None, + origin_event_id: None, + }, + false, + ) } } _ => return, @@ -1267,7 +1301,8 @@ impl EventHandler for Handler { self.allow_user_messages, AllowUsers::Involved | AllowUsers::MultibotMentions ) { - self.bot_participated_in_thread(&ctx.http, channel_id, bot_id).await + self.bot_participated_in_thread(&ctx.http, channel_id, bot_id) + .await } else { // For non-thread: still check multibot cache for dispatch info. let mb = { @@ -1301,17 +1336,16 @@ impl EventHandler for Handler { tokio::spawn(async move { // F2 fix: Fetch user info first, then apply user gating with confirmed bot status. - let (sender_name, display_name, is_bot_confirmed) = - match user_id.to_user(&http).await { - Ok(user) => { - let display = user.global_name.as_ref().unwrap_or(&user.name).clone(); - (user.name.clone(), display, user.bot) - } - Err(_) => { - let fallback = user_id.to_string(); - (fallback.clone(), fallback, is_reactor_bot) - } - }; + let (sender_name, display_name, is_bot_confirmed) = match user_id.to_user(&http).await { + Ok(user) => { + let display = user.global_name.as_ref().unwrap_or(&user.name).clone(); + (user.name.clone(), display, user.bot) + } + Err(_) => { + let fallback = user_id.to_string(); + (fallback.clone(), fallback, is_reactor_bot) + } + }; // Defense-in-depth: if to_user() reveals this is a bot but member was // None (rare edge case), re-apply bot gating retroactively. @@ -1319,8 +1353,7 @@ impl EventHandler for Handler { match allow_bot_messages { AllowBots::Off | AllowBots::Mentions => return, AllowBots::All => { - if !trusted_bot_ids.is_empty() - && !trusted_bot_ids.contains(&user_id.get()) + if !trusted_bot_ids.is_empty() && !trusted_bot_ids.contains(&user_id.get()) { return; } @@ -1403,23 +1436,31 @@ impl EventHandler for Handler { CreateCommand::new("reset").description("Reset the conversation session"), CreateCommand::new("remind") .description("Set a one-shot reminder to mention users/roles after a delay") - .add_option(CreateCommandOption::new( - CommandOptionType::String, - "targets", - "Users/roles to mention (e.g. @user1 @role1)", - ).required(true)) - .add_option(CreateCommandOption::new( - CommandOptionType::String, - "message", - "Reminder message", - ).required(true)) - .add_option(CreateCommandOption::new( - CommandOptionType::String, - "delay", - "Delay before firing (e.g. 30m, 2h, 1d)", - ).required(true)), - CreateCommand::new("auth") - .description("Authenticate the backend agent (device flow)"), + .add_option( + CreateCommandOption::new( + CommandOptionType::String, + "targets", + "Users/roles to mention (e.g. @user1 @role1)", + ) + .required(true), + ) + .add_option( + CreateCommandOption::new( + CommandOptionType::String, + "message", + "Reminder message", + ) + .required(true), + ) + .add_option( + CreateCommandOption::new( + CommandOptionType::String, + "delay", + "Delay before firing (e.g. 30m, 2h, 1d)", + ) + .required(true), + ), + CreateCommand::new("auth").description("Authenticate the backend agent (device flow)"), CreateCommand::new("usage") .description("Show backend account usage and billing information"), CreateCommand::new("export-thread") @@ -1491,7 +1532,8 @@ impl EventHandler for Handler { self.handle_config_command(&ctx, &cmd, "model").await; } Interaction::Command(cmd) if cmd.data.name == "effort" => { - self.handle_config_command(&ctx, &cmd, "thought_level").await; + self.handle_config_command(&ctx, &cmd, "thought_level") + .await; } Interaction::Command(cmd) if cmd.data.name == "agents" => { self.handle_config_command(&ctx, &cmd, "agent").await; @@ -1708,7 +1750,9 @@ impl Handler { if !self.router.pool().has_active_session(&thread_key).await { let response = CreateInteractionResponse::Message( CreateInteractionResponseMessage::new() - .content("⚠️ No active session. Start a conversation first by @mentioning the bot.") + .content( + "⚠️ No active session. Start a conversation first by @mentioning the bot.", + ) .ephemeral(true), ); if let Err(e) = cmd.create_response(&ctx.http, response).await { @@ -1719,8 +1763,9 @@ impl Handler { // The ACP round-trip can exceed Discord's 3-second interaction // deadline — acknowledge with a deferred ephemeral response first. - let defer = - CreateInteractionResponse::Defer(CreateInteractionResponseMessage::new().ephemeral(true)); + let defer = CreateInteractionResponse::Defer( + CreateInteractionResponseMessage::new().ephemeral(true), + ); if let Err(e) = cmd.create_response(&ctx.http, defer).await { tracing::error!(error = %e, "failed to defer /usage response"); return; @@ -1857,15 +1902,18 @@ impl Handler { // Extract options let opts = &cmd.data.options; - let targets_raw = opts.iter() + let targets_raw = opts + .iter() .find(|o| o.name == "targets") .and_then(|o| o.value.as_str()) .unwrap_or(""); - let message = opts.iter() + let message = opts + .iter() .find(|o| o.name == "message") .and_then(|o| o.value.as_str()) .unwrap_or(""); - let delay_raw = opts.iter() + let delay_raw = opts + .iter() .find(|o| o.name == "delay") .and_then(|o| o.value.as_str()) .unwrap_or(""); @@ -1927,7 +1975,10 @@ impl Handler { if targets.len() > remind::MAX_TARGETS { let response = CreateInteractionResponse::Message( CreateInteractionResponseMessage::new() - .content(format!("⚠️ Too many targets (max {}). Use a @role instead.", remind::MAX_TARGETS)) + .content(format!( + "⚠️ Too many targets (max {}). Use a @role instead.", + remind::MAX_TARGETS + )) .ephemeral(true), ); let _ = cmd.create_response(&ctx.http, response).await; @@ -2027,7 +2078,9 @@ impl Handler { if AUTH_IN_PROGRESS.swap(true, std::sync::atomic::Ordering::Acquire) { let response = CreateInteractionResponse::Message( CreateInteractionResponseMessage::new() - .content("⚠️ Authentication already in progress. Please wait for it to complete.") + .content( + "⚠️ Authentication already in progress. Please wait for it to complete.", + ) .ephemeral(true), ); let _ = cmd.create_response(&ctx.http, response).await; @@ -2040,7 +2093,9 @@ impl Handler { AUTH_IN_PROGRESS.store(false, std::sync::atomic::Ordering::Release); let response = CreateInteractionResponse::Message( CreateInteractionResponseMessage::new() - .content("⚠️ No auth command configured (`OPENAB_AGENT_AUTH_COMMAND` not set).") + .content( + "⚠️ No auth command configured (`OPENAB_AGENT_AUTH_COMMAND` not set).", + ) .ephemeral(true), ); let _ = cmd.create_response(&ctx.http, response).await; @@ -2063,9 +2118,9 @@ impl Handler { let user_id = cmd.user.id.get(); tokio::spawn(async move { + use std::sync::Arc; use tokio::io::AsyncBufReadExt; use tokio::process::Command as TokioCommand; - use std::sync::Arc; // Drop guard ensures AUTH_IN_PROGRESS is cleared even on panic. struct AuthGuard; @@ -2089,13 +2144,15 @@ impl Handler { Ok(c) => c, Err(e) => { tracing::error!(error = %e, "/auth: failed to spawn auth command"); - let _ = http.create_followup_message( - &token, - &CreateInteractionResponseFollowup::new() - .content(format!("❌ Failed to start auth command: {e}")) - .ephemeral(true), - Vec::new(), - ).await; + let _ = http + .create_followup_message( + &token, + &CreateInteractionResponseFollowup::new() + .content(format!("❌ Failed to start auth command: {e}")) + .ephemeral(true), + Vec::new(), + ) + .await; return; } }; @@ -2114,7 +2171,10 @@ impl Handler { let mut reader = tokio::io::BufReader::new(stdout).lines(); while let Ok(Some(line)) = reader.next_line().await { let has_url = line.contains("http://") || line.contains("https://"); - lines_out.lock().unwrap_or_else(|e| e.into_inner()).push(line); + lines_out + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(line); if has_url { url_found_out.notify_one(); } @@ -2129,7 +2189,10 @@ impl Handler { let mut reader = tokio::io::BufReader::new(stderr).lines(); while let Ok(Some(line)) = reader.next_line().await { let has_url = line.contains("http://") || line.contains("https://"); - lines_err.lock().unwrap_or_else(|e| e.into_inner()).push(line); + lines_err + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(line); if has_url { url_found_err.notify_one(); } @@ -2159,12 +2222,8 @@ impl Handler { // Handle an early exit (the command terminated during the URL window). if let Some(res) = early_exit { let _ = tokio::join!(stdout_task, stderr_task); - let collected = strip_ansi_codes( - &lines - .lock() - .unwrap_or_else(|e| e.into_inner()) - .join("\n"), - ); + let collected = + strip_ansi_codes(&lines.lock().unwrap_or_else(|e| e.into_inner()).join("\n")); let detail = if collected.trim().is_empty() { String::new() } else { @@ -2184,13 +2243,15 @@ impl Handler { } Err(e) => format!("❌ Error waiting for auth command: {e}"), }; - let _ = http.create_followup_message( - &token, - &CreateInteractionResponseFollowup::new() - .content(content) - .ephemeral(true), - Vec::new(), - ).await; + let _ = http + .create_followup_message( + &token, + &CreateInteractionResponseFollowup::new() + .content(content) + .ephemeral(true), + Vec::new(), + ) + .await; return; } @@ -2221,13 +2282,15 @@ impl Handler { // `truncate_to_utf16_budget` for the testable implementation. let truncated = truncate_to_utf16_budget(&output, prefix, suffix, 2000); let msg = format!("{prefix}{truncated}{suffix}"); - let _ = http.create_followup_message( - &token, - &CreateInteractionResponseFollowup::new() - .content(msg) - .ephemeral(true), - Vec::new(), - ).await; + let _ = http + .create_followup_message( + &token, + &CreateInteractionResponseFollowup::new() + .content(msg) + .ephemeral(true), + Vec::new(), + ) + .await; // Wait for the process to complete (user authorizes in browser). // Use 14min (not 15) to leave headroom for the Discord interaction token TTL. @@ -2235,44 +2298,55 @@ impl Handler { match tokio::time::timeout(timeout, child.wait()).await { Ok(Ok(status)) if status.success() => { info!("/auth: authentication successful"); - let _ = http.create_followup_message( - &token, - &CreateInteractionResponseFollowup::new() - .content("✅ Authentication successful!") - .ephemeral(true), - Vec::new(), - ).await; + let _ = http + .create_followup_message( + &token, + &CreateInteractionResponseFollowup::new() + .content("✅ Authentication successful!") + .ephemeral(true), + Vec::new(), + ) + .await; } Ok(Ok(status)) => { warn!(%status, "/auth: authentication failed"); - let _ = http.create_followup_message( - &token, - &CreateInteractionResponseFollowup::new() - .content(format!("❌ Authentication failed (exit code: {}).", status)) - .ephemeral(true), - Vec::new(), - ).await; + let _ = http + .create_followup_message( + &token, + &CreateInteractionResponseFollowup::new() + .content(format!( + "❌ Authentication failed (exit code: {}).", + status + )) + .ephemeral(true), + Vec::new(), + ) + .await; } Ok(Err(e)) => { tracing::error!(error = %e, "/auth: error waiting for auth process"); - let _ = http.create_followup_message( - &token, - &CreateInteractionResponseFollowup::new() - .content(format!("❌ Auth process error: {e}")) - .ephemeral(true), - Vec::new(), - ).await; + let _ = http + .create_followup_message( + &token, + &CreateInteractionResponseFollowup::new() + .content(format!("❌ Auth process error: {e}")) + .ephemeral(true), + Vec::new(), + ) + .await; } Err(_) => { warn!("/auth: timed out waiting for authorization"); let _ = child.kill().await; - let _ = http.create_followup_message( - &token, - &CreateInteractionResponseFollowup::new() - .content("⏰ Authentication timed out. Run `/auth` again to retry.") - .ephemeral(true), - Vec::new(), - ).await; + let _ = http + .create_followup_message( + &token, + &CreateInteractionResponseFollowup::new() + .content("⏰ Authentication timed out. Run `/auth` again to retry.") + .ephemeral(true), + Vec::new(), + ) + .await; } } @@ -2319,9 +2393,7 @@ impl Handler { ); (in_thread, gc.name.clone()) } - Ok(serenity::model::channel::Channel::Private(_)) => { - (self.allow_dm, "dm".to_string()) - } + Ok(serenity::model::channel::Channel::Private(_)) => (self.allow_dm, "dm".to_string()), Ok(_) => (false, "channel".to_string()), Err(e) => { tracing::warn!(channel_id = %channel_id, error = %e, "failed to inspect channel for export"); @@ -2343,16 +2415,34 @@ impl Handler { // --- Parse and validate filter params (mutual exclusion) --- let opts = &cmd.data.options; - let limit_opt = opts.iter().find(|o| o.name == "limit").and_then(|o| o.value.as_i64()); - let since_opt = opts.iter().find(|o| o.name == "since").and_then(|o| o.value.as_str()); - let days_opt = opts.iter().find(|o| o.name == "days").and_then(|o| o.value.as_i64()); - let all_opt = opts.iter().find(|o| o.name == "all").and_then(|o| o.value.as_bool()).unwrap_or(false); + let limit_opt = opts + .iter() + .find(|o| o.name == "limit") + .and_then(|o| o.value.as_i64()); + let since_opt = opts + .iter() + .find(|o| o.name == "since") + .and_then(|o| o.value.as_str()); + let days_opt = opts + .iter() + .find(|o| o.name == "days") + .and_then(|o| o.value.as_i64()); + let all_opt = opts + .iter() + .find(|o| o.name == "all") + .and_then(|o| o.value.as_bool()) + .unwrap_or(false); - let filter_count = limit_opt.is_some() as u8 + since_opt.is_some() as u8 + days_opt.is_some() as u8 + all_opt as u8; + let filter_count = limit_opt.is_some() as u8 + + since_opt.is_some() as u8 + + days_opt.is_some() as u8 + + all_opt as u8; if filter_count > 1 { let response = CreateInteractionResponse::Message( CreateInteractionResponseMessage::new() - .content("⚠️ Please specify only one filter: `limit`, `since`, `days`, or `all`.") + .content( + "⚠️ Please specify only one filter: `limit`, `since`, `days`, or `all`.", + ) .ephemeral(true), ); let _ = cmd.create_response(&ctx.http, response).await; @@ -2620,8 +2710,7 @@ fn format_usage_body(report: &UsageReport) -> (String, bool) { fn build_usage_reply(report: &UsageReport) -> (String, CreateEmbed) { let (body, over_limit) = format_usage_body(report); let content = format!("📊 **Usage — {}**\n{}", report.plan_name, body); - let mut embed = - CreateEmbed::new().colour(if over_limit { 0xE74C3C } else { 0x2ECC71 }); + let mut embed = CreateEmbed::new().colour(if over_limit { 0xE74C3C } else { 0x2ECC71 }); if let Some(reset) = &report.billing_cycle_reset { embed = embed.footer(CreateEmbedFooter::new(format!( "Billing cycle resets {reset}" @@ -2791,7 +2880,10 @@ async fn export_channel_messages( let filename = export_filename(channel_id, channel_name); if attachment_size_limit < 2048 { - tracing::warn!(attachment_size_limit, "attachment_size_limit is very small; export will likely be truncated"); + tracing::warn!( + attachment_size_limit, + "attachment_size_limit is very small; export will likely be truncated" + ); } let max_bytes = usize::try_from(attachment_size_limit) .unwrap_or(8 * 1024 * 1024) @@ -2861,10 +2953,7 @@ fn format_export_message(msg: &Message) -> String { let bot_marker = if msg.author.bot { " [bot]" } else { "" }; let mut out = format!( "[{}] {}{} ({})\n", - msg.timestamp, - msg.author.name, - bot_marker, - msg.author.id + msg.timestamp, msg.author.name, bot_marker, msg.author.id ); if msg.content.is_empty() { @@ -3284,7 +3373,7 @@ fn truncate_to_utf16_budget(body: &str, prefix: &str, suffix: &str, limit: usize mod tests { use super::*; use crate::acp::protocol::ConfigOptionValue; - use crate::bot_turns::{TurnResult, HARD_BOT_TURN_LIMIT, BOT_TURN_LIMIT_WARNING_PREFIX}; + use crate::bot_turns::{TurnResult, BOT_TURN_LIMIT_WARNING_PREFIX, HARD_BOT_TURN_LIMIT}; // --- truncate_for_discord (select menu option 100-char cap) --- @@ -3402,7 +3491,10 @@ mod tests { assert!(content.contains("12781.64 / 10000")); assert!(content.contains("Overage charges: 111.27 USD")); let json = serde_json::to_value(&embed).expect("embed serializes"); - assert!(json.get("description").is_none(), "body must not be in embed"); + assert!( + json.get("description").is_none(), + "body must not be in embed" + ); assert!(json.get("title").is_none(), "title must not be in embed"); assert_eq!(json["color"], 0xE74C3C, "over limit → red strip"); assert_eq!(json["footer"]["text"], "Billing cycle resets 2026-08-01"); @@ -3464,7 +3556,10 @@ mod tests { #[test] fn truncate_utf16_respects_prefix_suffix_budget() { // limit 10, prefix "pre" (3) + suffix "su" (2) = 5 → 5 ASCII units left. - assert_eq!(truncate_to_utf16_budget("abcdefghij", "pre", "su", 10), "abcde"); + assert_eq!( + truncate_to_utf16_budget("abcdefghij", "pre", "su", 10), + "abcde" + ); } /// A supplementary-plane scalar counts as TWO UTF-16 code units, not one. @@ -3644,14 +3739,14 @@ mod tests { fn image_attachment_block_includes_url_and_metadata() { // Simulates the format string used in the image attachment handler. let filename = "screenshot.png"; - let content_type = Some("image/png"); + let content_type = "image/png"; let size: u32 = 142048; let url = "https://cdn.discordapp.com/attachments/123/456/screenshot.png"; let text = format!( "[Image attachment]\nfilename: {}\ncontent_type: {}\nsize_bytes: {}\nurl: {} (expires ~24h)", filename, - content_type.unwrap_or("unknown"), + content_type, size, url, ); @@ -3666,11 +3761,11 @@ mod tests { #[test] fn image_attachment_block_missing_content_type_falls_back() { - let content_type: Option<&str> = None; + let content_type = "unknown"; let text = format!( "[Image attachment]\nfilename: {}\ncontent_type: {}\nsize_bytes: {}\nurl: {} (expires ~24h)", "photo.jpg", - content_type.unwrap_or("unknown"), + content_type, 99999, "https://cdn.discordapp.com/attachments/1/2/photo.jpg", ); @@ -4357,9 +4452,8 @@ mod tests { trusted_bot_ids: &HashSet, author_id: u64, ) -> bool { - let trusted_mention = is_mentioned - && !trusted_bot_ids.is_empty() - && trusted_bot_ids.contains(&author_id); + let trusted_mention = + is_mentioned && !trusted_bot_ids.is_empty() && trusted_bot_ids.contains(&author_id); if !trusted_mention { match allow_bot_messages { @@ -4392,7 +4486,12 @@ mod tests { #[test] fn bot_admission_untrusted_mention_blocked_by_off() { let trusted = HashSet::from([42]); - assert!(!should_admit_bot_message(AllowBots::Off, true, &trusted, 99)); + assert!(!should_admit_bot_message( + AllowBots::Off, + true, + &trusted, + 99 + )); } /// GIVEN: allow_bot_messages=Off, trusted bot without @mention @@ -4400,7 +4499,12 @@ mod tests { #[test] fn bot_admission_trusted_no_mention_blocked_by_off() { let trusted = HashSet::from([42]); - assert!(!should_admit_bot_message(AllowBots::Off, false, &trusted, 42)); + assert!(!should_admit_bot_message( + AllowBots::Off, + false, + &trusted, + 42 + )); } /// GIVEN: allow_bot_messages=Off, empty trusted_bot_ids, bot @mentions @@ -4408,7 +4512,12 @@ mod tests { #[test] fn bot_admission_empty_trusted_ids_off_mode() { let trusted: HashSet = HashSet::new(); - assert!(!should_admit_bot_message(AllowBots::Off, true, &trusted, 42)); + assert!(!should_admit_bot_message( + AllowBots::Off, + true, + &trusted, + 42 + )); } /// GIVEN: allow_bot_messages=Mentions, trusted bot @mentions @@ -4416,7 +4525,12 @@ mod tests { #[test] fn bot_admission_mentions_mode_trusted_mention() { let trusted = HashSet::from([42]); - assert!(should_admit_bot_message(AllowBots::Mentions, true, &trusted, 42)); + assert!(should_admit_bot_message( + AllowBots::Mentions, + true, + &trusted, + 42 + )); } /// GIVEN: allow_bot_messages=All, untrusted bot (not in trusted_bot_ids) @@ -4424,7 +4538,12 @@ mod tests { #[test] fn bot_admission_all_mode_untrusted_bot_rejected() { let trusted = HashSet::from([42]); - assert!(!should_admit_bot_message(AllowBots::All, false, &trusted, 99)); + assert!(!should_admit_bot_message( + AllowBots::All, + false, + &trusted, + 99 + )); } // --- DM gating tests (#656) --- @@ -4514,19 +4633,28 @@ mod tests { #[test] fn dedup_detects_existing_bot_warning() { - let msg = format!("{} (20/20). A human must reply.", BOT_TURN_LIMIT_WARNING_PREFIX); + let msg = format!( + "{} (20/20). A human must reply.", + BOT_TURN_LIMIT_WARNING_PREFIX + ); assert!(turn_limit_warning_present(&[(true, &msg)])); } #[test] fn dedup_ignores_human_warning_text() { - let msg = format!("{} (20/20). A human must reply.", BOT_TURN_LIMIT_WARNING_PREFIX); + let msg = format!( + "{} (20/20). A human must reply.", + BOT_TURN_LIMIT_WARNING_PREFIX + ); assert!(!turn_limit_warning_present(&[(false, &msg)])); } #[test] fn dedup_returns_false_when_no_warning() { - assert!(!turn_limit_warning_present(&[(true, "hello"), (false, "world")])); + assert!(!turn_limit_warning_present(&[ + (true, "hello"), + (false, "world") + ])); } #[test] @@ -4543,7 +4671,10 @@ mod tests { fn reaction_mentions_mode_always_rejected() { assert!(!should_process_reaction( AllowUsers::Mentions, - true, true, false, false, + true, + true, + false, + false, )); } @@ -4555,7 +4686,8 @@ mod tests { AllowUsers::Involved, false, // is_thread false, // bot_involved (irrelevant for non-thread) - false, false, + false, + false, )); } @@ -4567,7 +4699,8 @@ mod tests { AllowUsers::Involved, true, // is_thread false, // bot_involved - false, false, + false, + false, )); } @@ -4579,7 +4712,8 @@ mod tests { AllowUsers::Involved, true, // is_thread true, // bot_involved - false, false, + false, + false, )); } @@ -4629,7 +4763,9 @@ mod tests { assert!(!should_process_reaction( AllowUsers::MultibotMentions, false, // is_thread - false, false, false, + false, + false, + false, )); } } diff --git a/crates/openab-core/src/dispatch.rs b/crates/openab-core/src/dispatch.rs index 64ba68917..5a76eab4d 100644 --- a/crates/openab-core/src/dispatch.rs +++ b/crates/openab-core/src/dispatch.rs @@ -120,6 +120,10 @@ impl ThreadHandle { /// spinning up a real `SessionPool` (which forks ACP CLI subprocesses). /// `AdapterRouter` is the production implementor; tests use a mock that /// records calls. +// `#[async_trait]` stamps a bare `#[must_use]` on every method it expands to, +// which `clippy::double_must_use` flags even though the attribute is generated +// and cannot carry a message. +#[allow(clippy::double_must_use)] #[async_trait] pub trait DispatchTarget: Send + Sync + 'static { fn reactions_config(&self) -> &ReactionsConfig; diff --git a/crates/openab-core/src/filestore.rs b/crates/openab-core/src/filestore.rs index 0ba54ded1..92139b43c 100644 --- a/crates/openab-core/src/filestore.rs +++ b/crates/openab-core/src/filestore.rs @@ -92,11 +92,7 @@ impl Filestore { /// /// The object key is `{prefix}{uuid}_{filename}`. On success returns the /// presigned URL as a String. On failure logs the error and returns Err. - pub async fn upload_and_presign( - &self, - filename: &str, - data: &[u8], - ) -> anyhow::Result { + pub async fn upload_and_presign(&self, filename: &str, data: &[u8]) -> anyhow::Result { // Sanitize filename: strip path separators, traversal sequences, // double quotes (breaks Content-Disposition header parsing), and // non-ASCII chars. Limit length to prevent excessively long S3 keys. @@ -107,13 +103,12 @@ impl Filestore { .filter(|c| c.is_ascii_graphic() || *c == ' ') .take(200) .collect(); - let safe_name = if safe_name.is_empty() { "unnamed" } else { &safe_name }; - let key = format!( - "{}{}_{}", - self.prefix, - uuid::Uuid::new_v4(), - safe_name - ); + let safe_name = if safe_name.is_empty() { + "unnamed" + } else { + &safe_name + }; + let key = format!("{}{}_{}", self.prefix, uuid::Uuid::new_v4(), safe_name); // Upload the object (3-minute timeout to prevent indefinite hangs) const UPLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180); @@ -188,7 +183,7 @@ impl Filestore { } else { &safe_name }; - let key = format!("{}{}_{}",self.prefix, uuid::Uuid::new_v4(), safe_name); + let key = format!("{}{}_{}", self.prefix, uuid::Uuid::new_v4(), safe_name); // Pre-check reported size if reported_size > self.max_file_size { @@ -260,25 +255,26 @@ impl Filestore { .send() .await { - Ok(resp) => { - match resp.e_tag { - Some(tag) => { - parts.push( - CompletedPart::builder() - .part_number(part_number) - .e_tag(tag) - .build(), - ); - part_number += 1; - } - None => { - upload_error = Some(anyhow::anyhow!("upload_part {part_number} returned no ETag")); - break; - } + Ok(resp) => match resp.e_tag { + Some(tag) => { + parts.push( + CompletedPart::builder() + .part_number(part_number) + .e_tag(tag) + .build(), + ); + part_number += 1; } - } + None => { + upload_error = Some(anyhow::anyhow!( + "upload_part {part_number} returned no ETag" + )); + break; + } + }, Err(e) => { - upload_error = Some(anyhow::anyhow!("upload_part {part_number} failed: {e}")); + upload_error = + Some(anyhow::anyhow!("upload_part {part_number} failed: {e}")); break; } } @@ -294,27 +290,31 @@ impl Filestore { .key(&key) .upload_id(&upload_id) .part_number(part_number) - .body(aws_sdk_s3::primitives::ByteStream::from(std::mem::take(&mut buffer))) + .body(aws_sdk_s3::primitives::ByteStream::from(std::mem::take( + &mut buffer, + ))) .send() .await { - Ok(resp) => { - match resp.e_tag { - Some(tag) => { - parts.push( - CompletedPart::builder() - .part_number(part_number) - .e_tag(tag) - .build(), - ); - } - None => { - upload_error = Some(anyhow::anyhow!("upload_part {part_number} (final) returned no ETag")); - } + Ok(resp) => match resp.e_tag { + Some(tag) => { + parts.push( + CompletedPart::builder() + .part_number(part_number) + .e_tag(tag) + .build(), + ); } - } + None => { + upload_error = Some(anyhow::anyhow!( + "upload_part {part_number} (final) returned no ETag" + )); + } + }, Err(e) => { - upload_error = Some(anyhow::anyhow!("upload_part {part_number} (final) failed: {e}")); + upload_error = Some(anyhow::anyhow!( + "upload_part {part_number} (final) failed: {e}" + )); } } } @@ -361,7 +361,9 @@ impl Filestore { .upload_id(&upload_id) .send() .await; - return Err(anyhow::anyhow!("stream produced no data — file may be empty or download failed")); + return Err(anyhow::anyhow!( + "stream produced no data — file may be empty or download failed" + )); } // If buffer has remaining data but no parts yet (file < 16 MB), upload as single part @@ -383,7 +385,8 @@ impl Filestore { Some(tag) => tag, None => { error!(bucket = %self.bucket, key = %key, "upload single part returned no ETag, aborting"); - let _ = self.client + let _ = self + .client .abort_multipart_upload() .bucket(&self.bucket) .key(&key) @@ -393,16 +396,12 @@ impl Filestore { return Err(anyhow::anyhow!("upload single part returned no ETag")); } }; - parts.push( - CompletedPart::builder() - .part_number(1) - .e_tag(e_tag) - .build(), - ); + parts.push(CompletedPart::builder().part_number(1).e_tag(e_tag).build()); } Err(e) => { error!(bucket = %self.bucket, key = %key, error = %e, "upload single part failed, aborting"); - let _ = self.client + let _ = self + .client .abort_multipart_upload() .bucket(&self.bucket) .key(&key) @@ -419,7 +418,8 @@ impl Filestore { .set_parts(Some(parts)) .build(); - if let Err(e) = self.client + if let Err(e) = self + .client .complete_multipart_upload() .bucket(&self.bucket) .key(&key) @@ -430,7 +430,8 @@ impl Filestore { { error!(bucket = %self.bucket, key = %key, error = %e, "complete_multipart_upload failed, aborting"); // Abort the multipart upload to clean up orphaned parts - let _ = self.client + let _ = self + .client .abort_multipart_upload() .bucket(&self.bucket) .key(&key) @@ -480,11 +481,20 @@ impl Filestore { /// Format the hint block returned to the agent when a large file is uploaded /// to the filestore instead of being inlined. -pub fn format_filestore_hint(filename: &str, size_bytes: u64, presigned_url: &str, ttl_secs: u64) -> String { +pub fn format_filestore_hint( + filename: &str, + size_bytes: u64, + presigned_url: &str, + ttl_secs: u64, +) -> String { let size_kb = size_bytes / 1024; let ttl_minutes = ttl_secs / 60; // Sanitize filename for prompt safety — strip control characters - let safe_filename: String = filename.chars().filter(|c| !c.is_control()).take(200).collect(); + let safe_filename: String = filename + .chars() + .filter(|c| !c.is_control()) + .take(200) + .collect(); format!( "[File: {safe_filename}]\n\ This file ({size_kb} KB) exceeds the 512 KB inline limit. \ @@ -550,7 +560,9 @@ secret_access_key = "SECRET" assert!(hint.contains("[File: big-log.txt]")); assert!(hint.contains("1024 KB")); assert!(hint.contains("exceeds the 512 KB inline limit")); - assert!(hint.contains("https://bucket.s3.amazonaws.com/incoming/uuid_big-log.txt?X-Amz-...")); + assert!( + hint.contains("https://bucket.s3.amazonaws.com/incoming/uuid_big-log.txt?X-Amz-...") + ); assert!(hint.contains("expires in 60 minutes")); } diff --git a/crates/openab-core/src/format.rs b/crates/openab-core/src/format.rs index 4fa1ce9e1..91887c40f 100644 --- a/crates/openab-core/src/format.rs +++ b/crates/openab-core/src/format.rs @@ -413,7 +413,11 @@ mod tests { let chunks = split_message(line, 20); assert_length_invariant(&chunks, 20); assert!(chunks.len() > 1); - let rejoined = chunks.iter().map(|c| c.trim()).collect::>().join(" "); + let rejoined = chunks + .iter() + .map(|c| c.trim()) + .collect::>() + .join(" "); assert_eq!( rejoined.split_whitespace().collect::>(), line.split_whitespace().collect::>(), @@ -434,7 +438,7 @@ mod tests { #[test] fn fenced_hard_split_grapheme_safe() { // Emoji inside a code fence: grapheme-safe, all content preserved. - let content: String = std::iter::repeat("🎉❤️你").take(20).collect(); + let content: String = "🎉❤️你".repeat(20); let text = format!("```\n{content}\n```"); let chunks = split_message(&text, 20); assert_length_invariant(&chunks, 20); diff --git a/crates/openab-core/src/gateway.rs b/crates/openab-core/src/gateway.rs index e63416b56..f8dc253a7 100644 --- a/crates/openab-core/src/gateway.rs +++ b/crates/openab-core/src/gateway.rs @@ -105,7 +105,10 @@ struct EventFilterParams<'a> { /// Returns `true` if the event should be skipped (filtered out). fn should_skip_event(event: &GatewayEvent, filter: &EventFilterParams) -> bool { // Bot filter - if event.sender.is_bot && !filter.allow_bot_messages && !filter.trusted_bot_ids.contains(&event.sender.id) { + if event.sender.is_bot + && !filter.allow_bot_messages + && !filter.trusted_bot_ids.contains(&event.sender.id) + { tracing::info!(sender = %event.sender.id, "gateway: bot not in trusted_bot_ids, skipping"); return true; } @@ -120,7 +123,8 @@ fn should_skip_event(event: &GatewayEvent, filter: &EventFilterParams) -> bool { return true; } // @mention gating: in groups, only respond if bot is mentioned - let is_group = event.channel.channel_type == "group" || event.channel.channel_type == "supergroup"; + let is_group = + event.channel.channel_type == "group" || event.channel.channel_type == "supergroup"; let in_thread = event.channel.thread_id.is_some(); if is_group && !in_thread { if let Some(bot_name) = filter.bot_username { @@ -244,7 +248,8 @@ fn gateway_delivery_result(resp: GatewayResponse) -> Result { } else { Err(anyhow::anyhow!( "gateway reported failure: {}", - resp.error.unwrap_or_else(|| "unspecified error".to_string()) + resp.error + .unwrap_or_else(|| "unspecified error".to_string()) )) } } @@ -557,7 +562,8 @@ impl ChatAdapter for GatewayAdapter { content: &str, reply_to_message_id: &str, ) -> Result { - self.send_gateway_reply(channel, content, Some(reply_to_message_id)).await + self.send_gateway_reply(channel, content, Some(reply_to_message_id)) + .await } async fn create_thread( @@ -708,10 +714,14 @@ impl ChatAdapter for GatewayAdapter { match tokio::time::timeout( std::time::Duration::from_millis(EDIT_RESPONSE_TIMEOUT_MS), rx, - ).await { + ) + .await + { Ok(Ok(resp)) if resp.success => Ok(()), Ok(Ok(resp)) => { - let err_msg = resp.error.clone() + let err_msg = resp + .error + .clone() .unwrap_or_else(|| "gateway reported edit failure".to_string()); tracing::warn!(request_id = %id, error = %err_msg, "edit_message gateway replied failure"); Err(anyhow::anyhow!("edit failure: {err_msg}")) @@ -1464,7 +1474,11 @@ pub async fn process_gateway_event( } else { event.timestamp.clone() }), - message_id: if event.message_id.is_empty() { None } else { Some(event.message_id.clone()) }, + message_id: if event.message_id.is_empty() { + None + } else { + Some(event.message_id.clone()) + }, receiver_id: None, }; let sender_json = serde_json::to_string(&sender_ctx).unwrap_or_default(); @@ -1505,25 +1519,24 @@ pub async fn process_gateway_event( }; match att.attachment_type.as_str() { - "image" => { - match bytes_result { - Ok(bytes) => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); - extra_blocks.push(ContentBlock::Image { - media_type: att.mime_type.clone(), - data: b64, - }); - } - Err(e) => { - tracing::warn!(filename = %att.filename, error = %e, "gateway image read failed"); - } + "image" => match bytes_result { + Ok(bytes) => { + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + extra_blocks.push(ContentBlock::Image { + media_type: att.mime_type.clone(), + data: b64, + }); } - } + Err(e) => { + tracing::warn!(filename = %att.filename, error = %e, "gateway image read failed"); + } + }, "text_file" => { match bytes_result { Ok(bytes) => { - let safe_filename: String = att.filename + let safe_filename: String = att + .filename .chars() .filter(|c| !c.is_control()) .take(200) @@ -1538,7 +1551,14 @@ pub async fn process_gateway_event( // Large file — upload to filestore if available #[cfg(feature = "filestore")] if let Some(ref fs) = ctx.filestore { - if let Some((block, _)) = crate::media::upload_bytes_to_filestore_public(&att.filename, &bytes, fs).await { + if let Some((block, _)) = + crate::media::upload_bytes_to_filestore_public( + &att.filename, + &bytes, + fs, + ) + .await + { extra_blocks.push(block); } else { // Upload refused (size cap) — emit degraded hint, don't inline oversized body @@ -1572,35 +1592,38 @@ pub async fn process_gateway_event( } } } - "audio" if ctx.stt_config.enabled => { - match bytes_result { - Ok(bytes) => { - match crate::stt::transcribe( - &crate::media::HTTP_CLIENT, - &ctx.stt_config, - bytes, - att.filename.clone(), - &att.mime_type, - ).await { - Some(transcript) => { - extra_blocks.push(ContentBlock::Text { - text: format!("[Voice message transcript]: {transcript}"), - }); - } - None => { - extra_blocks.push(ContentBlock::Text { - text: format!("[Voice message — transcription failed for {}]", att.filename), - }); - } + "audio" if ctx.stt_config.enabled => match bytes_result { + Ok(bytes) => { + match crate::stt::transcribe( + &crate::media::HTTP_CLIENT, + &ctx.stt_config, + bytes, + att.filename.clone(), + &att.mime_type, + ) + .await + { + Some(transcript) => { + extra_blocks.push(ContentBlock::Text { + text: format!("[Voice message transcript]: {transcript}"), + }); + } + None => { + extra_blocks.push(ContentBlock::Text { + text: format!( + "[Voice message — transcription failed for {}]", + att.filename + ), + }); } - } - Err(_) => { - extra_blocks.push(ContentBlock::Text { - text: format!("[Voice message — read failed for {}]", att.filename), - }); } } - } + Err(_) => { + extra_blocks.push(ContentBlock::Text { + text: format!("[Voice message — read failed for {}]", att.filename), + }); + } + }, _ => {} } } @@ -1609,20 +1632,38 @@ pub async fn process_gateway_event( let prompt = event.content.text.clone(); let trimmed = prompt.trim(); if trimmed == "/reset" { - let thread_id_str = event.channel.thread_id.as_deref().unwrap_or(&event.channel.id); + let thread_id_str = event + .channel + .thread_id + .as_deref() + .unwrap_or(&event.channel.id); let thread_key = format!("{}:{}", event.platform, thread_id_str); - let dropped = ctx.dispatcher.cancel_buffered_thread(event.platform.as_str(), thread_id_str); + let dropped = ctx + .dispatcher + .cancel_buffered_thread(event.platform.as_str(), thread_id_str); let msg = match (ctx.router.pool().reset_session(&thread_key).await, dropped) { (Ok(()), 0) => "🔄 Session reset. Start a new conversation!".to_string(), - (Ok(()), n) => format!("🔄 Session reset. Dropped {n} buffered message(s). Start a new conversation!"), + (Ok(()), n) => format!( + "🔄 Session reset. Dropped {n} buffered message(s). Start a new conversation!" + ), (Err(_), 0) => "⚠️ No active session to reset.".to_string(), - (Err(_), n) => format!("🔄 Dropped {n} buffered message(s). No active session to reset."), + (Err(_), n) => { + format!("🔄 Dropped {n} buffered message(s). No active session to reset.") + } }; let _ = ctx.adapter.send_message(&channel, &msg).await; return Ok(false); } if trimmed == "/cancel" { - let thread_key = format!("{}:{}", event.platform, event.channel.thread_id.as_deref().unwrap_or(&event.channel.id)); + let thread_key = format!( + "{}:{}", + event.platform, + event + .channel + .thread_id + .as_deref() + .unwrap_or(&event.channel.id) + ); let msg = match ctx.router.pool().cancel_session(&thread_key).await { Ok(()) => "🛑 Cancel signal sent.".to_string(), Err(e) => format!("⚠️ {e}"), @@ -1631,7 +1672,15 @@ pub async fn process_gateway_event( return Ok(false); } { - let thread_key = format!("{}:{}", event.platform, event.channel.thread_id.as_deref().unwrap_or(&event.channel.id)); + let thread_key = format!( + "{}:{}", + event.platform, + event + .channel + .thread_id + .as_deref() + .unwrap_or(&event.channel.id) + ); if let Some(msg) = handle_config_command(trimmed, &ctx.router, &thread_key).await { let _ = ctx.adapter.send_message(&channel, &msg).await; return Ok(false); @@ -1645,32 +1694,26 @@ pub async fn process_gateway_event( let sender_id = event.sender.id.clone(); tokio::spawn(async move { - let thread_channel = if event.channel.channel_type == "supergroup" - && channel.thread_id.is_none() - { - let title = crate::format::shorten_thread_name(&prompt); - match adapter.create_thread(&channel, &trigger_msg, &title).await { - Ok(tc) => tc, - Err(e) => { - tracing::warn!("create_thread failed, replying in channel: {e}"); - channel.clone() + let thread_channel = + if event.channel.channel_type == "supergroup" && channel.thread_id.is_none() { + let title = crate::format::shorten_thread_name(&prompt); + match adapter.create_thread(&channel, &trigger_msg, &title).await { + Ok(tc) => tc, + Err(e) => { + tracing::warn!("create_thread failed, replying in channel: {e}"); + channel.clone() + } } - } - } else { - channel.clone() - }; + } else { + channel.clone() + }; let thread_id = thread_channel .thread_id .as_deref() .unwrap_or(&thread_channel.channel_id); - let thread_key = dispatcher.key( - &thread_channel.platform, - thread_id, - &sender_id, - ); - let estimated_tokens = - crate::dispatch::estimate_tokens(&prompt, &extra_blocks); + let thread_key = dispatcher.key(&thread_channel.platform, thread_id, &sender_id); + let estimated_tokens = crate::dispatch::estimate_tokens(&prompt, &extra_blocks); let buf_msg = crate::dispatch::BufferedMessage { sender_json, sender_name, @@ -1822,7 +1865,14 @@ mod tests { assert!(!echo_allowed(key), "still throttled within the window"); } - fn make_event(is_bot: bool, sender_id: &str, channel_id: &str, channel_type: &str, thread_id: Option<&str>, mentions: Vec<&str>) -> GatewayEvent { + fn make_event( + is_bot: bool, + sender_id: &str, + channel_id: &str, + channel_type: &str, + thread_id: Option<&str>, + mentions: Vec<&str>, + ) -> GatewayEvent { serde_json::from_value(serde_json::json!({ "schema": "openab.gateway.event.v1", "event_id": "evt1", @@ -1833,10 +1883,15 @@ mod tests { "content": { "type": "text", "text": "hello" }, "mentions": mentions, "message_id": "msg1" - })).unwrap() + })) + .unwrap() } - fn default_filter<'a>(allowed_channels: &'a HashSet, allowed_users: &'a HashSet, trusted_bot_ids: &'a HashSet) -> EventFilterParams<'a> { + fn default_filter<'a>( + allowed_channels: &'a HashSet, + allowed_users: &'a HashSet, + trusted_bot_ids: &'a HashSet, + ) -> EventFilterParams<'a> { EventFilterParams { allow_all_channels: true, allowed_channels, diff --git a/crates/openab-core/src/hooks.rs b/crates/openab-core/src/hooks.rs index 164ce39d0..51a63ab7d 100644 --- a/crates/openab-core/src/hooks.rs +++ b/crates/openab-core/src/hooks.rs @@ -111,9 +111,10 @@ fn write_temp_script(name: &str, content: &str) -> anyhow::Result { let mut f = builder.tempfile()?; f.write_all(content.as_bytes())?; - let path = f.into_temp_path().keep().map_err(|e| { - anyhow::anyhow!("failed to persist temp script: {}", e.error) - })?; + let path = f + .into_temp_path() + .keep() + .map_err(|e| anyhow::anyhow!("failed to persist temp script: {}", e.error))?; Ok(path) } @@ -128,9 +129,7 @@ async fn fetch_and_verify(url: &str, expected_hex: &str) -> anyhow::Result MAX_SCRIPT_SIZE { - anyhow::bail!( - "hook script too large: {content_length} bytes (max {MAX_SCRIPT_SIZE})" - ); + anyhow::bail!("hook script too large: {content_length} bytes (max {MAX_SCRIPT_SIZE})"); } let body = resp.bytes().await?; if body.len() > MAX_SCRIPT_SIZE { diff --git a/crates/openab-core/src/lib.rs b/crates/openab-core/src/lib.rs index 0e61e7cb2..52c3c3925 100644 --- a/crates/openab-core/src/lib.rs +++ b/crates/openab-core/src/lib.rs @@ -1,14 +1,15 @@ pub mod acp; -pub mod adapter; #[cfg(feature = "acp-mcp")] pub mod acp_mcp; -pub mod redact; +pub mod adapter; pub mod bot_turns; pub mod config; pub mod cron; pub mod directives; pub mod dispatch; pub mod error_display; +#[cfg(feature = "filestore")] +pub mod filestore; pub mod format; pub mod gateway; pub mod hooks; @@ -17,9 +18,8 @@ pub mod media; pub mod multibot_cache; #[cfg(feature = "pre-seed")] pub mod pre_seed; -#[cfg(feature = "filestore")] -pub mod filestore; pub mod reactions; +pub mod redact; #[cfg(feature = "discord")] pub mod remind; pub mod secrets; @@ -28,9 +28,9 @@ pub mod stt; pub mod timestamp; pub mod trust; -#[cfg(feature = "discord")] -pub mod discord; #[cfg(feature = "discord")] pub mod ambient; +#[cfg(feature = "discord")] +pub mod discord; #[cfg(feature = "slack")] pub mod slack; diff --git a/crates/openab-core/src/media.rs b/crates/openab-core/src/media.rs index cadc56445..42b1da6d1 100644 --- a/crates/openab-core/src/media.rs +++ b/crates/openab-core/src/media.rs @@ -98,10 +98,7 @@ fn hex_prefix(body: &[u8]) -> String { /// (`image/*`) because CDNs commonly serve any file type as `application/octet-stream`; /// rejecting that header would silently break real downloads. The magic-byte check /// examines the actual bytes regardless of what the server claims. -fn validate_image_response( - content_type: Option<&str>, - body: &[u8], -) -> Result<(), MediaFetchError> { +fn validate_image_response(content_type: Option<&str>, body: &[u8]) -> Result<(), MediaFetchError> { // Reject explicitly-text responses early (e.g. Slack HTML login page at HTTP 200). // application/octet-stream and other generic types pass through to magic-byte check. if let Some(ct) = content_type { @@ -344,7 +341,11 @@ pub async fn download_and_transcribe( }; if bytes.len() as u64 > MAX_SIZE { - error!(filename, size = bytes.len(), "downloaded audio exceeds 25MB limit"); + error!( + filename, + size = bytes.len(), + "downloaded audio exceeds 25MB limit" + ); return None; } @@ -574,15 +575,27 @@ async fn download_text_file_inner( let stream = Box::pin(resp.bytes_stream()); let upload_result = tokio::time::timeout( STREAM_TIMEOUT, - fs.stream_upload_and_presign(filename, stream, content_length, Some("text/plain; charset=utf-8")), + fs.stream_upload_and_presign( + filename, + stream, + content_length, + Some("text/plain; charset=utf-8"), + ), ) .await; return match upload_result { Ok(Ok((presigned_url, actual_bytes))) => { let hint = crate::filestore::format_filestore_hint( - filename, actual_bytes, &presigned_url, fs.presigned_ttl_secs(), + filename, + actual_bytes, + &presigned_url, + fs.presigned_ttl_secs(), + ); + tracing::info!( + filename, + size = actual_bytes, + "text file streamed to filestore (inline fallback)" ); - tracing::info!(filename, size = actual_bytes, "text file streamed to filestore (inline fallback)"); Some((ContentBlock::Text { text: hint }, 0)) } Ok(Err(e)) => { @@ -596,7 +609,10 @@ async fn download_text_file_inner( Some((ContentBlock::Text { text: hint }, 0)) } Err(_) => { - tracing::error!(filename, "filestore stream upload timed out (inline fallback)"); + tracing::error!( + filename, + "filestore stream upload timed out (inline fallback)" + ); let size_kb = content_length / 1024; let hint = format!( "[File: {safe_filename}]\n\ @@ -739,7 +755,12 @@ async fn download_and_upload_to_filestore( // Cap file size to prevent abuse (configurable, default 250 MB, max 500 MB). let max_size = filestore.max_file_size(); if size > max_size { - tracing::warn!(filename, size, max = max_size, "text file exceeds filestore size limit, skipping"); + tracing::warn!( + filename, + size, + max = max_size, + "text file exceeds filestore size limit, skipping" + ); return None; } @@ -789,7 +810,12 @@ async fn download_and_upload_to_filestore( let upload_result = tokio::time::timeout( STREAM_TIMEOUT, - filestore.stream_upload_and_presign(filename, stream, size, Some("text/plain; charset=utf-8")), + filestore.stream_upload_and_presign( + filename, + stream, + size, + Some("text/plain; charset=utf-8"), + ), ) .await; @@ -801,7 +827,11 @@ async fn download_and_upload_to_filestore( &presigned_url, filestore.presigned_ttl_secs(), ); - tracing::info!(filename, size = actual_bytes, "text file streamed to filestore"); + tracing::info!( + filename, + size = actual_bytes, + "text file streamed to filestore" + ); Some((ContentBlock::Text { text: hint }, 0)) } Ok(Err(e)) => { @@ -856,7 +886,12 @@ pub async fn download_and_upload_any_file( ) -> Option<(ContentBlock, u64)> { let max_size = filestore.max_file_size(); if size > max_size { - tracing::warn!(filename, size, max = max_size, "file exceeds filestore size limit, skipping"); + tracing::warn!( + filename, + size, + max = max_size, + "file exceeds filestore size limit, skipping" + ); return None; } @@ -881,7 +916,12 @@ pub async fn download_and_upload_any_file( // Content-Length pre-check if let Some(content_length) = resp.content_length() { if content_length > max_size { - tracing::warn!(filename, content_length, max = max_size, "Content-Length exceeds filestore limit"); + tracing::warn!( + filename, + content_length, + max = max_size, + "Content-Length exceeds filestore limit" + ); return None; } } @@ -922,7 +962,12 @@ pub async fn download_and_upload_any_file( Note: this URL expires in {} minutes.", filestore.presigned_ttl_secs() / 60 ); - tracing::info!(filename, mime, size = actual_bytes, "file uploaded to filestore (any-file path)"); + tracing::info!( + filename, + mime, + size = actual_bytes, + "file uploaded to filestore (any-file path)" + ); Some((ContentBlock::Text { text: hint }, 0)) } Ok(Err(e)) => { @@ -984,7 +1029,11 @@ async fn upload_bytes_to_filestore( &presigned_url, filestore.presigned_ttl_secs(), ); - tracing::info!(filename, size = actual_size, "text file uploaded to filestore"); + tracing::info!( + filename, + size = actual_size, + "text file uploaded to filestore" + ); // Return 0 inline bytes — the file is stored externally, not inlined // into the prompt. This prevents it from counting against the // caller's aggregate size cap. diff --git a/crates/openab-core/src/multibot_cache.rs b/crates/openab-core/src/multibot_cache.rs index a9bd8c82e..05d01e027 100644 --- a/crates/openab-core/src/multibot_cache.rs +++ b/crates/openab-core/src/multibot_cache.rs @@ -61,7 +61,9 @@ impl MultibotCache { threads.clone() }; let path = self.path.clone(); - tokio::task::spawn_blocking(move || persist(&path, &snapshot)).await.ok(); + tokio::task::spawn_blocking(move || persist(&path, &snapshot)) + .await + .ok(); } } diff --git a/crates/openab-core/src/redact.rs b/crates/openab-core/src/redact.rs index 85954da12..7a23a7510 100644 --- a/crates/openab-core/src/redact.rs +++ b/crates/openab-core/src/redact.rs @@ -24,9 +24,14 @@ /// identifier in a log at all. pub fn redact_session_ids(s: &str) -> String { s.split(':') - .map(|seg| match seg.strip_prefix("acp_").or_else(|| seg.strip_prefix("sess_")) { - Some(uuid) if !uuid.is_empty() => hash_tag(uuid), - _ => seg.to_string(), + .map(|seg| { + match seg + .strip_prefix("acp_") + .or_else(|| seg.strip_prefix("sess_")) + { + Some(uuid) if !uuid.is_empty() => hash_tag(uuid), + _ => seg.to_string(), + } }) .collect::>() .join(":") @@ -53,7 +58,10 @@ mod tests { fn both_encodings_hash_alike_and_everything_else_passes_through() { let u = "00000000-0000-0000-0000-000000000000"; let tag = redact_session_ids(&format!("acp_{u}")); - assert!(tag.starts_with('#') && tag.len() == 9, "expected #<8hex>, got {tag}"); + assert!( + tag.starts_with('#') && tag.len() == 9, + "expected #<8hex>, got {tag}" + ); assert_eq!( redact_session_ids(&format!("sess_{u}")), tag, @@ -65,10 +73,22 @@ mod tests { format!("acp:{tag}"), "a : pool key must redact the id half and keep the platform greppable" ); - assert_eq!(redact_session_ids("1234567890"), "1234567890", "public ids stay greppable"); - assert_eq!(redact_session_ids("-"), "-", "the no-session sentinel is not a session"); + assert_eq!( + redact_session_ids("1234567890"), + "1234567890", + "public ids stay greppable" + ); + assert_eq!( + redact_session_ids("-"), + "-", + "the no-session sentinel is not a session" + ); assert_eq!(redact_session_ids(""), "", "empty in, empty out"); - assert_eq!(redact_session_ids("acp_"), "acp_", "a bare prefix carries no uuid to hide"); + assert_eq!( + redact_session_ids("acp_"), + "acp_", + "a bare prefix carries no uuid to hide" + ); assert_eq!( redact_session_ids("discord:1234567890"), "discord:1234567890", diff --git a/crates/openab-core/src/remind.rs b/crates/openab-core/src/remind.rs index 9472c53d8..b4c43495b 100644 --- a/crates/openab-core/src/remind.rs +++ b/crates/openab-core/src/remind.rs @@ -108,7 +108,9 @@ pub fn sanitize_message(msg: &str) -> String { /// Validate reminder message length. pub fn validate_message(msg: &str) -> Result<(), String> { if msg.len() > MAX_MESSAGE_LEN { - Err(format!("message too long (max {MAX_MESSAGE_LEN} characters)")) + Err(format!( + "message too long (max {MAX_MESSAGE_LEN} characters)" + )) } else { Ok(()) } @@ -130,7 +132,9 @@ pub fn parse_delay(input: &str) -> Result { if ch.is_ascii_digit() { num_buf.push(ch); } else { - let n: u64 = num_buf.parse().map_err(|_| format!("invalid number in delay: {input}"))?; + let n: u64 = num_buf + .parse() + .map_err(|_| format!("invalid number in delay: {input}"))?; num_buf.clear(); let multiplier = match ch { 'm' => 60, @@ -144,7 +148,9 @@ pub fn parse_delay(input: &str) -> Result { // Handle bare number (default to minutes) if !num_buf.is_empty() { - let n: u64 = num_buf.parse().map_err(|_| format!("invalid number in delay: {input}"))?; + let n: u64 = num_buf + .parse() + .map_err(|_| format!("invalid number in delay: {input}"))?; total_secs += n * 60; // default unit = minutes } @@ -164,18 +170,24 @@ pub fn format_delay(secs: u64) -> String { let h = (secs % 86400) / 3600; let m = (secs % 3600) / 60; let mut parts = Vec::new(); - if d > 0 { parts.push(format!("{d}d")); } - if h > 0 { parts.push(format!("{h}h")); } - if m > 0 { parts.push(format!("{m}m")); } - if parts.is_empty() { "< 1m".into() } else { parts.join(" ") } + if d > 0 { + parts.push(format!("{d}d")); + } + if h > 0 { + parts.push(format!("{h}h")); + } + if m > 0 { + parts.push(format!("{m}m")); + } + if parts.is_empty() { + "< 1m".into() + } else { + parts.join(" ") + } } /// Spawn a tokio task that fires the reminder after the delay. -pub fn schedule_reminder( - http: Arc, - store: ReminderStore, - reminder: Reminder, -) { +pub fn schedule_reminder(http: Arc, store: ReminderStore, reminder: Reminder) { let now = Utc::now(); let delay = if reminder.fire_at > now { (reminder.fire_at - now).to_std().unwrap_or_default() @@ -370,9 +382,18 @@ mod tests { #[test] fn sanitize_message_strips_everyone_here() { - assert_eq!(sanitize_message("hello @everyone"), "hello @\u{200b}everyone"); - assert_eq!(sanitize_message("hey @here check"), "hey @\u{200b}here check"); - assert_eq!(sanitize_message("@everyone @here"), "@\u{200b}everyone @\u{200b}here"); + assert_eq!( + sanitize_message("hello @everyone"), + "hello @\u{200b}everyone" + ); + assert_eq!( + sanitize_message("hey @here check"), + "hey @\u{200b}here check" + ); + assert_eq!( + sanitize_message("@everyone @here"), + "@\u{200b}everyone @\u{200b}here" + ); } #[test] diff --git a/crates/openab-core/src/secrets.rs b/crates/openab-core/src/secrets.rs index e6a7967fd..bb5b06226 100644 --- a/crates/openab-core/src/secrets.rs +++ b/crates/openab-core/src/secrets.rs @@ -23,9 +23,9 @@ pub async fn resolve(cfg: &SecretsConfig) -> anyhow::Result { let value = if uri.starts_with("aws-sm://") { #[cfg(feature = "secrets-aws")] { - let client = aws_client.as_ref().ok_or_else(|| { - anyhow::anyhow!("secret '{key}': AWS client not initialized") - })?; + let client = aws_client + .as_ref() + .ok_or_else(|| anyhow::anyhow!("secret '{key}': AWS client not initialized"))?; resolve_aws_sm(key, uri, client).await? } #[cfg(not(feature = "secrets-aws"))] @@ -81,12 +81,16 @@ async fn resolve_aws_sm( .await .map_err(|e| { error!(secret = key, secret_id = %secret_id, "AWS Secrets Manager error"); - anyhow::anyhow!("secret '{key}': failed to fetch '{secret_id}' from AWS Secrets Manager: {e}") + anyhow::anyhow!( + "secret '{key}': failed to fetch '{secret_id}' from AWS Secrets Manager: {e}" + ) })?; - let secret_string = resp - .secret_string() - .ok_or_else(|| anyhow::anyhow!("secret '{key}': '{secret_id}' has no string value (binary secrets not supported)"))?; + let secret_string = resp.secret_string().ok_or_else(|| { + anyhow::anyhow!( + "secret '{key}': '{secret_id}' has no string value (binary secrets not supported)" + ) + })?; // Parse as JSON and extract the key let json: serde_json::Value = serde_json::from_str(secret_string) @@ -95,7 +99,9 @@ async fn resolve_aws_sm( let value = json .get(&json_key) .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("secret '{key}': JSON key '{json_key}' not found in '{secret_id}'"))?; + .ok_or_else(|| { + anyhow::anyhow!("secret '{key}': JSON key '{json_key}' not found in '{secret_id}'") + })?; Ok(value.to_owned()) } @@ -117,9 +123,9 @@ fn parse_aws_sm_uri(uri: &str) -> Option<(String, String)> { async fn resolve_exec(key: &str, uri: &str, cfg: &SecretsConfig) -> anyhow::Result { let rest = uri.strip_prefix("exec://").unwrap(); let mut parts_iter = rest.splitn(3, ' '); - let script = parts_iter.next().ok_or_else(|| { - anyhow::anyhow!("secret '{key}': exec:// URI missing script path") - })?; + let script = parts_iter + .next() + .ok_or_else(|| anyhow::anyhow!("secret '{key}': exec:// URI missing script path"))?; if script.is_empty() { anyhow::bail!("secret '{key}': exec:// URI has empty script path"); } @@ -240,9 +246,13 @@ mod tests { #[cfg(feature = "secrets-aws")] #[test] fn parse_aws_sm_uri_with_arn() { - let uri = "aws-sm://arn:aws:secretsmanager:us-east-1:123456789:secret:my-secret-abc123#api_key"; + let uri = + "aws-sm://arn:aws:secretsmanager:us-east-1:123456789:secret:my-secret-abc123#api_key"; let (id, key) = parse_aws_sm_uri(uri).unwrap(); - assert_eq!(id, "arn:aws:secretsmanager:us-east-1:123456789:secret:my-secret-abc123"); + assert_eq!( + id, + "arn:aws:secretsmanager:us-east-1:123456789:secret:my-secret-abc123" + ); assert_eq!(key, "api_key"); } @@ -349,8 +359,7 @@ mod tests { exec: ExecSecretsConfig { timeout_seconds: 5 }, refs: HashMap::new(), }; - let result = - resolve_exec("test", "exec:///nonexistent/script arg1 arg2", &cfg).await; + let result = resolve_exec("test", "exec:///nonexistent/script arg1 arg2", &cfg).await; let err = result.unwrap_err().to_string(); assert!(err.contains("not found")); assert!(err.contains("pre_boot")); @@ -406,7 +415,10 @@ mod tests { }; let result = resolve(&cfg).await; assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("unrecognized URI scheme")); + assert!(result + .unwrap_err() + .to_string() + .contains("unrecognized URI scheme")); } #[cfg(feature = "secrets-aws")] @@ -430,7 +442,9 @@ mod tests { refs: HashMap::new(), }; // /usr/bin/env prints all env vars; grep for our dummy var - let result = resolve_exec("test", "exec:///usr/bin/env", &cfg).await.unwrap(); + let result = resolve_exec("test", "exec:///usr/bin/env", &cfg) + .await + .unwrap(); assert!( !result.contains("OPENAB_TEST_LEAKED_SECRET"), "exec script should not see unrelated env vars" @@ -452,7 +466,10 @@ mod tests { // sleep 10 will be killed after 1s timeout let result = resolve_exec("test", "exec:///bin/sleep 10", &cfg).await; let err = result.unwrap_err().to_string(); - assert!(err.contains("timed out"), "expected timeout error, got: {err}"); + assert!( + err.contains("timed out"), + "expected timeout error, got: {err}" + ); } #[test] @@ -471,8 +488,8 @@ args = ["--key", "${secrets.key}"] "#; let substituted = substitute(raw, &secrets); // Verify the substituted text is valid TOML that parses correctly - let cfg: crate::config::Config = toml::from_str(&substituted) - .expect("substituted config should be valid TOML"); + let cfg: crate::config::Config = + toml::from_str(&substituted).expect("substituted config should be valid TOML"); assert_eq!(cfg.discord.unwrap().bot_token, "xoxb-secret-value"); assert_eq!(cfg.agent.args[1], "sk-with\"special\\chars"); } diff --git a/crates/openab-core/src/slack.rs b/crates/openab-core/src/slack.rs index 04af2d9bc..012b85caa 100644 --- a/crates/openab-core/src/slack.rs +++ b/crates/openab-core/src/slack.rs @@ -144,7 +144,6 @@ impl SlackAdapter { self.multibot_cache.mark_multibot(thread_ts).await; } - /// Insert a stream entry, bounding the map so aborted turns (begin without a /// matching finish) can't leak unboundedly. Normal lifecycle: stream_begin /// inserts, stream_finish removes. @@ -451,7 +450,8 @@ impl ChatAdapter for SlackAdapter { // instead of failing outright. Err(e) if is_block_payload_rejected(&e) => { warn!(error = %e, "markdown block rejected; retrying chat.postMessage text-only"); - let fallback = build_post_message_text_only(&channel.channel_id, thread_ts, content); + let fallback = + build_post_message_text_only(&channel.channel_id, thread_ts, content); self.api_post("chat.postMessage", fallback).await? } Err(e) => return Err(e), @@ -591,7 +591,10 @@ impl ChatAdapter for SlackAdapter { if let Some(ts) = resp["ts"].as_str() { self.insert_stream( ts.to_string(), - StreamEntry { active: true, degraded_buf: String::new() }, + StreamEntry { + active: true, + degraded_buf: String::new(), + }, ) .await; return Ok(make_ref(ts.to_string())); @@ -605,14 +608,20 @@ impl ChatAdapter for SlackAdapter { } else { // Expected for bot-authored turns (no recipient bound) and non-user // triggers, so warn! rather than error! to avoid on-call noise. - warn!(thread_ts, "no recipient for turn; falling back to post+edit"); + warn!( + thread_ts, + "no recipient for turn; falling back to post+edit" + ); } // Degraded fallback: plain placeholder via send_message; mark inactive. let msg = self.send_message(channel, "…").await?; self.insert_stream( msg.message_id.clone(), - StreamEntry { active: false, degraded_buf: String::new() }, + StreamEntry { + active: false, + degraded_buf: String::new(), + }, ) .await; Ok(msg) @@ -1596,14 +1605,11 @@ async fn handle_message( size, Some(bot_token), filestore, - ).await; + ) + .await; #[cfg(not(feature = "filestore"))] - let text_file_result = media::download_and_read_text_file( - url, - filename, - size, - Some(bot_token), - ).await; + let text_file_result = + media::download_and_read_text_file(url, filename, size, Some(bot_token)).await; if let Some((block, actual_bytes)) = text_file_result { if text_file_bytes + actual_bytes > TEXT_TOTAL_CAP { debug!( @@ -1652,7 +1658,9 @@ async fn handle_message( Some(mimetype), Some(bot_token), fs, - ).await { + ) + .await + { extra_blocks.push(block); } } @@ -1676,9 +1684,7 @@ async fn handle_message( warn!(filename, error = %e, "image post-processing failed"); failed_image_files.push(filename.to_string()); } - Err(media::MediaFetchError::HttpStatus(status)) - if status.is_client_error() => - { + Err(media::MediaFetchError::HttpStatus(status)) if status.is_client_error() => { warn!(filename, %status, "image download denied"); failed_image_files.push(filename.to_string()); } @@ -1880,7 +1886,10 @@ fn strip_mime_params(mimetype: &str) -> &str { /// mrkdwn delimiters; without escaping, `` or `` `<@U123>` `` would render /// as mentions or @-here pings. pub(crate) fn sanitize_slack_filename(s: &str) -> String { - s.replace('&', "&").replace('`', "'").replace('<', "(").replace('>', ")") + s.replace('&', "&") + .replace('`', "'") + .replace('<', "(") + .replace('>', ")") } /// Returns `true` if `text` contains a Slack user mention for `uid`. @@ -1891,8 +1900,12 @@ pub(crate) fn sanitize_slack_filename(s: &str) -> String { /// misses it. fn text_mentions_uid(text: &str, uid: &str) -> bool { let prefix = format!("<@{uid}"); - text.match_indices(&prefix) - .any(|(i, _)| matches!(text.as_bytes().get(i + prefix.len()), Some(b'>') | Some(b'|'))) + text.match_indices(&prefix).any(|(i, _)| { + matches!( + text.as_bytes().get(i + prefix.len()), + Some(b'>') | Some(b'|') + ) + }) } fn bot_id_matches_trusted( @@ -2061,7 +2074,12 @@ fn markdown_to_mrkdwn(text: &str) -> String { text.into_owned() } -fn build_start_stream_body(channel: &str, thread_ts: &str, user_id: &str, team_id: &str) -> serde_json::Value { +fn build_start_stream_body( + channel: &str, + thread_ts: &str, + user_id: &str, + team_id: &str, +) -> serde_json::Value { serde_json::json!({ "channel": channel, "thread_ts": thread_ts, @@ -2141,13 +2159,29 @@ mod tests { #[tokio::test] async fn degraded_stream_append_accumulates() { - let adapter = SlackAdapter::new("xoxb-test".into(), std::time::Duration::from_secs(60), AllowBots::Off, true, crate::multibot_cache::MultibotCache::load("/dev/null".into()), true); + let adapter = SlackAdapter::new( + "xoxb-test".into(), + std::time::Duration::from_secs(60), + AllowBots::Off, + true, + crate::multibot_cache::MultibotCache::load("/dev/null".into()), + true, + ); adapter.streams.lock().await.insert( "TS".into(), - StreamEntry { active: false, degraded_buf: String::new() }, + StreamEntry { + active: false, + degraded_buf: String::new(), + }, + ); + assert_eq!( + adapter.accumulate_degraded("TS", "a").await.as_deref(), + Some("a") + ); + assert_eq!( + adapter.accumulate_degraded("TS", "b").await.as_deref(), + Some("ab") ); - assert_eq!(adapter.accumulate_degraded("TS", "a").await.as_deref(), Some("a")); - assert_eq!(adapter.accumulate_degraded("TS", "b").await.as_deref(), Some("ab")); // missing stream is not resurrected: assert_eq!(adapter.accumulate_degraded("MISSING", "x").await, None); } @@ -2238,7 +2272,10 @@ mod tests { #[test] fn mentions_uid_labelled_form_mid_sentence() { - assert!(text_mentions_uid("please ask <@U123BOT|handle> to run", "U123BOT")); + assert!(text_mentions_uid( + "please ask <@U123BOT|handle> to run", + "U123BOT" + )); } #[test] @@ -2356,7 +2393,10 @@ mod tests { #[test] fn sanitize_leaves_normal_filename_unchanged() { assert_eq!(sanitize_slack_filename("photo.png"), "photo.png"); - assert_eq!(sanitize_slack_filename("my file (1).jpg"), "my file (1).jpg"); + assert_eq!( + sanitize_slack_filename("my file (1).jpg"), + "my file (1).jpg" + ); } #[test] @@ -2374,10 +2414,7 @@ mod tests { #[test] fn sanitize_combined_injection_attempt() { // A filename constructed to inject a Slack @here ping. - assert_eq!( - sanitize_slack_filename("``"), - "'(!here)'" - ); + assert_eq!(sanitize_slack_filename("``"), "'(!here)'"); } #[test] @@ -2386,8 +2423,14 @@ mod tests { // "<@here>" would round-trip back to "<@here>" and trigger a mention // ping if & is not escaped. The & must be escaped first so downstream // Slack entity decoding cannot reconstruct a mrkdwn delimiter. - assert_eq!(sanitize_slack_filename("<@here>"), "&lt;@here&gt;"); - assert_eq!(sanitize_slack_filename("file&name.png"), "file&name.png"); + assert_eq!( + sanitize_slack_filename("<@here>"), + "&lt;@here&gt;" + ); + assert_eq!( + sanitize_slack_filename("file&name.png"), + "file&name.png" + ); } // --- strip_mime_params tests --- @@ -2427,11 +2470,7 @@ mod tests { #[test] fn trusted_bot_ids_accepts_resolved_bot_user_id() { let trusted = HashSet::from(["U123BOT".to_string()]); - assert!(bot_id_matches_trusted( - &trusted, - "B123BOT", - Some("U123BOT") - )); + assert!(bot_id_matches_trusted(&trusted, "B123BOT", Some("U123BOT"))); } #[test] @@ -2450,7 +2489,14 @@ mod tests { #[test] fn streaming_per_thread() { let ttl = std::time::Duration::from_secs(300); - let adapter = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Mentions, false, crate::multibot_cache::MultibotCache::load("/dev/null".into()), true); + let adapter = SlackAdapter::new( + "xoxb-test".into(), + ttl, + AllowBots::Mentions, + false, + crate::multibot_cache::MultibotCache::load("/dev/null".into()), + true, + ); assert!( adapter.use_streaming(false), @@ -2467,22 +2513,70 @@ mod tests { let ttl = std::time::Duration::from_secs(60); // assistant_mode=true → status API on; native streaming on (no other bot), // off when another bot is present; post+edit streaming on regardless. - let adapter = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Off, true, crate::multibot_cache::MultibotCache::load("/dev/null".into()), true); - assert!(adapter.uses_assistant_status(), "assistant_mode enables status API"); - assert!(adapter.use_streaming(false), "post+edit streaming on when no other bot"); - assert!(adapter.uses_native_streaming(false), "native streaming on when no other bot"); - assert!(!adapter.uses_native_streaming(true), "other bot present disables native"); + let adapter = SlackAdapter::new( + "xoxb-test".into(), + ttl, + AllowBots::Off, + true, + crate::multibot_cache::MultibotCache::load("/dev/null".into()), + true, + ); + assert!( + adapter.uses_assistant_status(), + "assistant_mode enables status API" + ); + assert!( + adapter.use_streaming(false), + "post+edit streaming on when no other bot" + ); + assert!( + adapter.uses_native_streaming(false), + "native streaming on when no other bot" + ); + assert!( + !adapter.uses_native_streaming(true), + "other bot present disables native" + ); // assistant_mode=false → no status API, no native streaming; post+edit still streams. - let adapter2 = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Off, false, crate::multibot_cache::MultibotCache::load("/dev/null".into()), true); + let adapter2 = SlackAdapter::new( + "xoxb-test".into(), + ttl, + AllowBots::Off, + false, + crate::multibot_cache::MultibotCache::load("/dev/null".into()), + true, + ); assert!(!adapter2.uses_assistant_status()); - assert!(adapter2.use_streaming(false), "post+edit streaming independent of assistant_mode"); - assert!(!adapter2.uses_native_streaming(false), "native streaming requires assistant_mode"); + assert!( + adapter2.use_streaming(false), + "post+edit streaming independent of assistant_mode" + ); + assert!( + !adapter2.uses_native_streaming(false), + "native streaming requires assistant_mode" + ); // streaming=false → send-once: neither post+edit nor native, even alone. - let adapter3 = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Off, true, crate::multibot_cache::MultibotCache::load("/dev/null".into()), false); - assert!(!adapter3.use_streaming(false), "streaming=false forces send-once (no post+edit)"); - assert!(!adapter3.uses_native_streaming(false), "streaming=false disables native even with assistant_mode"); - assert!(adapter3.uses_assistant_status(), "streaming switch does not affect assistant status API"); + let adapter3 = SlackAdapter::new( + "xoxb-test".into(), + ttl, + AllowBots::Off, + true, + crate::multibot_cache::MultibotCache::load("/dev/null".into()), + false, + ); + assert!( + !adapter3.use_streaming(false), + "streaming=false forces send-once (no post+edit)" + ); + assert!( + !adapter3.uses_native_streaming(false), + "streaming=false disables native even with assistant_mode" + ); + assert!( + adapter3.uses_assistant_status(), + "streaming switch does not affect assistant status API" + ); } /// chat.postMessage body carries Block Kit `markdown` blocks with the raw @@ -2495,7 +2589,10 @@ mod tests { assert_eq!(b["blocks"][0]["type"], "markdown"); // Raw markdown preserved — heading is NOT flattened to `*Heading*`. assert_eq!(b["blocks"][0]["text"], "## Heading\n- item"); - assert!(b["text"].is_string(), "text fallback present for a11y/notifs"); + assert!( + b["text"].is_string(), + "text fallback present for a11y/notifs" + ); } /// thread_ts is omitted (top-level post) when the channel has no thread. @@ -2536,7 +2633,14 @@ mod tests { #[test] fn typical_long_table_stays_in_one_chunk() { let ttl = std::time::Duration::from_secs(300); - let adapter = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Mentions, true, crate::multibot_cache::MultibotCache::load("/dev/null".into()), true); + let adapter = SlackAdapter::new( + "xoxb-test".into(), + ttl, + AllowBots::Mentions, + true, + crate::multibot_cache::MultibotCache::load("/dev/null".into()), + true, + ); let limit = adapter.message_limit(); assert_eq!(limit, MARKDOWN_BLOCK_LIMIT); let mut table = String::from("| col a | col b | col c |\n|---|---|---|\n"); @@ -2588,7 +2692,9 @@ mod tests { // Exact error-code match, not substring: a future code that merely // contains `invalid_blocks` must NOT trigger a text-only retry. assert!( - !is_block_payload_rejected(&anyhow!("Slack API chat.postMessage: invalid_blocks_field")), + !is_block_payload_rejected(&anyhow!( + "Slack API chat.postMessage: invalid_blocks_field" + )), "must match the error code exactly, not as a substring" ); } @@ -2598,7 +2704,14 @@ mod tests { #[test] fn slack_renders_native_tables() { let ttl = std::time::Duration::from_secs(300); - let adapter = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Mentions, true, crate::multibot_cache::MultibotCache::load("/dev/null".into()), true); + let adapter = SlackAdapter::new( + "xoxb-test".into(), + ttl, + AllowBots::Mentions, + true, + crate::multibot_cache::MultibotCache::load("/dev/null".into()), + true, + ); assert!(adapter.renders_native_tables("slack")); } } @@ -2622,7 +2735,19 @@ mod socket_keepalive_tests { cur }) .collect(); - assert_eq!(seq, vec![1, 2, 4, 8, 16, MAX_BACKOFF_SECS, MAX_BACKOFF_SECS, MAX_BACKOFF_SECS]); + assert_eq!( + seq, + vec![ + 1, + 2, + 4, + 8, + 16, + MAX_BACKOFF_SECS, + MAX_BACKOFF_SECS, + MAX_BACKOFF_SECS + ] + ); assert_eq!(next_backoff(MAX_BACKOFF_SECS), MAX_BACKOFF_SECS); } @@ -2632,9 +2757,15 @@ mod socket_keepalive_tests { fn idle_detects_half_open_at_boundary() { let timeout = Duration::from_secs(IDLE_TIMEOUT_SECS); assert!(!socket_idle(Duration::from_secs(0), timeout)); - assert!(!socket_idle(Duration::from_secs(IDLE_TIMEOUT_SECS - 1), timeout)); + assert!(!socket_idle( + Duration::from_secs(IDLE_TIMEOUT_SECS - 1), + timeout + )); assert!(socket_idle(Duration::from_secs(IDLE_TIMEOUT_SECS), timeout)); - assert!(socket_idle(Duration::from_secs(IDLE_TIMEOUT_SECS + 10), timeout)); + assert!(socket_idle( + Duration::from_secs(IDLE_TIMEOUT_SECS + 10), + timeout + )); } /// Regression test for #1279: a stalled WebSocket handshake during reconnect diff --git a/crates/openab-core/src/trust.rs b/crates/openab-core/src/trust.rs index 5d3ab343b..e34a842e0 100644 --- a/crates/openab-core/src/trust.rs +++ b/crates/openab-core/src/trust.rs @@ -206,10 +206,10 @@ mod tests { fn cfg() -> TrustConfig { // L2 open, explicit allowed channel; L3 with one allowed user. TrustConfig::new( - None, // allow_all_channels → true + None, // allow_all_channels → true ["chan-1".to_string()], - None, // allow_dm → true - None, // allow_all_users → false (deny) + None, // allow_dm → true + None, // allow_all_users → false (deny) ["user-1".to_string()], ) } @@ -225,7 +225,10 @@ mod tests { #[test] fn allowed_user_in_scope_channel_is_allowed() { - assert_eq!(cfg().decide("any-channel", false, "user-1"), Decision::Allow); + assert_eq!( + cfg().decide("any-channel", false, "user-1"), + Decision::Allow + ); } #[test] @@ -239,7 +242,10 @@ mod tests { #[test] fn untrusted_user_in_dm_denied_identity_not_scope() { // DM surface open by default → reaches L3 → identity deny (echo path). - assert_eq!(cfg().decide("dm-chan", true, "stranger"), Decision::DenyIdentity); + assert_eq!( + cfg().decide("dm-chan", true, "stranger"), + Decision::DenyIdentity + ); } #[test] @@ -266,13 +272,25 @@ mod tests { #[test] fn allow_all_users_opens_l3() { - let c = TrustConfig::new(None, std::iter::empty(), None, Some(true), std::iter::empty()); + let c = TrustConfig::new( + None, + std::iter::empty(), + None, + Some(true), + std::iter::empty(), + ); assert_eq!(c.decide("c", false, "anyone"), Decision::Allow); } #[test] fn dm_closed_denies_scope_even_for_allowed_user() { - let c = TrustConfig::new(None, std::iter::empty(), Some(false), None, ["user-1".to_string()]); + let c = TrustConfig::new( + None, + std::iter::empty(), + Some(false), + None, + ["user-1".to_string()], + ); // allowed user, but DM surface disabled → DenyScope (no echo). assert_eq!(c.decide("dm", true, "user-1"), Decision::DenyScope); // same user in a channel (L2 open) → Allow. @@ -291,7 +309,10 @@ mod tests { fn registry_returns_default_for_unknown_platform() { let reg = PlatformTrustConfigs::new(); // unknown platform → default (L3 deny-all) → stranger denied identity. - assert_eq!(reg.decide("mars", "c", false, "stranger"), Decision::DenyIdentity); + assert_eq!( + reg.decide("mars", "c", false, "stranger"), + Decision::DenyIdentity + ); } #[test] @@ -302,15 +323,27 @@ mod tests { TrustConfig::new(None, std::iter::empty(), None, None, ["123".to_string()]), ); assert_eq!(reg.decide("telegram", "c", false, "123"), Decision::Allow); - assert_eq!(reg.decide("telegram", "c", false, "999"), Decision::DenyIdentity); + assert_eq!( + reg.decide("telegram", "c", false, "999"), + Decision::DenyIdentity + ); // unregistered platform still gets deny-all default. - assert_eq!(reg.decide("discord", "c", false, "123"), Decision::DenyIdentity); + assert_eq!( + reg.decide("discord", "c", false, "123"), + Decision::DenyIdentity + ); } #[test] fn empty_sender_is_never_identity_allowed() { // Even with allow_all_users = true, an empty sender_id fails closed. - let open = TrustConfig::new(None, std::iter::empty(), None, Some(true), std::iter::empty()); + let open = TrustConfig::new( + None, + std::iter::empty(), + None, + Some(true), + std::iter::empty(), + ); assert!(!open.identity_allowed("")); assert_eq!(open.decide("c", false, ""), Decision::DenyIdentity); // non-empty still allowed under allow_all_users. diff --git a/crates/openab-cp/Cargo.toml b/crates/openab-cp/Cargo.toml index 86ca5e30c..894fae50f 100644 --- a/crates/openab-cp/Cargo.toml +++ b/crates/openab-cp/Cargo.toml @@ -24,3 +24,8 @@ subtle = "2" # WebSocket client for the end-to-end admission/lifecycle tests. Same version # axum 0.8 already uses, so it adds no new dependency to the workspace. tokio-tungstenite = "0.29" +# `test-util` (feature-unified onto the tokio above, so no new crate enters the +# lockfile) for `#[tokio::test(start_paused = true)]`: the sweeper-supervision +# tests drive multi-second backoff and stall windows on a simulated clock +# instead of sleeping through them. +tokio = { version = "1", features = ["test-util"] } diff --git a/crates/openab-cp/src/main.rs b/crates/openab-cp/src/main.rs index 7a34753c3..2209f0e03 100644 --- a/crates/openab-cp/src/main.rs +++ b/crates/openab-cp/src/main.rs @@ -1,13 +1,14 @@ //! Standalone control-plane binary. use std::sync::Arc; +use std::time::Duration; use anyhow::{Context, Result}; use clap::Parser; -use tracing::info; +use tracing::{info, warn}; use openab_cp::config::CpConfig; -use openab_cp::server::{app, run_sweeper, AppState}; +use openab_cp::server::{app, supervise_sweeper, AppState}; #[derive(Parser)] #[command(name = "openab-cp", about = "OpenAB Agent Control Plane")] @@ -39,7 +40,12 @@ async fn main() -> Result<()> { ); let state = Arc::new(AppState::new(cfg)); - let sweeper = tokio::spawn(run_sweeper(state.clone())); + // The sweeper is supervised, not fire-and-forget: a panic (or a stall, or + // an unexpected return) inside it would otherwise leave the CP answering + // `/health` with `ok` while lease expiry and deadline sweeping are dead. + // The supervisor restarts it with backoff and keeps `/health` honest. + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + let sweeper = tokio::spawn(supervise_sweeper(state.clone(), shutdown_rx)); let listener = tokio::net::TcpListener::bind(&listen) .await @@ -51,6 +57,18 @@ async fn main() -> Result<()> { }) .await?; - sweeper.abort(); + // Signal the supervisor instead of aborting the sweeper handle: the abort + // would bypass the supervision that was just installed. The supervisor + // aborts the sweeper itself, on its way out. + let _ = shutdown_tx.send(true); + if tokio::time::timeout(Duration::from_secs(5), sweeper) + .await + .is_err() + { + // The handle is gone with the timeout, so this cannot be retried or + // aborted; runtime teardown is the last resort. Say so rather than + // exiting quietly with a sweeper of unknown state. + warn!("sweeper supervisor did not stop within 5s of shutdown"); + } Ok(()) } diff --git a/crates/openab-cp/src/registry.rs b/crates/openab-cp/src/registry.rs index f81c34981..bbe84d33b 100644 --- a/crates/openab-cp/src/registry.rs +++ b/crates/openab-cp/src/registry.rs @@ -47,7 +47,7 @@ impl OutboundBudget { /// treats it exactly like a full queue (the peer is disconnected). fn reserve(&self, n: usize) -> bool { self.reserved - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |cur| { + .try_update(Ordering::AcqRel, Ordering::Acquire, |cur| { match cur.checked_add(n) { Some(next) if next <= self.max => Some(next), _ => None, diff --git a/crates/openab-cp/src/server.rs b/crates/openab-cp/src/server.rs index c442d6d39..8aff10b39 100644 --- a/crates/openab-cp/src/server.rs +++ b/crates/openab-cp/src/server.rs @@ -24,7 +24,7 @@ //! consulting CP logs. use std::collections::BTreeMap; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -37,7 +37,7 @@ use axum::Router as AxumRouter; use futures_util::{SinkExt, StreamExt}; use parking_lot::Mutex; use tokio::sync::watch; -use tracing::{info, warn}; +use tracing::{error, info, warn}; use crate::config::{AgentIdentity, CpConfig}; use crate::events::EventHub; @@ -59,6 +59,8 @@ pub struct AppState { /// Live connections per identity (`namespace/name`), counted from the /// upgrade so pre-registration sockets are bounded too. conns: Mutex>, + /// Liveness of the background sweeper, surfaced through `/health`. + sweeper: SweeperHealth, } impl AppState { @@ -70,6 +72,7 @@ impl AppState { router: Router::new(), rpc_id: AtomicU64::new(1), conns: Mutex::new(BTreeMap::new()), + sweeper: SweeperHealth::default(), } } @@ -99,6 +102,75 @@ impl AppState { pub fn conn_count(&self, logical_id: &str) -> u32 { self.conns.lock().get(logical_id).copied().unwrap_or(0) } + + /// Record one *completed* sweep pass. This is the sweeper's only + /// self-report: the supervisor reads the counter to tell a working sweeper + /// from one that is wedged mid-pass (a task that never returns also never + /// records a pass). + pub fn record_sweeper_pass(&self) { + self.sweeper.record_pass(); + } + + /// Record that the sweeper is gone (panicked, returned, or stalled). + /// Called by [`supervise_sweeper_task`] before it waits out a backoff, so + /// the window in which sweeping is stopped is never reported as healthy. + pub fn record_sweeper_death(&self) { + self.sweeper.record_death(); + } + + /// Current sweeper liveness, as `/health` reports it. + pub fn sweeper_status(&self) -> SweeperStatus { + self.sweeper.status() + } +} + +/// Liveness of the background sweeper, as `/health` reports it. +/// +/// A CP whose sweeper is dead is still accepting registrations and delegations +/// while lease expiry and deadline sweeping have stopped — leases never expire +/// and overdue delegations never resolve — so this state has to be visible +/// above the process rather than only in its logs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SweeperStatus { + /// Sweep passes completed since start. Rises while the sweeper works. + pub passes: u64, + /// Deaths observed by the supervisor (panic, unexpected return, stall). + /// Monotonic for the life of the process. + pub deaths: u64, + /// `true` from a death until a pass completes afterwards — i.e. lease + /// expiry and deadline sweeping are known to be stopped right now. + pub degraded: bool, +} + +/// Counters behind [`SweeperStatus`]. Lock-free: every writer is the sweeper +/// task itself and every reader is a request handler or the supervisor. +#[derive(Debug, Default)] +struct SweeperHealth { + passes: AtomicU64, + deaths: AtomicU64, + degraded: AtomicBool, +} + +impl SweeperHealth { + /// A completed pass both proves progress and clears the degraded state: the + /// CP is sweeping again, so the previous death is no longer live. + fn record_pass(&self) { + self.passes.fetch_add(1, Ordering::Relaxed); + self.degraded.store(false, Ordering::Release); + } + + fn record_death(&self) { + self.deaths.fetch_add(1, Ordering::Relaxed); + self.degraded.store(true, Ordering::Release); + } + + fn status(&self) -> SweeperStatus { + SweeperStatus { + passes: self.passes.load(Ordering::Relaxed), + deaths: self.deaths.load(Ordering::Relaxed), + degraded: self.degraded.load(Ordering::Acquire), + } + } } /// RAII connection slot. Dropping it frees the identity's quota; it is never @@ -127,8 +199,16 @@ pub fn app(state: Arc) -> AxumRouter { .with_state(state) } -async fn health() -> &'static str { - "ok" +/// Liveness endpoint. Reports `503` whenever the background sweeper is known to +/// be stopped, because everything else the CP answers stays green while lease +/// expiry and deadline sweeping are dead — an orchestrator that only sees a +/// 200 would never restart a CP whose maintenance loop is gone. +async fn health(State(state): State>) -> impl IntoResponse { + if state.sweeper_status().degraded { + (StatusCode::SERVICE_UNAVAILABLE, "sweeper degraded") + } else { + (StatusCode::OK, "ok") + } } /// Reason string on the close frame sent when `cp/register` never arrived. @@ -986,6 +1066,11 @@ pub fn sweep_leases(state: &Arc, lease: Duration) { } /// Background sweeps: lease expiry and delegation deadlines. +/// +/// Records a pass after every completed tick. That counter is the sweeper's +/// liveness evidence: [`supervise_sweeper_task`] restarts the task when it +/// stops advancing, which is the only way to notice a sweeper wedged inside a +/// pass (a task stuck there never returns, so its `JoinHandle` never resolves). pub async fn run_sweeper(state: Arc) { let mut tick = tokio::time::interval(Duration::from_secs(1)); tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -1005,7 +1090,203 @@ pub async fn run_sweeper(state: Arc) { ) { let _ = inst.tx.try_send(frame); } + + state.record_sweeper_pass(); + } +} + +/// Supervision policy for the background sweeper. +/// +/// A struct rather than constants so the supervision loop can be exercised on a +/// compressed clock (`#[tokio::test(start_paused = true)]`) — production values +/// are deliberately too slow to test against directly. +#[derive(Debug, Clone, Copy)] +pub struct SweeperSupervision { + /// How often the supervisor samples the pass counter while a sweeper runs. + pub poll_interval: Duration, + /// A window with no completed pass this long means the sweeper is dead + /// even though its task never returned. Generous next to the 1 s tick, so + /// an ordinary slow pass is never mistaken for a stall. + pub stall_timeout: Duration, + /// Base delay for restart backoff. The supervisor counts a death *before* it + /// computes that death's delay, so the first restart of a fresh incident + /// waits `2 * initial_backoff` (500 ms in production) and every further + /// consecutive death doubles it up to [`Self::max_backoff`]. + pub initial_backoff: Duration, + /// Ceiling for the doubling backoff across repeated deaths. + pub max_backoff: Duration, +} + +/// Production policy. The stall window is 30 missed passes, and a sweeper that +/// dies in a loop is retried at most once every 30 s — enough to recover from a +/// transient fault, never enough to spin. +pub const SWEEPER_SUPERVISION: SweeperSupervision = SweeperSupervision { + poll_interval: Duration::from_secs(1), + stall_timeout: Duration::from_secs(30), + initial_backoff: Duration::from_millis(250), + max_backoff: Duration::from_secs(30), +}; + +impl SweeperSupervision { + /// Delay before the restart that follows `deaths` consecutive deaths. + /// Doubling, saturating, and clamped to `max_backoff` — a pathological death + /// count cannot overflow the shift or the multiply into a zero (i.e. hot) + /// delay. + pub fn backoff_after_consecutive_deaths(&self, deaths: u32) -> Duration { + let factor = 1u32 << deaths.min(31); + self.initial_backoff + .saturating_mul(factor) + .min(self.max_backoff) + } +} + +/// Passes a sweeper must have completed within one stall window before its +/// next death is treated as a fresh incident rather than part of a crash loop. +const MIN_PASSES_FOR_RESET: u64 = 2; + +/// How a supervised sweeper's run ended. +enum SweeperDeath { + /// The task ended: `Err` is a panic (or an abort), `Ok(())` an unexpected + /// return from an infinite loop. + Ended(Result<(), tokio::task::JoinError>), + /// No completed pass for `stall_timeout`; the task is aborted. + Stalled, +} + +/// Run and supervise the real sweeper until `shutdown` fires. The CP binary's +/// entry point for background work. +pub async fn supervise_sweeper(state: Arc, shutdown: watch::Receiver) { + let task_state = Arc::clone(&state); + supervise_sweeper_task(state, shutdown, SWEEPER_SUPERVISION, move || { + run_sweeper(Arc::clone(&task_state)) + }) + .await +} + +/// Supervise a background task on the CP's [`AppState`], restarting it when it +/// dies until `shutdown` is signalled (or its sender is dropped). +/// +/// Three ways a sweeper can stop sweeping without the CP noticing, and all +/// three are handled here: +/// +/// - it **panics** (the `JoinHandle` resolves with a panic `JoinError`); +/// - it **returns** (the loop is infinite, so any return is a bug — treated the +/// same way, since the alternative is a silently abandoned maintenance task); +/// - it **stalls** — no completed pass within [`SweeperSupervision::stall_timeout`], +/// which is the only signal available for a task wedged inside a pass. +/// +/// Each death is recorded on `state` *before* the backoff wait, so `/health` +/// reports `degraded` for the whole window in which sweeping is stopped, and +/// the replacement's first pass clears it. Restarts are rate-limited so a +/// deterministic panic becomes a bounded retry loop rather than a hot one. +pub async fn supervise_sweeper_task( + state: Arc, + mut shutdown: watch::Receiver, + policy: SweeperSupervision, + mut make_task: F, +) where + F: FnMut() -> Fut, + Fut: std::future::Future + Send + 'static, +{ + let mut consecutive_deaths: u32 = 0; + loop { + if *shutdown.borrow_and_update() { + return; + } + let started = tokio::time::Instant::now(); + let passes_at_start = state.sweeper_status().passes; + // Borrowed by the select below so the handle stays ours to abort on + // shutdown and on a detected stall. + let mut task = tokio::spawn(make_task()); + + let mut poll = tokio::time::interval(policy.poll_interval); + poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // The first tick is immediate; consume it so the watchdog only fires + // after a real `poll_interval` has elapsed. + poll.tick().await; + let mut last_passes = state.sweeper_status().passes; + let mut last_progress = tokio::time::Instant::now(); + + let death = loop { + tokio::select! { + joined = &mut task => break SweeperDeath::Ended(joined), + _ = shutdown_requested(&mut shutdown) => { + task.abort(); + // Await the abort rather than dropping the handle: a dropped + // handle detaches, so the supervisor would return with the + // sweeper's cancellation not yet carried out. + let _ = task.await; + return; + } + _ = poll.tick() => { + let passes = state.sweeper_status().passes; + if passes != last_passes { + last_passes = passes; + last_progress = tokio::time::Instant::now(); + } else if last_progress.elapsed() >= policy.stall_timeout { + task.abort(); + let _ = task.await; + break SweeperDeath::Stalled; + } + } + } + }; + + state.record_sweeper_death(); + // A sweeper that survived a full stall window AND kept sweeping through + // it was not crash-looping, so its replacement starts with a fresh + // budget: the ceiling from an old incident must not delay the next one. + // Progress is part of the test on purpose — a sweeper that completes one + // pass and then wedges for the whole window is exactly the failure the + // backoff exists to slow down, so it must keep growing its delay. + let made_progress = state + .sweeper_status() + .passes + .saturating_sub(passes_at_start) + >= MIN_PASSES_FOR_RESET; + consecutive_deaths = if started.elapsed() >= policy.stall_timeout && made_progress { + 0 + } else { + consecutive_deaths.saturating_add(1) + }; + let backoff = policy.backoff_after_consecutive_deaths(consecutive_deaths); + match &death { + SweeperDeath::Ended(Err(joined)) if joined.is_panic() => error!( + "sweeper panicked ({joined}) — restarting in {backoff_ms} ms", + backoff_ms = backoff.as_millis() as u64, + ), + SweeperDeath::Ended(outcome) => warn!( + "sweeper returned unexpectedly ({outcome:?}) — restarting in {backoff_ms} ms", + backoff_ms = backoff.as_millis() as u64, + ), + SweeperDeath::Stalled => warn!( + "sweeper completed no pass for {stall_timeout_secs}s — restarting in {backoff_ms} ms", + stall_timeout_secs = policy.stall_timeout.as_secs(), + backoff_ms = backoff.as_millis() as u64, + ), + } + + // The wait is interruptible: shutdown must not have to wait out a + // backoff that has grown to the ceiling. + tokio::select! { + _ = tokio::time::sleep(backoff) => {} + _ = shutdown_requested(&mut shutdown) => return, + } + } +} + +/// Resolves once shutdown is requested. Both outcomes of the wait mean the +/// same thing — the sender fired, or the sender is gone and nothing can ever +/// signal again — which is also why a dropped sender cannot turn the +/// supervisor into a spin loop (`changed()` resolves immediately and forever in +/// that case, and the caller always leaves the loop). +async fn shutdown_requested(rx: &mut watch::Receiver) { + if *rx.borrow_and_update() { + return; } + // `Err` means the sender is gone: no signal can ever arrive again, so a + // dropped sender is shutdown too. + let _ = rx.changed().await; } #[cfg(test)] diff --git a/crates/openab-cp/tests/sweeper_supervision.rs b/crates/openab-cp/tests/sweeper_supervision.rs new file mode 100644 index 000000000..03b72fec8 --- /dev/null +++ b/crates/openab-cp/tests/sweeper_supervision.rs @@ -0,0 +1,577 @@ +//! Supervision and liveness of the CP's background sweeper (#1474). +//! +//! A sweeper that dies silently turns lease expiry and deadline sweeping into +//! a no-op while the process keeps serving: leases of disconnected agents never +//! expire, deadline-overdue delegations never resolve, and nothing above the +//! CP can tell — `/health` still answers `ok`. These tests pin the two +//! properties that close that gap: +//! +//! 1. a dead sweeper is *supervised* — restarted with backoff, whether it +//! panicked, returned, or stalled without completing a pass; and +//! 2. `/health` reports `503` for exactly as long as sweeping is known to be +//! stopped, and recovers on the first pass of the replacement. +//! +//! Every supervision test drives the real supervisor loop on a paused clock +//! with a compressed policy, so the timings are simulated and deterministic. + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use parking_lot::Mutex; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::sync::watch; +use tokio_tungstenite::tungstenite::http::StatusCode; + +use openab_cp::config::CpConfig; +use openab_cp::server::{app, supervise_sweeper_task, AppState, SweeperStatus, SweeperSupervision}; + +/// Compressed supervision policy: the production values (1 s watchdog poll, +/// 30 s stall window, 250 ms → 30 s backoff) would make these tests either +/// minutes long or dependent on wall-clock slack. The *shape* under test is the +/// same; only the scale differs. +fn fast_policy() -> SweeperSupervision { + SweeperSupervision { + poll_interval: Duration::from_millis(10), + stall_timeout: Duration::from_millis(200), + initial_backoff: Duration::from_millis(10), + max_backoff: Duration::from_millis(100), + } +} + +/// Restart ceiling for the backoff-interruption test: long enough that a +/// shutdown which waits its delay out cannot possibly fit in the test's timeout. +const CEILING: Duration = Duration::from_secs(300); + +/// CP state with the stock (all-default) configuration. +fn state() -> Arc { + let cfg: CpConfig = toml::from_str("").expect("default config parses"); + cfg.validate().expect("default config validates"); + Arc::new(AppState::new(cfg)) +} + +/// Wait until `done` holds, on the (simulated) clock. +async fn wait_until(label: &str, mut done: impl FnMut() -> bool) { + tokio::time::timeout(Duration::from_secs(60), async { + while !done() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {label}")); +} + +#[tokio::test] +async fn health_is_ok_only_while_the_sweeper_is_alive() { + // The reported failure mode is "the process stays up and /health still + // returns ok" after the sweeper dies. A CP that answers 200 while it knows + // sweeping has stopped is lying to its orchestrator, so the death has to + // move the endpoint to 503 and the first pass of the replacement has to + // move it back. + let state = state(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let router = app(state.clone()); + tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + let (status, body) = http_get(addr, "/health").await; + assert_eq!(status, StatusCode::OK, "a live sweeper must report ok"); + assert_eq!(body, "ok"); + + state.record_sweeper_death(); + let (status, body) = http_get(addr, "/health").await; + assert_eq!( + status, + StatusCode::SERVICE_UNAVAILABLE, + "a dead sweeper must be visible to the orchestrator, not hidden behind a 200" + ); + assert!( + body.contains("sweeper"), + "the 503 body must name the failed component, got {body:?}" + ); + + state.record_sweeper_pass(); + let (status, body) = http_get(addr, "/health").await; + assert_eq!( + status, + StatusCode::OK, + "the first pass of a replacement sweeper must clear the degraded state" + ); + assert_eq!(body, "ok"); +} + +#[tokio::test(start_paused = true)] +async fn a_panicking_sweeper_is_restarted_and_the_replacement_comes_up_degraded() { + // A panic inside the sweeper used to end the task with nothing watching: + // the CP kept accepting registrations and delegations while lease expiry + // and deadlines silently stopped. The supervisor must bring a replacement + // up — and must not let that replacement start while `/health` still + // claims sweeping is fine. + let state = state(); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let starts = Arc::new(AtomicUsize::new(0)); + let seen: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let starts_task = Arc::clone(&starts); + let seen_task = Arc::clone(&seen); + let state_task = Arc::clone(&state); + let supervisor = tokio::spawn(supervise_sweeper_task( + state.clone(), + shutdown_rx, + fast_policy(), + move || { + let starts = Arc::clone(&starts_task); + let seen = Arc::clone(&seen_task); + let state = Arc::clone(&state_task); + async move { + // Sampled at spawn time: what `/health` would have said the + // instant this sweeper existed. + seen.lock().push(state.sweeper_status()); + let nth = starts.fetch_add(1, Ordering::SeqCst); + if nth == 0 { + panic!("injected sweeper panic"); + } + // A healthy replacement keeps making progress. + loop { + tokio::time::sleep(Duration::from_millis(20)).await; + state.record_sweeper_pass(); + } + } + }, + )); + + wait_until("the replacement sweeper to be running and healthy", || { + starts.load(Ordering::SeqCst) >= 2 && !state.sweeper_status().degraded + }) + .await; + + let status = state.sweeper_status(); + assert!(status.deaths >= 1, "the panic must be recorded as a death"); + assert!( + status.passes >= 1, + "the replacement must actually sweep (pass counter advances)" + ); + + let seen = seen.lock().clone(); + assert!( + !seen[0].degraded, + "a CP that never lost its sweeper is not degraded" + ); + assert!( + seen[1].degraded, + "a replacement must not come up while health still reports ok — \ + that window is exactly when sweeping is stopped and nothing says so" + ); + + drop(shutdown_tx); + supervisor + .await + .expect("the supervisor task itself must not panic"); +} + +#[tokio::test(start_paused = true)] +async fn a_sweeper_that_stops_making_progress_is_restarted() { + // Panic supervision alone is not enough: a task can wedge *inside* a pass + // (a park it never leaves) and never return, so no `JoinHandle` ever + // resolves. The pass counter is the only liveness evidence available, so a + // window with no completed pass has to be treated as death. + let state = state(); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let starts = Arc::new(AtomicUsize::new(0)); + let seen: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let starts_task = Arc::clone(&starts); + let seen_task = Arc::clone(&seen); + let state_task = Arc::clone(&state); + let supervisor = tokio::spawn(supervise_sweeper_task( + state.clone(), + shutdown_rx, + fast_policy(), + move || { + let starts = Arc::clone(&starts_task); + let seen = Arc::clone(&seen_task); + let state = Arc::clone(&state_task); + async move { + seen.lock().push(state.sweeper_status()); + starts.fetch_add(1, Ordering::SeqCst); + // Never returns and never records a pass. + std::future::pending::<()>().await; + } + }, + )); + + wait_until("the stalled sweeper to be replaced twice", || { + starts.load(Ordering::SeqCst) >= 3 + }) + .await; + + let seen = seen.lock().clone(); + assert!( + seen[1].degraded && seen[2].degraded, + "every replacement of a stalled sweeper must come up degraded, got {seen:?}" + ); + assert!( + state.sweeper_status().deaths >= 2, + "each stall must be recorded, got {:?}", + state.sweeper_status() + ); + + drop(shutdown_tx); + supervisor + .await + .expect("the supervisor task itself must not panic"); +} + +#[tokio::test(start_paused = true)] +async fn a_crash_looping_sweeper_is_backed_off_not_hot_restarted() { + // Restarting unconditionally would turn a deterministic panic into a busy + // loop that starves the runtime — a second silent failure with a much + // worse symptom. Restarts are rate-limited by a backoff that doubles up to + // a ceiling, so a crash loop costs a bounded number of attempts per second. + let state = state(); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let starts = Arc::new(AtomicUsize::new(0)); + let starts_task = Arc::clone(&starts); + let supervisor = tokio::spawn(supervise_sweeper_task( + state.clone(), + shutdown_rx, + fast_policy(), + move || { + let starts = Arc::clone(&starts_task); + async move { + starts.fetch_add(1, Ordering::SeqCst); + panic!("injected sweeper panic"); + } + }, + )); + + // Two simulated seconds against a 100 ms ceiling: a hot restart loop would + // produce thousands of attempts here. + tokio::time::sleep(Duration::from_secs(2)).await; + let attempts = starts.load(Ordering::SeqCst); + assert!( + (2..=40).contains(&attempts), + "a crash-looping sweeper must be retried with backoff, not in a hot loop \ + ({attempts} attempts in 2 simulated seconds)" + ); + + drop(shutdown_tx); + supervisor + .await + .expect("the supervisor task itself must not panic"); +} + +#[tokio::test] +async fn a_long_lived_sweeper_resets_the_backoff_budget() { + // Backoff must not be a permanent penalty: a sweeper that died after hours + // of healthy service is a fresh incident, and its replacement must be + // allowed to start promptly instead of inheriting the ceiling from an old + // crash loop. + let policy = fast_policy(); + assert_eq!( + policy.backoff_after_consecutive_deaths(0), + policy.initial_backoff + ); + assert_eq!( + policy.backoff_after_consecutive_deaths(1), + policy.initial_backoff * 2, + ); + assert!( + policy.backoff_after_consecutive_deaths(2) > policy.backoff_after_consecutive_deaths(1), + "repeated deaths must grow the delay" + ); + assert_eq!( + policy.backoff_after_consecutive_deaths(64), + policy.max_backoff, + "the delay is clamped, and a huge death count must not overflow" + ); +} + +#[tokio::test(start_paused = true)] +async fn a_long_healthy_run_resets_the_delay_seen_by_the_next_death() { + // The delay a replacement waits is only meaningful if it tracks the CURRENT + // incident. A sweeper that ran healthily for longer than the stall window + // and then died is a fresh incident: its replacement must get the initial + // backoff, not a doubled one inherited from history. End to end this shows + // up as an exact restart delay on the simulated clock. + let policy = fast_policy(); + let state = state(); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let starts = Arc::new(AtomicUsize::new(0)); + let start_times: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let starts_task = Arc::clone(&starts); + let times_task = Arc::clone(&start_times); + let state_task = Arc::clone(&state); + let supervisor = tokio::spawn(supervise_sweeper_task( + state.clone(), + shutdown_rx, + policy, + move || { + let starts = Arc::clone(&starts_task); + let times = Arc::clone(×_task); + let state = Arc::clone(&state_task); + async move { + times.lock().push(tokio::time::Instant::now()); + let nth = starts.fetch_add(1, Ordering::SeqCst); + if nth == 0 { + // A long, healthy run: three stall windows of passes, then + // it dies. + for _ in 0..30 { + tokio::time::sleep(Duration::from_millis(20)).await; + state.record_sweeper_pass(); + } + } + panic!("injected sweeper panic"); + } + }, + )); + + wait_until("three sweeper generations", || { + starts.load(Ordering::SeqCst) >= 3 + }) + .await; + let times = start_times.lock().clone(); + + assert_eq!( + times[2] - times[1], + Duration::from_millis(20), + "the first death after a long healthy run starts a fresh budget, so its \ + replacement waits 2x the initial backoff ({:?} -> {:?}); a budget carried \ + over from the previous incident would make this delay different", + policy.initial_backoff, + policy.initial_backoff * 2, + ); + + drop(shutdown_tx); + supervisor + .await + .expect("the supervisor task itself must not panic"); +} + +#[tokio::test(start_paused = true)] +async fn shutdown_while_the_sweeper_runs_terminates_it_and_waits() { + // `abort()` only *requests* cancellation, and a dropped `JoinHandle` + // detaches: a supervisor that returned on the abort alone would leave the + // sweeper's cancellation uncarrried out while the rest of the process tears + // down. The sweeper must be gone by the time the supervisor returns. + let state = state(); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let dropped = Arc::new(AtomicBool::new(false)); + + struct DropMarksCancellation(Arc); + impl Drop for DropMarksCancellation { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + let dropped_task = Arc::clone(&dropped); + let supervisor = tokio::spawn(supervise_sweeper_task( + state.clone(), + shutdown_rx, + fast_policy(), + move || { + let dropped = Arc::clone(&dropped_task); + async move { + let _marks_cancellation = DropMarksCancellation(dropped); + std::future::pending::<()>().await; + } + }, + )); + + // Let the sweeper actually start before asking the supervisor to stop. + tokio::time::sleep(Duration::from_millis(50)).await; + assert!( + !dropped.load(Ordering::SeqCst), + "the sweeper must still be running before shutdown" + ); + shutdown_tx.send(true).expect("supervisor holds a receiver"); + + // Snapshot the flag in the same poll that observes the supervisor's + // return. Reading it from this task instead would let the scheduler run the + // aborted sweeper before the assertion, which is exactly what makes the + // un-awaited variant look correct. + let observed = Arc::new(AtomicBool::new(false)); + let observer = { + let observed = Arc::clone(&observed); + tokio::spawn(async move { + let outcome = tokio::time::timeout(Duration::from_secs(5), supervisor).await; + observed.store(dropped.load(Ordering::SeqCst), Ordering::SeqCst); + outcome + }) + }; + observer + .await + .expect("the observer must not panic") + .expect("the supervisor must return on shutdown, not hang") + .expect("the supervisor task itself must not panic"); + assert!( + observed.load(Ordering::SeqCst), + "the sweeper must be torn down before the supervisor returns — a dropped \ + JoinHandle detaches, so the abort is not carried out until later" + ); +} + +#[tokio::test(start_paused = true)] +async fn shutdown_interrupts_the_backoff_wait() { + // A crash-looping sweeper sits in the backoff between restarts; shutdown + // must not have to wait the delay out (which may have grown to the + // ceiling), or a process asked to stop could hang on the way out. + let state = state(); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let starts = Arc::new(AtomicUsize::new(0)); + + let starts_task = Arc::clone(&starts); + let supervisor = tokio::spawn(supervise_sweeper_task( + state.clone(), + shutdown_rx, + SweeperSupervision { + max_backoff: CEILING, + ..fast_policy() + }, + move || { + let starts = Arc::clone(&starts_task); + async move { + starts.fetch_add(1, Ordering::SeqCst); + panic!("injected sweeper panic"); + } + }, + )); + + // Grow the delay to its ceiling and let most of it elapse: a generation + // panics instantly, so each retry doubles the wait (20/40/80/... ms) until + // it is clamped at CEILING. After this sleep the supervisor is parked in a + // full CEILING-long wait with ~50 s still to run, which an uninterruptible + // shutdown cannot fit inside the 5 s timeout below. + tokio::time::sleep(CEILING - Duration::from_secs(50)).await; + let started_at = tokio::time::Instant::now(); + shutdown_tx.send(true).expect("supervisor holds a receiver"); + + tokio::time::timeout(Duration::from_secs(5), supervisor) + .await + .expect("shutdown must interrupt the backoff wait, not sit out its ceiling") + .expect("the supervisor task itself must not panic"); + assert!( + started_at.elapsed() < Duration::from_secs(5), + "shutdown returned only after {:?}, so the remaining backoff was waited out", + started_at.elapsed() + ); +} + +#[tokio::test(start_paused = true)] +async fn a_sweeper_that_makes_one_pass_then_wedges_keeps_its_backoff_growing() { + // A sweeper that completes a single pass and then hangs for the whole stall + // window looks "long-lived" by wall-clock alone. Treating that as a healthy + // run would hand every replacement the initial backoff and turn a + // slowly-wedging task into a permanent 30-second restart loop; progress + // through the window is part of what earns the reset. + let policy = fast_policy(); + let state = state(); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let starts = Arc::new(AtomicUsize::new(0)); + let start_times: Arc>> = Arc::new(Mutex::new(Vec::new())); + let state_task = Arc::clone(&state); + let starts_task = Arc::clone(&starts); + let times_task = Arc::clone(&start_times); + + let supervisor = tokio::spawn(supervise_sweeper_task( + state.clone(), + shutdown_rx, + policy, + move || { + let starts = Arc::clone(&starts_task); + let times = Arc::clone(×_task); + let state = Arc::clone(&state_task); + async move { + times.lock().push(tokio::time::Instant::now()); + if starts.fetch_add(1, Ordering::SeqCst) == 0 { + state.record_sweeper_pass(); + } + // One pass, then wedged for the whole stall window. + std::future::pending::<()>().await; + } + }, + )); + + wait_until( + "three replacements of a one-pass-then-wedged sweeper", + || starts.load(Ordering::SeqCst) >= 4, + ) + .await; + let times = start_times.lock().clone(); + // Generation 0 completes exactly one pass and then wedges; every generation + // after it completes none. Each death therefore waits the stall timeout plus + // a backoff for one death more than the last. If a single completed pass were + // enough to look "healthy", the budget would reset on every generation and + // all three delays would be identical — which is the difference this + // assertion exists to catch. + let delays: Vec<_> = times.windows(2).map(|w| w[1] - w[0]).collect(); + assert!( + delays[0] < delays[1] && delays[1] < delays[2], + "restart delays must grow while the sweeper keeps failing without progress, \ + got {delays:?} (stall timeout {:?}, initial backoff {:?})", + policy.stall_timeout, + policy.initial_backoff, + ); + + drop(shutdown_tx); + supervisor + .await + .expect("the supervisor task itself must not panic"); +} + +#[tokio::test(start_paused = true)] +async fn shutdown_stops_the_supervisor_before_it_starts_a_task() { + // Process shutdown must not spawn a sweeper on its way out, and must stop + // the one it owns — otherwise `main` would have to abort the task directly + // and would bypass the supervision it just installed. + let state = state(); + let (shutdown_tx, shutdown_rx) = watch::channel(true); + let starts = Arc::new(AtomicUsize::new(0)); + let starts_task = Arc::clone(&starts); + tokio::time::timeout( + Duration::from_secs(5), + supervise_sweeper_task(state.clone(), shutdown_rx, fast_policy(), move || { + let starts = Arc::clone(&starts_task); + async move { + starts.fetch_add(1, Ordering::SeqCst); + std::future::pending::<()>().await; + } + }), + ) + .await + .expect("an already-signalled supervisor must return, not block"); + assert_eq!( + starts.load(Ordering::SeqCst), + 0, + "no sweeper may be spawned after shutdown was requested" + ); + drop(shutdown_tx); +} + +/// Minimal HTTP/1.1 GET over a loopback socket: `(status, body)`. +async fn http_get(addr: std::net::SocketAddr, path: &str) -> (StatusCode, String) { + let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap(); + let request = format!("GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"); + stream.write_all(request.as_bytes()).await.unwrap(); + let mut raw = Vec::new(); + stream.read_to_end(&mut raw).await.unwrap(); + let text = String::from_utf8_lossy(&raw).to_string(); + let (head, body) = text + .split_once("\r\n\r\n") + .unwrap_or_else(|| panic!("malformed HTTP response: {text:?}")); + let code: u16 = head + .split_whitespace() + .nth(1) + .unwrap_or_else(|| panic!("no status code in {head:?}")) + .parse() + .expect("numeric status code"); + ( + StatusCode::from_u16(code).expect("known status"), + body.to_string(), + ) +} diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 747132e28..d702e50dd 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -745,10 +745,10 @@ async fn route_client_response( } // Accept a numeric id (what we mint) or a stringified number ("1") from a spec-loose // client, so its responses still correlate to the pending request instead of being dropped. - let Some(id) = raw - .get("id") - .and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) - else { + let Some(id) = raw.get("id").and_then(|v| { + v.as_u64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + }) else { return false; }; if let Some(tx) = pending.lock().await.remove(&id) { @@ -757,7 +757,10 @@ async fn route_client_response( // `debug!`, not `warn!`: since the tunnel cancels on expiry, a reply arriving after we // gave up is EXPECTED traffic from a well-behaved peer, not a client defect. Logging it at // warn made a correct peer look broken. - debug!(id, "acp: client response arrived after its request was abandoned; discarding"); + debug!( + id, + "acp: client response arrived after its request was abandoned; discarding" + ); } true } @@ -831,7 +834,15 @@ async fn mcp_connect( acp_id: acp_id.to_string(), }) .unwrap(); - let frame = send_request(out_tx, pending, next_id, "mcp/connect", params, timeout_secs).await?; + let frame = send_request( + out_tx, + pending, + next_id, + "mcp/connect", + params, + timeout_secs, + ) + .await?; let result: McpConnectResult = serde_json::from_value(frame_result(frame)?) .map_err(|e| format!("mcp/connect: malformed result: {e}"))?; Ok(result.connection_id) @@ -871,9 +882,16 @@ async fn mcp_disconnect( connection_id: connection_id.to_string(), }) .unwrap(); - send_request(out_tx, pending, next_id, "mcp/disconnect", params, timeout_secs) - .await - .map(|_| ()) + send_request( + out_tx, + pending, + next_id, + "mcp/disconnect", + params, + timeout_secs, + ) + .await + .map(|_| ()) } /// Monotonic attach ordering for last-attach-wins (ADR §6.1). @@ -1007,8 +1025,7 @@ const INNER_MCP_PROTOCOL_VERSION: &str = "2025-06-18"; /// in the abstract, so a transport or framing change can invalidate the set while the method list /// stays identical. A bare list of version strings with no stated reason is how this becomes /// silently wrong later. -const SUPPORTED_INNER_MCP_PROTOCOL_VERSIONS: [&str; 3] = - ["2025-06-18", "2025-03-26", "2024-11-05"]; +const SUPPORTED_INNER_MCP_PROTOCOL_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"]; /// Perform the inner MCP handshake on a freshly connected tunnel. /// @@ -1332,8 +1349,17 @@ fn spawn_acp_tunnels( } establish_tasks.push(tokio::spawn(async move { if let Err(e) = establish_and_register_tunnel( - out_tx, pending, next_id, srv.id, srv.name, channel_id, registry, 30, owner, - connection_generation, connection_closed, + out_tx, + pending, + next_id, + srv.id, + srv.name, + channel_id, + registry, + 30, + owner, + connection_generation, + connection_closed, ) .await { @@ -1493,8 +1519,11 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { Ok(r) => r, Err(e) => { if !is_notification { - let err_resp = - JsonRpcResponse::error(Value::Null, -32600, format!("Invalid Request: {e}")); + let err_resp = JsonRpcResponse::error( + Value::Null, + -32600, + format!("Invalid Request: {e}"), + ); let _ = out_tx.send(serde_json::to_string(&err_resp).unwrap()); } continue; @@ -1545,9 +1574,9 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { continue; } // Required params per schema: { cwd, mcpServers }. - if let Err(msg) = - validate_params::(req.params.as_ref()) - { + if let Err(msg) = validate_params::( + req.params.as_ref(), + ) { let resp = JsonRpcResponse::error(id, -32602, msg); let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); continue; @@ -1564,18 +1593,16 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { } // Bound the declaration fan-out BEFORE the session exists or any task is // spawned (R3-F1): an over-declaring request must cost nothing. - let acp_mcp_servers = match accept_acp_servers(parse_acp_mcp_servers( - req.params.as_ref(), - )) { - Ok(list) => list, - Err(msg) => { - let resp = JsonRpcResponse::error(id, ACP_OVERLOADED, msg); - let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); - continue; - } - }; - let (resp, channel_id) = - handle_session_new(&sessions, id.clone()).await; + let acp_mcp_servers = + match accept_acp_servers(parse_acp_mcp_servers(req.params.as_ref())) { + Ok(list) => list, + Err(msg) => { + let resp = JsonRpcResponse::error(id, ACP_OVERLOADED, msg); + let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); + continue; + } + }; + let (resp, channel_id) = handle_session_new(&sessions, id.clone()).await; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); // If the client declared "type":"acp" MCP servers, open + register a tunnel to @@ -1605,9 +1632,9 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { } // Required params per schema: { sessionId, cwd, mcpServers? }. The // sessionId's `sess_` shape is checked further in the handler. - if let Err(msg) = - validate_params::(req.params.as_ref()) - { + if let Err(msg) = validate_params::( + req.params.as_ref(), + ) { let resp = JsonRpcResponse::error(id, -32602, msg); let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); continue; @@ -1918,7 +1945,11 @@ fn handle_initialize(req: &JsonRpcRequest) -> JsonRpcResponse { match serde_json::from_value(req.params.clone().unwrap_or(Value::Null)) { Ok(r) => r, Err(e) => { - return JsonRpcResponse::error(id, -32602, format!("Invalid initialize params: {e}")); + return JsonRpcResponse::error( + id, + -32602, + format!("Invalid initialize params: {e}"), + ); } }; // Negotiate: respond with the version we will use = the lower of the client's and @@ -2054,7 +2085,10 @@ async fn handle_session_resume( id: Value, params: Option<&Value>, ) -> (JsonRpcResponse, Option) { - let session_id = match params.and_then(|p| p.get("sessionId")).and_then(|v| v.as_str()) { + let session_id = match params + .and_then(|p| p.get("sessionId")) + .and_then(|v| v.as_str()) + { Some(s) => s.to_string(), None => { return ( @@ -2147,7 +2181,9 @@ async fn handle_session_cancel( "session/cancel is a notification and must not carry an id", )); } - let sess_key = params.and_then(|p| p.get("sessionId")).and_then(|v| v.as_str()); + let sess_key = params + .and_then(|p| p.get("sessionId")) + .and_then(|v| v.as_str()); if let Some(k) = sess_key { let notify = sessions.lock().await.get(k).and_then(|s| s.cancel.clone()); if let Some(n) = notify { @@ -2178,7 +2214,10 @@ pub(crate) fn redact_id(id: &str) -> String { // and `prompt dispatched` prints two of them on a single line. Correlating a session across // logs is the entire reason the tag exists, so producing several defeats the purpose more // completely than not redacting would. - let uuid = id.strip_prefix("acp_").or_else(|| id.strip_prefix("sess_")).unwrap_or(id); + let uuid = id + .strip_prefix("acp_") + .or_else(|| id.strip_prefix("sess_")) + .unwrap_or(id); use sha2::{Digest as _, Sha256}; let digest = Sha256::digest(uuid.as_bytes()); let short: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect(); @@ -2212,7 +2251,13 @@ mod redact_id_cross_encoding { fn openab_core_tag(uuid: &str) -> String { use sha2::{Digest as _, Sha256}; let d = Sha256::digest(uuid.as_bytes()); - format!("#{}", d.iter().take(4).map(|b| format!("{b:02x}")).collect::()) + format!( + "#{}", + d.iter() + .take(4) + .map(|b| format!("{b:02x}")) + .collect::() + ) } } @@ -2270,12 +2315,11 @@ async fn handle_session_prompt( let channel_id = match sessions.lock().await.get(&session_id) { Some(s) => s.channel_id.clone(), None => { - let resp = - JsonRpcResponse::error( - id, - -32602, - format!("Unknown session: {}", redact_id(&session_id)), - ); + let resp = JsonRpcResponse::error( + id, + -32602, + format!("Unknown session: {}", redact_id(&session_id)), + ); let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); release_prompt(sessions, &session_id).await; return; @@ -2325,11 +2369,7 @@ async fn handle_session_prompt( if state.event_tx.send(json).is_err() { // No receivers — agent/core not connected warn!("ACP: event_tx send failed — no agent connected"); - let resp = JsonRpcResponse::error( - id, - -32603, - "No agent backend connected", - ); + let resp = JsonRpcResponse::error(id, -32603, "No agent backend connected"); let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); release_prompt(sessions, &session_id).await; // Cleanup registry — only this turn's own sink (F4). @@ -2527,7 +2567,10 @@ pub async fn handle_reply(reply: &GatewayReply, registry: &AcpReplyRegistry) { sink.tx.clone() } Some(_) => { - debug!(channel = key, "ACP dropping stale reply from a superseded turn"); + debug!( + channel = key, + "ACP dropping stale reply from a superseded turn" + ); return; } None => return, @@ -2538,15 +2581,24 @@ pub async fn handle_reply(reply: &GatewayReply, registry: &AcpReplyRegistry) { Some("edit_message") => { // Streaming update — send as text snapshot if tx.send(ReplyChunk::Text(full_text)).is_err() { - debug!(channel = key, "ACP reply send failed (client likely disconnected)"); - registry.lock().unwrap_or_else(|e| e.into_inner()).remove(key); + debug!( + channel = key, + "ACP reply send failed (client likely disconnected)" + ); + registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(key); } } None | Some("send_message") => { // Final message let _ = tx.send(ReplyChunk::Text(full_text)); let _ = tx.send(ReplyChunk::Done); - registry.lock().unwrap_or_else(|e| e.into_inner()).remove(key); + registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(key); } Some("add_reaction") | Some("remove_reaction") => { // Reactions are agent state indicators — could map to notifications later @@ -2576,12 +2628,21 @@ mod acp_conformance { where T: serde::Serialize + serde::de::DeserializeOwned, { - let a: T = serde_json::from_value(wire.clone()) - .unwrap_or_else(|e| panic!("emitted wire is not valid ACP {}: {e}\n wire={wire}", std::any::type_name::())); + let a: T = serde_json::from_value(wire.clone()).unwrap_or_else(|e| { + panic!( + "emitted wire is not valid ACP {}: {e}\n wire={wire}", + std::any::type_name::() + ) + }); let v1 = serde_json::to_value(&a).unwrap(); let b: T = serde_json::from_value(v1.clone()).expect("re-parse of generated form"); let v2 = serde_json::to_value(&b).unwrap(); - assert_eq!(v1, v2, "ACP serde is not a stable fixed point for {}", std::any::type_name::()); + assert_eq!( + v1, + v2, + "ACP serde is not a stable fixed point for {}", + std::any::type_name::() + ); } // --- outbound responses (exact shapes handle_* emit) --- @@ -2608,7 +2669,9 @@ mod acp_conformance { #[test] fn new_session_response() { - conforms::(json!({ "sessionId": "sess_00000000-0000-0000-0000-000000000000" })); + conforms::( + json!({ "sessionId": "sess_00000000-0000-0000-0000-000000000000" }), + ); } #[test] @@ -2652,12 +2715,12 @@ mod acp_conformance { // astral-plane emoji, ZWJ sequence, regional-indicator flag, VS16 emoji, // astral-plane CJK, and a mixed run. const EDGE_TEXT: &[&str] = &[ - "🎉", // U+1F389, 4-byte astral emoji - "👨‍👩‍👧‍👦", // ZWJ family (7 codepoints joined by ZWJ) - "🇹🇼", // regional-indicator pair (flag) - "❤️", // U+2764 + U+FE0F (VS16) - "𠀀", // U+20000, astral-plane CJK - "🎉 你好 (๑•̀ㅂ•́)و ❤️", // mixed emoji + CJK + kaomoji + VS16 + "🎉", // U+1F389, 4-byte astral emoji + "👨‍👩‍👧‍👦", // ZWJ family (7 codepoints joined by ZWJ) + "🇹🇼", // regional-indicator pair (flag) + "❤️", // U+2764 + U+FE0F (VS16) + "𠀀", // U+20000, astral-plane CJK + "🎉 你好 (๑•̀ㅂ•́)و ❤️", // mixed emoji + CJK + kaomoji + VS16 ]; #[test] @@ -2698,7 +2761,13 @@ mod acp_conformance { #[test] fn prompt_response_all_stop_reasons() { - for sr in ["end_turn", "max_tokens", "max_turn_requests", "refusal", "cancelled"] { + for sr in [ + "end_turn", + "max_tokens", + "max_turn_requests", + "refusal", + "cancelled", + ] { conforms::(json!({ "stopReason": sr })); } } @@ -2725,8 +2794,12 @@ mod acp_conformance { serde_json::from_str(r#"{"jsonrpc":"2.0","method":"session/cancel"}"#).unwrap(); assert!(notif.get("id").is_none(), "no id member → notification"); let req_null: Value = - serde_json::from_str(r#"{"jsonrpc":"2.0","method":"session/cancel","id":null}"#).unwrap(); - assert!(req_null.get("id").is_some(), "explicit id:null → request (id member present)"); + serde_json::from_str(r#"{"jsonrpc":"2.0","method":"session/cancel","id":null}"#) + .unwrap(); + assert!( + req_null.get("id").is_some(), + "explicit id:null → request (id member present)" + ); let req_num: Value = serde_json::from_str(r#"{"jsonrpc":"2.0","method":"initialize","id":7}"#).unwrap(); assert_eq!(req_num.get("id"), Some(&json!(7))); @@ -2738,14 +2811,36 @@ mod acp_conformance { fn session_param_validation() { use super::validate_params; // session/new requires { cwd, mcpServers } - assert!(validate_params::(Some(&json!({"cwd": "/w", "mcpServers": []}))).is_ok()); - assert!(validate_params::(Some(&json!({"mcpServers": []}))).is_err(), "missing cwd"); - assert!(validate_params::(Some(&json!({"cwd": "/w"}))).is_err(), "missing mcpServers"); - assert!(validate_params::(None).is_err(), "missing params"); + assert!(validate_params::(Some( + &json!({"cwd": "/w", "mcpServers": []}) + )) + .is_ok()); + assert!( + validate_params::(Some(&json!({"mcpServers": []}))).is_err(), + "missing cwd" + ); + assert!( + validate_params::(Some(&json!({"cwd": "/w"}))).is_err(), + "missing mcpServers" + ); + assert!( + validate_params::(None).is_err(), + "missing params" + ); // session/resume requires { sessionId, cwd } - assert!(validate_params::(Some(&json!({"sessionId": "sess_x", "cwd": "/w", "mcpServers": []}))).is_ok()); - assert!(validate_params::(Some(&json!({"cwd": "/w"}))).is_err(), "missing sessionId"); - assert!(validate_params::(Some(&json!({"sessionId": "sess_x"}))).is_err(), "missing cwd"); + assert!(validate_params::(Some( + &json!({"sessionId": "sess_x", "cwd": "/w", "mcpServers": []}) + )) + .is_ok()); + assert!( + validate_params::(Some(&json!({"cwd": "/w"}))).is_err(), + "missing sessionId" + ); + assert!( + validate_params::(Some(&json!({"sessionId": "sess_x"}))) + .is_err(), + "missing cwd" + ); } // --- prompt content blocks (F10): unsupported block types rejected, not dropped --- @@ -2796,13 +2891,16 @@ mod acp_conformance { // R17-F3a — a plain-string prompt is non-conformant (schema requires // `prompt: [ContentBlock]`) → rejected, surfaced as -32602 at the call site. assert!( - extract_prompt_params(Some(&json!({"sessionId": "sess_x", "prompt": "hello"}))).is_err(), + extract_prompt_params(Some(&json!({"sessionId": "sess_x", "prompt": "hello"}))) + .is_err(), "a bare string prompt must be rejected, not coerced" ); // an object (non-array, non-string) prompt is likewise rejected. assert!( - extract_prompt_params(Some(&json!({"sessionId": "sess_x", "prompt": {"type": "text"}}))) - .is_err(), + extract_prompt_params(Some( + &json!({"sessionId": "sess_x", "prompt": {"type": "text"}}) + )) + .is_err(), "a non-array prompt must be rejected" ); } @@ -2833,8 +2931,11 @@ mod acp_conformance { use axum::http::HeaderMap; let mut h = HeaderMap::new(); assert_eq!(subprotocol_token(&h), None); // no header - // the browser offers "openab.bearer., acp.v1" → extract the token - h.insert("sec-websocket-protocol", "openab.bearer.abc123, acp.v1".parse().unwrap()); + // the browser offers "openab.bearer., acp.v1" → extract the token + h.insert( + "sec-websocket-protocol", + "openab.bearer.abc123, acp.v1".parse().unwrap(), + ); assert_eq!(subprotocol_token(&h), Some("abc123")); // only the real protocol, no bearer entry → None h.insert("sec-websocket-protocol", "acp.v1".parse().unwrap()); @@ -2873,14 +2974,7 @@ mod acp_streaming { #[test] fn multibyte_codepoints_never_split() { // each snapshot appends a whole multi-byte grapheme; reconstruction is exact - let snaps = [ - "a", - "a🎉", - "a🎉你", - "a🎉你👨‍👩‍👧‍👦", - "a🎉你👨‍👩‍👧‍👦🇹🇼", - "a🎉你👨‍👩‍👧‍👦🇹🇼❤️", - ]; + let snaps = ["a", "a🎉", "a🎉你", "a🎉你👨‍👩‍👧‍👦", "a🎉你👨‍👩‍👧‍👦🇹🇼", "a🎉你👨‍👩‍👧‍👦🇹🇼❤️"]; assert_eq!(replay(&snaps), *snaps.last().unwrap()); } @@ -2936,7 +3030,11 @@ mod acp_requests { pending: &Arc>>>, ) { let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); - assert_eq!(f["params"]["method"], json!("initialize"), "expected the inner MCP initialize"); + assert_eq!( + f["params"]["method"], + json!("initialize"), + "expected the inner MCP initialize" + ); route_client_response( pending, &json!({"jsonrpc":"2.0","id":f["id"],"result":{ @@ -2951,8 +3049,7 @@ mod acp_requests { assert_eq!(n["params"]["method"], json!("notifications/initialized")); } - fn new_pending( - ) -> Arc>>> { + fn new_pending() -> Arc>>> { Arc::new(tokio::sync::Mutex::new(HashMap::new())) } @@ -2967,8 +3064,7 @@ mod acp_requests { let p2 = pending.clone(); let version = version.map(str::to_string); let responder = tokio::spawn(async move { - let f: serde_json::Value = - serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); let result = match version { Some(v) => json!({ "protocolVersion": v, "capabilities": {"tools": {}}, @@ -2984,7 +3080,8 @@ mod acp_requests { route_client_response(&p2, &json!({"jsonrpc":"2.0","id":f["id"],"result":result})) .await; // Drain `notifications/initialized` if the handshake got far enough to send it. - let _ = tokio::time::timeout(std::time::Duration::from_millis(200), out_rx.recv()).await; + let _ = + tokio::time::timeout(std::time::Duration::from_millis(200), out_rx.recv()).await; }); let r = super::inner_mcp_handshake(&out_tx, &pending, &next_id, "conn-1", 5).await; let _ = responder.await; @@ -3013,7 +3110,10 @@ mod acp_requests { let err = handshake_answering(Some("1999-01-01")) .await .expect_err("an unknown revision must not be negotiated into"); - assert!(err.contains("1999-01-01"), "the error must name what the peer answered: {err}"); + assert!( + err.contains("1999-01-01"), + "the error must name what the peer answered: {err}" + ); assert!( err.contains(super::INNER_MCP_PROTOCOL_VERSION), "the error must name what we requested: {err}" @@ -3031,7 +3131,10 @@ mod acp_requests { let err = handshake_answering(None) .await .expect_err("no protocolVersion is not a compliant initialize result"); - assert!(err.contains("no `protocolVersion`"), "unexpected error: {err}"); + assert!( + err.contains("no `protocolVersion`"), + "unexpected error: {err}" + ); } #[tokio::test] @@ -3039,9 +3142,11 @@ mod acp_requests { let pending = new_pending(); let (tx, rx) = oneshot::channel(); pending.lock().await.insert(5, tx); - let consumed = - route_client_response(&pending, &json!({"jsonrpc":"2.0","id":5,"result":{"ok":true}})) - .await; + let consumed = route_client_response( + &pending, + &json!({"jsonrpc":"2.0","id":5,"result":{"ok":true}}), + ) + .await; assert!(consumed, "an id+result frame is a response we consume"); assert_eq!(rx.await.unwrap()["result"]["ok"], json!(true)); assert!( @@ -3054,7 +3159,9 @@ mod acp_requests { async fn route_client_response_ignores_requests_and_notifications() { let pending = new_pending(); // has `method` → a request, not a response - assert!(!route_client_response(&pending, &json!({"jsonrpc":"2.0","id":1,"method":"foo"})).await); + assert!( + !route_client_response(&pending, &json!({"jsonrpc":"2.0","id":1,"method":"foo"})).await + ); // notification-shaped, no result/error → not a response assert!(!route_client_response(&pending, &json!({"jsonrpc":"2.0","method":"bar"})).await); // id present but neither result nor error → not a response @@ -3069,7 +3176,10 @@ mod acp_requests { &json!({"jsonrpc":"2.0","id":99,"error":{"code":-1,"message":"x"}}), ) .await; - assert!(consumed, "an unmatched response is still consumed (logged, no panic)"); + assert!( + consumed, + "an unmatched response is still consumed (logged, no panic)" + ); } #[tokio::test] @@ -3088,8 +3198,11 @@ mod acp_requests { assert_eq!(v["method"], json!("mcp/message")); assert_eq!(v["params"]["connectionId"], json!("conn-1")); let id = v["id"].as_u64().unwrap(); - route_client_response(&pending2, &json!({"jsonrpc":"2.0","id":id,"result":{"pong":true}})) - .await; + route_client_response( + &pending2, + &json!({"jsonrpc":"2.0","id":id,"result":{"pong":true}}), + ) + .await; }); let resp = send_request( @@ -3104,7 +3217,11 @@ mod acp_requests { .unwrap(); assert_eq!(resp["result"]["pong"], json!(true)); driver.await.unwrap(); - assert_eq!(next_id.load(Ordering::Relaxed), 2, "the id counter advanced"); + assert_eq!( + next_id.load(Ordering::Relaxed), + 2, + "the id counter advanced" + ); } #[tokio::test] @@ -3117,7 +3234,8 @@ mod acp_requests { // tools/list into an inner result, routing each reply by the frame's own outer id. let pending2 = pending.clone(); let ext = tokio::spawn(async move { - let f1: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + let f1: serde_json::Value = + serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); assert_eq!(f1["method"], json!("mcp/connect")); assert_eq!(f1["params"]["acpId"], json!("srv-1")); route_client_response( @@ -3126,7 +3244,8 @@ mod acp_requests { ) .await; - let f2: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + let f2: serde_json::Value = + serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); assert_eq!(f2["method"], json!("mcp/message")); assert_eq!(f2["params"]["connectionId"], json!("conn-9")); assert_eq!(f2["params"]["method"], json!("tools/list")); @@ -3170,8 +3289,11 @@ mod acp_requests { assert_eq!(f["method"], json!("mcp/message")); assert_eq!(f["params"]["connectionId"], json!("conn-9")); assert_eq!(f["params"]["method"], json!("tools/call")); - route_client_response(&pending2, &json!({"jsonrpc":"2.0","id":f["id"],"result":{"ok":true}})) - .await; + route_client_response( + &pending2, + &json!({"jsonrpc":"2.0","id":f["id"],"result":{"ok":true}}), + ) + .await; }); let result = handle @@ -3224,14 +3346,23 @@ mod acp_requests { let mut reg = registry.lock().unwrap(); // Deliberately out of insertion order relative to rank, so a "first match" answer and a // "newest" answer differ. - reg.insert(("acp_1".into(), "srv-new".into()), tunnel_ranked("conn-b", 7, 2)); - reg.insert(("acp_1".into(), "srv-old".into()), tunnel_ranked("conn-a", 3, 9)); + reg.insert( + ("acp_1".into(), "srv-new".into()), + tunnel_ranked("conn-b", 7, 2), + ); + reg.insert( + ("acp_1".into(), "srv-old".into()), + tunnel_ranked("conn-a", 3, 9), + ); reg.insert(("acp_1".into(), "other".into()), { let mut h = tunnel_ranked("conn-c", 9, 9); h.server_name = "notes".into(); h }); - reg.insert(("acp_2".into(), "elsewhere".into()), tunnel_ranked("conn-d", 99, 99)); + reg.insert( + ("acp_2".into(), "elsewhere".into()), + tunnel_ranked("conn-d", 99, 99), + ); } assert_eq!( super::resolve_by_name(®istry, "acp_1", "katashiro").as_deref(), @@ -3370,8 +3501,11 @@ mod acp_requests { let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); assert_eq!(f["method"], json!("mcp/connect")); assert_eq!(f["params"]["acpId"], json!("srv-1")); - route_client_response(&pending2, &json!({"jsonrpc":"2.0","id":f["id"],"result":{"connectionId":"conn-9"}})) - .await; + route_client_response( + &pending2, + &json!({"jsonrpc":"2.0","id":f["id"],"result":{"connectionId":"conn-9"}}), + ) + .await; answer_inner_handshake(&mut out_rx, &pending2).await; }); @@ -3563,7 +3697,10 @@ mod acp_handlers { #[test] fn initialize_returns_conformant_capabilities() { - let v = serde_json::to_value(handle_initialize(&init_req(Some(json!({"protocolVersion": 1}))))).unwrap(); + let v = serde_json::to_value(handle_initialize(&init_req(Some( + json!({"protocolVersion": 1}), + )))) + .unwrap(); assert_eq!(v["id"], json!(1)); let result = &v["result"]; assert_eq!(result["protocolVersion"], json!(1)); @@ -3575,26 +3712,44 @@ mod acp_handlers { // the serde default {http:false, sse:false} — the same VALUES, but arrived at by silence // while the gateway ships MCP-over-ACP and says so nowhere. let mcp = &result["agentCapabilities"]["mcpCapabilities"]; - assert_eq!(mcp["http"], json!(false), "no code forwards an http declaration anywhere"); - assert_eq!(mcp["sse"], json!(false), "same for sse — parse_acp_mcp_servers drops both"); assert_eq!( - mcp["_meta"]["dev.openab/acp"], json!(true), + mcp["http"], + json!(false), + "no code forwards an http declaration anywhere" + ); + assert_eq!( + mcp["sse"], + json!(false), + "same for sse — parse_acp_mcp_servers drops both" + ); + assert_eq!( + mcp["_meta"]["dev.openab/acp"], + json!(true), "the ACP capability is a reverse-DNS-namespaced extension under _meta (F1(b))" ); assert!( mcp["_meta"]["acp"].is_null(), "the bare `_meta.acp` key is gone — the informal convention the framework supersedes" ); - assert!(mcp.get("acp").is_none(), "no core mcpCapabilities.acp field (would fork the schema)"); + assert!( + mcp.get("acp").is_none(), + "no core mcpCapabilities.acp field (would fork the schema)" + ); } #[test] fn initialize_negotiates_version_and_rejects_bad() { // a higher client version negotiates down to ours (1) - let v = serde_json::to_value(handle_initialize(&init_req(Some(json!({"protocolVersion": 5}))))).unwrap(); + let v = serde_json::to_value(handle_initialize(&init_req(Some( + json!({"protocolVersion": 5}), + )))) + .unwrap(); assert_eq!(v["result"]["protocolVersion"], json!(1)); // version 0 is below our minimum → -32602 - let v = serde_json::to_value(handle_initialize(&init_req(Some(json!({"protocolVersion": 0}))))).unwrap(); + let v = serde_json::to_value(handle_initialize(&init_req(Some( + json!({"protocolVersion": 0}), + )))) + .unwrap(); assert_eq!(v["error"]["code"], json!(-32602)); // missing protocolVersion → -32602 let v = serde_json::to_value(handle_initialize(&init_req(Some(json!({}))))).unwrap(); @@ -3607,11 +3762,16 @@ mod acp_handlers { #[tokio::test] async fn session_new_mints_and_stores_a_session() { let sessions = new_sessions(); - let v = - serde_json::to_value(handle_session_new(&sessions, json!(2)).await.0).unwrap(); + let v = serde_json::to_value(handle_session_new(&sessions, json!(2)).await.0).unwrap(); let sid = v["result"]["sessionId"].as_str().unwrap(); - assert!(sid.starts_with("sess_"), "sessionId must be sess_: {sid}"); - assert!(sessions.lock().await.contains_key(sid), "session must be stored"); + assert!( + sid.starts_with("sess_"), + "sessionId must be sess_: {sid}" + ); + assert!( + sessions.lock().await.contains_key(sid), + "session must be stored" + ); } #[test] @@ -3627,8 +3787,14 @@ mod acp_handlers { assert_eq!( parse_acp_mcp_servers(Some(¶ms)), vec![ - AcpMcpServer { id: "srv-1".into(), name: "browser".into() }, - AcpMcpServer { id: "srv-2".into(), name: "other".into() }, + AcpMcpServer { + id: "srv-1".into(), + name: "browser".into() + }, + AcpMcpServer { + id: "srv-2".into(), + name: "other".into() + }, ] ); // no mcpServers -> empty @@ -3636,25 +3802,34 @@ mod acp_handlers { assert!(parse_acp_mcp_servers(None).is_empty()); } - #[tokio::test] async fn session_resume_valid_stores_and_invalid_errors() { let sessions = new_sessions(); // valid sess_ → {} and the session is (re)stored let sid = format!("sess_{}", Uuid::new_v4()); let params = json!({"sessionId": sid, "cwd": "/w", "mcpServers": []}); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(¶ms)).await.0) - .unwrap(); + let v = serde_json::to_value( + handle_session_resume(&sessions, json!(3), Some(¶ms)) + .await + .0, + ) + .unwrap(); assert_eq!(v["result"], json!({})); assert!(sessions.lock().await.contains_key(&sid)); // malformed sessionId shape → -32602 let bad = json!({"sessionId": "not-a-session", "cwd": "/w", "mcpServers": []}); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(4), Some(&bad)).await.0) - .unwrap(); + let v = serde_json::to_value( + handle_session_resume(&sessions, json!(4), Some(&bad)) + .await + .0, + ) + .unwrap(); assert_eq!(v["error"]["code"], json!(-32602)); // missing sessionId → -32602 let v = serde_json::to_value( - handle_session_resume(&sessions, json!(5), Some(&json!({"cwd": "/w"}))).await.0, + handle_session_resume(&sessions, json!(5), Some(&json!({"cwd": "/w"}))) + .await + .0, ) .unwrap(); assert_eq!(v["error"]["code"], json!(-32602)); @@ -3685,23 +3860,39 @@ mod acp_review_fixes { for _ in 0..MAX_SESSIONS_PER_CONNECTION { let sid = format!("sess_{}", Uuid::new_v4()); let p = json!({ "sessionId": sid }); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(1), Some(&p)).await.0) - .unwrap(); + let v = + serde_json::to_value(handle_session_resume(&sessions, json!(1), Some(&p)).await.0) + .unwrap(); assert_eq!(v["result"], json!({}), "resume under cap should succeed"); ids.push(sid); } assert_eq!(sessions.lock().await.len(), MAX_SESSIONS_PER_CONNECTION); // A new distinct session over the cap is refused with ACP_OVERLOADED. let over = json!({ "sessionId": format!("sess_{}", Uuid::new_v4()) }); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(2), Some(&over)).await.0) - .unwrap(); - assert_eq!(v["error"]["code"], json!(ACP_OVERLOADED), "over-cap resume must be refused"); + let v = serde_json::to_value( + handle_session_resume(&sessions, json!(2), Some(&over)) + .await + .0, + ) + .unwrap(); + assert_eq!( + v["error"]["code"], + json!(ACP_OVERLOADED), + "over-cap resume must be refused" + ); // Re-resuming an already-present session is exempt (idempotent). let existing = json!({ "sessionId": ids[0] }); - let v = - serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(&existing)).await.0) - .unwrap(); - assert_eq!(v["result"], json!({}), "re-resume of existing session must bypass the cap"); + let v = serde_json::to_value( + handle_session_resume(&sessions, json!(3), Some(&existing)) + .await + .0, + ) + .unwrap(); + assert_eq!( + v["result"], + json!({}), + "re-resume of existing session must bypass the cap" + ); } // --- R3-F1: declaration fan-out is bounded before it costs anything --- @@ -3855,14 +4046,23 @@ mod acp_review_fixes { // 1. missing sessionId let (resp, chan) = handle_session_resume(&sessions, json!(1), Some(&json!({"cwd": "/w"}))).await; - assert_eq!(serde_json::to_value(resp).unwrap()["error"]["code"], json!(-32602)); + assert_eq!( + serde_json::to_value(resp).unwrap()["error"]["code"], + json!(-32602) + ); assert!(chan.is_none(), "missing sessionId must not yield a channel"); // 2. malformed sessionId let bad = json!({"sessionId": "not-a-session", "cwd": "/w"}); let (resp, chan) = handle_session_resume(&sessions, json!(2), Some(&bad)).await; - assert_eq!(serde_json::to_value(resp).unwrap()["error"]["code"], json!(-32602)); - assert!(chan.is_none(), "malformed sessionId must not yield a channel"); + assert_eq!( + serde_json::to_value(resp).unwrap()["error"]["code"], + json!(-32602) + ); + assert!( + chan.is_none(), + "malformed sessionId must not yield a channel" + ); // 3. over the per-connection cap — note the id IS well formed, so the old // derive-from-params guard would have happily produced a channel here. @@ -3877,7 +4077,10 @@ mod acp_review_fixes { serde_json::to_value(resp).unwrap()["error"]["code"], json!(ACP_OVERLOADED) ); - assert!(chan.is_none(), "an over-cap resume must not yield a channel"); + assert!( + chan.is_none(), + "an over-cap resume must not yield a channel" + ); // 4. busy — likewise a well-formed id on a session that really exists. let busy_sid = format!("sess_{}", Uuid::new_v4()); @@ -3891,8 +4094,14 @@ mod acp_review_fixes { ); let (resp, chan) = handle_session_resume(&sessions, json!(5), Some(&json!({"sessionId": busy_sid}))).await; - assert_eq!(serde_json::to_value(resp).unwrap()["error"]["code"], json!(-32001)); - assert!(chan.is_none(), "a busy-rejected resume must not yield a channel"); + assert_eq!( + serde_json::to_value(resp).unwrap()["error"]["code"], + json!(-32001) + ); + assert!( + chan.is_none(), + "a busy-rejected resume must not yield a channel" + ); } fn reply(channel_id: &str, reply_to: &str, text: &str, command: Option<&str>) -> GatewayReply { @@ -3900,7 +4109,10 @@ mod acp_review_fixes { schema: "openab.gateway.reply.v1".into(), reply_to: reply_to.into(), platform: "acp".into(), - channel: crate::schema::ReplyChannel { id: channel_id.into(), thread_id: None }, + channel: crate::schema::ReplyChannel { + id: channel_id.into(), + thread_id: None, + }, content: crate::schema::Content { content_type: "text".into(), text: text.into(), @@ -3918,22 +4130,33 @@ mod acp_review_fixes { async fn handle_reply_fences_stale_turn() { let registry = new_reply_registry(); let (tx, mut rx) = mpsc::unbounded_channel::(); - registry - .lock() - .unwrap() - .insert( - "acp_chan".into(), - ReplySink { turn_id: "evt_current".into(), tx, owner: "conn-test".into(), generation: 0 }, - ); + registry.lock().unwrap().insert( + "acp_chan".into(), + ReplySink { + turn_id: "evt_current".into(), + tx, + owner: "conn-test".into(), + generation: 0, + }, + ); // Stale reply (previous turn's event id) → dropped. - handle_reply(&reply("acp_chan", "evt_stale", "leaked", Some("edit_message")), ®istry) - .await; - assert!(rx.try_recv().is_err(), "stale reply must not reach the active turn"); + handle_reply( + &reply("acp_chan", "evt_stale", "leaked", Some("edit_message")), + ®istry, + ) + .await; + assert!( + rx.try_recv().is_err(), + "stale reply must not reach the active turn" + ); // Matching reply → delivered. - handle_reply(&reply("acp_chan", "evt_current", "hello", Some("edit_message")), ®istry) - .await; + handle_reply( + &reply("acp_chan", "evt_current", "hello", Some("edit_message")), + ®istry, + ) + .await; match rx.try_recv() { Ok(ReplyChunk::Text(t)) => assert_eq!(t, "hello"), _ => panic!("expected the matching reply to be delivered"), @@ -3948,30 +4171,64 @@ mod acp_review_fixes { let registry = new_reply_registry(); let sink = |turn: &str, owner: &str, generation: u64| { let (tx, _rx) = mpsc::unbounded_channel::(); - super::ReplySink { turn_id: turn.into(), tx, owner: owner.into(), generation } + super::ReplySink { + turn_id: turn.into(), + tx, + owner: owner.into(), + generation, + } + }; + let current_turn = || { + registry + .lock() + .unwrap() + .get("acp_x") + .map(|s| s.turn_id.clone()) }; - let current_turn = || registry.lock().unwrap().get("acp_x").map(|s| s.turn_id.clone()); // Connection A (gen 1) installs its sink. - assert!(super::install_reply_sink(®istry, "acp_x", sink("evt_a", "conn-A", 1))); + assert!(super::install_reply_sink( + ®istry, + "acp_x", + sink("evt_a", "conn-A", 1) + )); // Newer connection B (gen 2) resumes the same session and takes over (reconnect semantics). - assert!(super::install_reply_sink(®istry, "acp_x", sink("evt_b", "conn-B", 2))); - assert_eq!(current_turn().as_deref(), Some("evt_b"), "the newer connection owns the sink"); + assert!(super::install_reply_sink( + ®istry, + "acp_x", + sink("evt_b", "conn-B", 2) + )); + assert_eq!( + current_turn().as_deref(), + Some("evt_b"), + "the newer connection owns the sink" + ); // The older connection A, its prompt processed late, must NOT clobber B's live sink. assert!( !super::install_reply_sink(®istry, "acp_x", sink("evt_a2", "conn-A", 1)), "an older connection cannot install over a newer one" ); - assert_eq!(current_turn().as_deref(), Some("evt_b"), "B's sink survives A's late install"); + assert_eq!( + current_turn().as_deref(), + Some("evt_b"), + "B's sink survives A's late install" + ); // A's turn completing must not remove B's sink (turn-scoped removal). super::remove_reply_sink_if_owner(®istry, "acp_x", "evt_a"); - assert_eq!(current_turn().as_deref(), Some("evt_b"), "A's completion must not remove B's sink"); + assert_eq!( + current_turn().as_deref(), + Some("evt_b"), + "A's completion must not remove B's sink" + ); // B's own completion removes B's sink. super::remove_reply_sink_if_owner(®istry, "acp_x", "evt_b"); - assert!(current_turn().is_none(), "the owner's completion removes its own sink"); + assert!( + current_turn().is_none(), + "the owner's completion removes its own sink" + ); } // F4 — the SAME connection starting its next turn replaces its own sink; the stale prior turn's @@ -3981,14 +4238,24 @@ mod acp_review_fixes { let registry = new_reply_registry(); let sink = |turn: &str| { let (tx, _rx) = mpsc::unbounded_channel::(); - super::ReplySink { turn_id: turn.into(), tx, owner: "conn-A".into(), generation: 5 } + super::ReplySink { + turn_id: turn.into(), + tx, + owner: "conn-A".into(), + generation: 5, + } }; assert!(super::install_reply_sink(®istry, "acp_x", sink("evt_1"))); assert!(super::install_reply_sink(®istry, "acp_x", sink("evt_2"))); // Stale turn 1's completion must not remove turn 2's sink. super::remove_reply_sink_if_owner(®istry, "acp_x", "evt_1"); assert_eq!( - registry.lock().unwrap().get("acp_x").map(|s| s.turn_id.clone()).as_deref(), + registry + .lock() + .unwrap() + .get("acp_x") + .map(|s| s.turn_id.clone()) + .as_deref(), Some("evt_2"), "the stale turn must not remove the same connection's newer sink" ); @@ -4001,10 +4268,19 @@ mod acp_review_fixes { // the `else` of the bearer branch), so a keyed bind is unaffected by the allowlist. #[test] fn acp_origin_ok_keyless_gating() { - let allow = vec!["https://app.example".to_string(), "http://localhost:5173".to_string()]; + let allow = vec![ + "https://app.example".to_string(), + "http://localhost:5173".to_string(), + ]; // Absent Origin (non-browser client) → accept, regardless of allowlist. - assert!(acp_origin_ok(None, &allow), "no Origin (non-browser) must be admitted"); - assert!(acp_origin_ok(None, &[]), "no Origin must be admitted even with empty allowlist"); + assert!( + acp_origin_ok(None, &allow), + "no Origin (non-browser) must be admitted" + ); + assert!( + acp_origin_ok(None, &[]), + "no Origin must be admitted even with empty allowlist" + ); // Allowlisted browser Origin → accept (exact match, both entries). assert!(acp_origin_ok(Some("https://app.example"), &allow)); assert!(acp_origin_ok(Some("http://localhost:5173"), &allow)); @@ -4034,7 +4310,10 @@ mod acp_review_fixes { assert_eq!(ws_bearer_token(&h), Some("sekret")); // The subprotocol path still carries the key. let mut h = HeaderMap::new(); - h.insert("sec-websocket-protocol", "openab.bearer.sekret, acp.v1".parse().unwrap()); + h.insert( + "sec-websocket-protocol", + "openab.bearer.sekret, acp.v1".parse().unwrap(), + ); assert_eq!(ws_bearer_token(&h), Some("sekret")); } @@ -4043,10 +4322,18 @@ mod acp_review_fixes { #[test] fn ws_subprotocol_token_charset() { for &b in b"AZaz09._~+-!#$%&'*^`|" { - assert!(is_ws_subprotocol_token_char(b), "{} should be token-safe", b as char); + assert!( + is_ws_subprotocol_token_char(b), + "{} should be token-safe", + b as char + ); } for &b in b"=/,; @\"" { - assert!(!is_ws_subprotocol_token_char(b), "{} should be rejected", b as char); + assert!( + !is_ws_subprotocol_token_char(b), + "{} should be rejected", + b as char + ); } } @@ -4079,8 +4366,18 @@ mod acp_review_fixes { let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); let params = json!({"sessionId": sid, "prompt": [{"type": "text", "text": "hi"}]}); - handle_session_prompt(&state, &sessions, json!(7), Some(¶ms), &out_tx, sid.clone(), cancel, "conn-test", 0) - .await; + handle_session_prompt( + &state, + &sessions, + json!(7), + Some(¶ms), + &out_tx, + sid.clone(), + cancel, + "conn-test", + 0, + ) + .await; // The final response (matching our request id) must carry stopReason "cancelled". let mut final_resp = None; @@ -4099,7 +4396,10 @@ mod acp_review_fixes { // And the reservation is released. let g = sessions.lock().await; let s = g.get(&sid).unwrap(); - assert!(!s.busy && s.cancel.is_none(), "cancel must release busy + cancel handle"); + assert!( + !s.busy && s.cancel.is_none(), + "cancel must release busy + cancel handle" + ); } // R16-F2 — session/resume on a session with a prompt in flight is rejected (busy), so the @@ -4121,7 +4421,11 @@ mod acp_review_fixes { let params = json!({"sessionId": sid, "cwd": "/w", "mcpServers": []}); let (resp, resumed) = handle_session_resume(&sessions, json!(9), Some(¶ms)).await; let v = serde_json::to_value(resp).unwrap(); - assert_eq!(v["error"]["code"], json!(-32001), "resume while busy must be rejected"); + assert_eq!( + v["error"]["code"], + json!(-32001), + "resume while busy must be rejected" + ); assert!( resumed.is_none(), "a rejected resume must not hand back a channel — that value is the caller's \ @@ -4132,7 +4436,10 @@ mod acp_review_fixes { let g = sessions.lock().await; let s = g.get(&sid).unwrap(); assert!(s.busy, "busy must remain set after a rejected resume"); - assert!(s.cancel.is_some(), "the active prompt's cancel handle must survive resume"); + assert!( + s.cancel.is_some(), + "the active prompt's cancel handle must survive resume" + ); } // R16-F3(A) — Phase-1 send-once: the ACP path streams the whole reply as a SINGLE terminal @@ -4152,7 +4459,11 @@ mod acp_review_fixes { let cancel = Arc::new(tokio::sync::Notify::new()); sessions.lock().await.insert( sid.clone(), - AcpSession { channel_id: channel_id.clone(), busy: true, cancel: Some(cancel.clone()) }, + AcpSession { + channel_id: channel_id.clone(), + busy: true, + cancel: Some(cancel.clone()), + }, ); let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); @@ -4161,21 +4472,40 @@ mod acp_review_fixes { let sid2 = sid.clone(); let handle = tokio::spawn(async move { let params = json!({"sessionId": sid2, "prompt": [{"type": "text", "text": "hi"}]}); - handle_session_prompt(&st2, &sessions2, json!(11), Some(¶ms), &out_tx, sid2.clone(), cancel, "conn-test", 0) - .await; + handle_session_prompt( + &st2, + &sessions2, + json!(11), + Some(¶ms), + &out_tx, + sid2.clone(), + cancel, + "conn-test", + 0, + ) + .await; }); // Wait for the handler to register its reply sink, then feed one final reply. let mut turn_id = None; for _ in 0..10_000 { - if let Some(t) = registry.lock().unwrap().get(&channel_id).map(|s| s.turn_id.clone()) { + if let Some(t) = registry + .lock() + .unwrap() + .get(&channel_id) + .map(|s| s.turn_id.clone()) + { turn_id = Some(t); break; } tokio::task::yield_now().await; } let turn_id = turn_id.expect("handler must register a reply sink"); - handle_reply(&reply(&channel_id, &turn_id, "hello world", Some("send_message")), ®istry).await; + handle_reply( + &reply(&channel_id, &turn_id, "hello world", Some("send_message")), + ®istry, + ) + .await; handle.await.unwrap(); let mut chunks = Vec::new(); @@ -4185,15 +4515,28 @@ mod acp_review_fixes { if v["method"] == json!("session/update") && v["params"]["update"]["sessionUpdate"] == json!("agent_message_chunk") { - chunks.push(v["params"]["update"]["content"]["text"].as_str().unwrap_or("").to_string()); + chunks.push( + v["params"]["update"]["content"]["text"] + .as_str() + .unwrap_or("") + .to_string(), + ); } if v.get("id") == Some(&json!(11)) { final_stop = v["result"]["stopReason"].as_str().map(str::to_string); } } - assert_eq!(chunks.len(), 1, "Phase-1 must stream exactly one terminal chunk, got {chunks:?}"); + assert_eq!( + chunks.len(), + 1, + "Phase-1 must stream exactly one terminal chunk, got {chunks:?}" + ); assert_eq!(chunks[0], "hello world"); - assert_eq!(final_stop.as_deref(), Some("end_turn"), "a completed turn ends end_turn"); + assert_eq!( + final_stop.as_deref(), + Some("end_turn"), + "a completed turn ends end_turn" + ); } // R17-F3c — a request-shaped `session/cancel` (id present) must NOT be acknowledged with @@ -4207,7 +4550,11 @@ mod acp_review_fixes { .await .expect("a request-shaped cancel must produce a response, not silence"); let v = serde_json::to_value(&resp).unwrap(); - assert_eq!(v["error"]["code"], json!(-32600), "request-shaped cancel must be -32600"); + assert_eq!( + v["error"]["code"], + json!(-32600), + "request-shaped cancel must be -32600" + ); assert_eq!(v["id"], json!(42)); assert!( v.get("result").is_none(), @@ -4232,7 +4579,10 @@ mod acp_review_fixes { ); let params = json!({"sessionId": sid}); let resp = handle_session_cancel(&sessions, Value::Null, Some(¶ms), true).await; - assert!(resp.is_none(), "a notification cancel must produce no response frame"); + assert!( + resp.is_none(), + "a notification cancel must produce no response frame" + ); // notify_one stored a permit, so notified() resolves immediately — the signal fired. assert!( tokio::time::timeout(std::time::Duration::from_millis(200), cancel.notified()) @@ -4328,19 +4678,23 @@ mod acp_ws_integration { } match frame["params"]["method"].as_str() { Some("initialize") => { - send(ws, json!({ - "jsonrpc": "2.0", "id": frame["id"].clone(), - "result": { - "protocolVersion": "2025-06-18", - "capabilities": { "tools": {} }, - "serverInfo": { "name": "test-ext", "version": "0" } - } - })).await; - Some("initialize") - } - // A notification: no reply is owed, but it must still be taken off the socket or the - // next `recv` in a test mistakes it for the frame it was waiting for. - Some("notifications/initialized") => Some("initialized"), + send( + ws, + json!({ + "jsonrpc": "2.0", "id": frame["id"].clone(), + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "test-ext", "version": "0" } + } + }), + ) + .await; + Some("initialize") + } + // A notification: no reply is owed, but it must still be taken off the socket or the + // next `recv` in a test mistakes it for the frame it was waiting for. + Some("notifications/initialized") => Some("initialized"), _ => None, } } @@ -4348,20 +4702,28 @@ mod acp_ws_integration { /// Drive `initialize` + `session/new` declaring one `type:acp` server, then answer the /// `mcp/connect` the gateway sends back. Returns the session id. async fn handshake(ws: &mut Ws, acp_id: &str, name: &str, connection_id: &str) -> String { - send(ws, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + ws, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let init = recv(ws).await; assert!(init.get("result").is_some(), "initialize failed: {init}"); - send(ws, json!({ - "jsonrpc": "2.0", "id": 2, "method": "session/new", - "params": { - "cwd": "/w", - "mcpServers": [{"type": "acp", "id": acp_id, "name": name}] - } - })).await; + send( + ws, + json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": { + "cwd": "/w", + "mcpServers": [{"type": "acp", "id": acp_id, "name": name}] + } + }), + ) + .await; // The gateway now does two things concurrently: answer session/new, and open the tunnel // by sending mcp/connect. Order is not guaranteed, so accept either first. @@ -4381,17 +4743,25 @@ mod acp_ws_integration { } if frame.get("method").and_then(Value::as_str) == Some("mcp/connect") { assert_eq!( - frame["params"]["acpId"], json!(acp_id), + frame["params"]["acpId"], + json!(acp_id), "mcp/connect must name the declared id" ); - send(ws, json!({ - "jsonrpc": "2.0", "id": frame["id"].clone(), - "result": {"connectionId": connection_id} - })).await; + send( + ws, + json!({ + "jsonrpc": "2.0", "id": frame["id"].clone(), + "result": {"connectionId": connection_id} + }), + ) + .await; connected = true; } else if frame.get("id") == Some(&json!(2)) { session_id = Some( - frame["result"]["sessionId"].as_str().expect("sessionId").to_string(), + frame["result"]["sessionId"] + .as_str() + .expect("sessionId") + .to_string(), ); } } @@ -4429,10 +4799,14 @@ mod acp_ws_integration { async fn the_inner_mcp_lifecycle_completes_before_the_tunnel_is_registered() { let (url, registry) = serve().await; let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut ws).await; send(&mut ws, json!({ "jsonrpc": "2.0", "id": 2, "method": "session/new", @@ -4445,40 +4819,48 @@ mod acp_ws_integration { // should also say which bug. let collect = async { let mut order: Vec = Vec::new(); - let mut connected = false; - let mut lifecycle = 0; - while !connected || lifecycle < 2 { - let f = recv(&mut ws).await; - if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { - order.push("mcp/connect".into()); - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": f["id"].clone(), - "result": {"connectionId": "conn-1"} - })).await; - connected = true; - } else if f.get("method").and_then(Value::as_str) == Some("mcp/message") { - let inner = f["params"]["method"].as_str().unwrap_or("").to_string(); - order.push(inner.clone()); - if inner == "initialize" { - // The registry must still be empty: a server that has not answered - // `initialize` has not agreed to serve anything yet. - assert!( - registry.lock().unwrap().is_empty(), - "the tunnel was registered before the MCP handshake completed" - ); - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": f["id"].clone(), - "result": { - "protocolVersion": "2025-06-18", - "capabilities": { "tools": {} }, - "serverInfo": { "name": "test-ext", "version": "0" } - } - })).await; + let mut connected = false; + let mut lifecycle = 0; + while !connected || lifecycle < 2 { + let f = recv(&mut ws).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + order.push("mcp/connect".into()); + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-1"} + }), + ) + .await; + connected = true; + } else if f.get("method").and_then(Value::as_str) == Some("mcp/message") { + let inner = f["params"]["method"].as_str().unwrap_or("").to_string(); + order.push(inner.clone()); + if inner == "initialize" { + // The registry must still be empty: a server that has not answered + // `initialize` has not agreed to serve anything yet. + assert!( + registry.lock().unwrap().is_empty(), + "the tunnel was registered before the MCP handshake completed" + ); + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "test-ext", "version": "0" } + } + }), + ) + .await; + } + lifecycle += 1; } - lifecycle += 1; } - } - order + order }; let order = tokio::time::timeout(std::time::Duration::from_secs(10), collect) .await @@ -4511,10 +4893,14 @@ mod acp_ws_integration { async fn a_server_that_refuses_initialize_is_not_registered() { let (url, registry) = serve().await; let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut ws).await; send(&mut ws, json!({ "jsonrpc": "2.0", "id": 2, "method": "session/new", @@ -4529,16 +4915,24 @@ mod acp_ws_integration { let f = recv(&mut ws).await; match f.get("method").and_then(Value::as_str) { Some("mcp/connect") => { - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": f["id"].clone(), - "result": {"connectionId": "conn-1"} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-1"} + }), + ) + .await; } Some("mcp/message") if f["params"]["method"] == json!("initialize") => { - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": f["id"].clone(), - "error": {"code": -32603, "message": "not accepting connections"} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "error": {"code": -32603, "message": "not accepting connections"} + }), + ) + .await; refused = true; } _ => {} @@ -4614,15 +5008,23 @@ mod acp_ws_integration { // B opens FIRST — older connection — and declares nothing yet. let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut b).await; - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 2, "method": "session/new", - "params": {"cwd": "/w", "mcpServers": []} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": []} + }), + ) + .await; let session_id = loop { let f = recv(&mut b).await; if f.get("id") == Some(&json!(2)) { @@ -4634,16 +5036,24 @@ mod acp_ws_integration { // `mcp/connect` is captured and deliberately left unanswered, so it takes the LOWER attach // number while making no progress. let (mut c, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut c, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut c, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut c).await; - send(&mut c, json!({ - "jsonrpc": "2.0", "id": 2, "method": "session/resume", - "params": {"sessionId": session_id.clone(), "cwd": "/w", - "mcpServers": [{"type": "acp", "id": "srv-c", "name": "katashiro"}]} - })).await; + send( + &mut c, + json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id.clone(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-c", "name": "katashiro"}]} + }), + ) + .await; let c_connect_id = loop { let f = recv(&mut c).await; if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { @@ -4653,19 +5063,27 @@ mod acp_ws_integration { // Now the OLDER connection declares the same name under a different id and completes, so it // registers with the HIGHER attach number. - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 9, "method": "session/resume", - "params": {"sessionId": session_id, "cwd": "/w", - "mcpServers": [{"type": "acp", "id": "srv-b", "name": "katashiro"}]} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 9, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-b", "name": "katashiro"}]} + }), + ) + .await; let mut done = false; while !done { let f = recv(&mut b).await; if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { - send(&mut b, json!({ - "jsonrpc": "2.0", "id": f["id"].clone(), - "result": {"connectionId": "conn-b"} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-b"} + }), + ) + .await; } else if handled_inner_lifecycle(&mut b, &f).await == Some("initialize") { done = true; } @@ -4673,10 +5091,14 @@ mod acp_ws_integration { wait_for_tunnels(®istry, 1).await; // Finally let the stalled, newer-connection establish finish. - send(&mut c, json!({ - "jsonrpc": "2.0", "id": c_connect_id, - "result": {"connectionId": "conn-c"} - })).await; + send( + &mut c, + json!({ + "jsonrpc": "2.0", "id": c_connect_id, + "result": {"connectionId": "conn-c"} + }), + ) + .await; loop { let f = recv(&mut c).await; if handled_inner_lifecycle(&mut c, &f).await == Some("initialize") { @@ -4725,15 +5147,23 @@ mod acp_ws_integration { async fn within_one_connection_a_late_finishing_older_establish_loses_to_its_successor() { let (url, registry) = serve().await; let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut ws).await; - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": 2, "method": "session/new", - "params": {"cwd": "/w", "mcpServers": []} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": []} + }), + ) + .await; let session_id = loop { let f = recv(&mut ws).await; if f.get("id") == Some(&json!(2)) { @@ -4743,11 +5173,15 @@ mod acp_ws_integration { // First resume: declare srv-1 and PARK it — its `mcp/connect` is captured, not answered, so // it holds the lower attach number while making no progress. - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": 3, "method": "session/resume", - "params": {"sessionId": session_id.clone(), "cwd": "/w", - "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": 3, "method": "session/resume", + "params": {"sessionId": session_id.clone(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + }), + ) + .await; let parked_connect_id = loop { let f = recv(&mut ws).await; if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { @@ -4757,21 +5191,29 @@ mod acp_ws_integration { }; // Second resume on the SAME connection: same name, new id, driven to completion. - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": 4, "method": "session/resume", - "params": {"sessionId": session_id, "cwd": "/w", - "mcpServers": [{"type": "acp", "id": "srv-2", "name": "katashiro"}]} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": 4, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-2", "name": "katashiro"}]} + }), + ) + .await; let mut registered = false; while !registered { let f = recv(&mut ws).await; if f.get("method").and_then(Value::as_str) == Some("mcp/connect") && f["params"]["acpId"] == json!("srv-2") { - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": f["id"].clone(), - "result": {"connectionId": "conn-2"} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-2"} + }), + ) + .await; } else if handled_inner_lifecycle(&mut ws, &f).await == Some("initialize") { registered = true; } @@ -4779,10 +5221,14 @@ mod acp_ws_integration { wait_for_tunnels(®istry, 1).await; // Now release the parked, EARLIER establish. - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": parked_connect_id, - "result": {"connectionId": "conn-1"} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": parked_connect_id, + "result": {"connectionId": "conn-1"} + }), + ) + .await; loop { let f = recv(&mut ws).await; if handled_inner_lifecycle(&mut ws, &f).await == Some("initialize") { @@ -4825,15 +5271,23 @@ mod acp_ws_integration { // B opens first, so its connection is the OLDER one. It declares nothing yet. let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut b).await; - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 2, "method": "session/new", - "params": {"cwd": "/w", "mcpServers": []} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": []} + }), + ) + .await; let session_id = loop { let f = recv(&mut b).await; if f.get("id") == Some(&json!(2)) { @@ -4843,23 +5297,35 @@ mod acp_ws_integration { // C opens second (NEWER connection) and establishes srv-3 under the shared name. let (mut c, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut c, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut c, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut c).await; - send(&mut c, json!({ - "jsonrpc": "2.0", "id": 2, "method": "session/resume", - "params": {"sessionId": session_id.clone(), "cwd": "/w", - "mcpServers": [{"type": "acp", "id": "srv-3", "name": "katashiro"}]} - })).await; + send( + &mut c, + json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id.clone(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-3", "name": "katashiro"}]} + }), + ) + .await; loop { let f = recv(&mut c).await; if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { - send(&mut c, json!({ - "jsonrpc": "2.0", "id": f["id"].clone(), - "result": {"connectionId": "conn-c"} - })).await; + send( + &mut c, + json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-c"} + }), + ) + .await; } else if handled_inner_lifecycle(&mut c, &f).await == Some("initialize") { break; } @@ -4867,19 +5333,27 @@ mod acp_ws_integration { wait_for_tunnels(®istry, 1).await; // The OLDER connection now declares the same NAME under a different id, and completes. - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 9, "method": "session/resume", - "params": {"sessionId": session_id, "cwd": "/w", - "mcpServers": [{"type": "acp", "id": "srv-4", "name": "katashiro"}]} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 9, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-4", "name": "katashiro"}]} + }), + ) + .await; let mut done = false; while !done { let f = recv(&mut b).await; if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { - send(&mut b, json!({ - "jsonrpc": "2.0", "id": f["id"].clone(), - "result": {"connectionId": "conn-b"} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-b"} + }), + ) + .await; } else if handled_inner_lifecycle(&mut b, &f).await == Some("initialize") { done = true; } @@ -4906,7 +5380,11 @@ mod acp_ws_integration { let reg = registry.lock().unwrap(); reg.keys().map(|(_, id)| id.clone()).collect() }; - assert_eq!(ids, vec!["srv-3".to_string()], "the newer connection's tunnel must hold the name"); + assert_eq!( + ids, + vec!["srv-3".to_string()], + "the newer connection's tunnel must hold the name" + ); } /// An older connection's late resume must not TAKE OVER a newer connection's tunnel either. @@ -4929,15 +5407,23 @@ mod acp_ws_integration { // Connection B (older) opens the session but establishes nothing yet. let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut b).await; - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 2, "method": "session/new", - "params": {"cwd": "/w", "mcpServers": []} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": []} + }), + ) + .await; let session_id = loop { let f = recv(&mut b).await; if f.get("id") == Some(&json!(2)) { @@ -4947,23 +5433,35 @@ mod acp_ws_integration { // Connection C (newer) resumes and establishes srv-1. let (mut c, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut c, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut c, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut c).await; - send(&mut c, json!({ - "jsonrpc": "2.0", "id": 2, "method": "session/resume", - "params": {"sessionId": session_id.clone(), "cwd": "/w", - "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} - })).await; + send( + &mut c, + json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id.clone(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + }), + ) + .await; loop { let f = recv(&mut c).await; if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { - send(&mut c, json!({ - "jsonrpc": "2.0", "id": f["id"].clone(), - "result": {"connectionId": "conn-c"} - })).await; + send( + &mut c, + json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-c"} + }), + ) + .await; } else if handled_inner_lifecycle(&mut c, &f).await == Some("initialize") { break; } @@ -4971,19 +5469,27 @@ mod acp_ws_integration { wait_for_tunnels(®istry, 1).await; // The OLDER connection now resumes declaring THE SAME id, and answers its handshake fully. - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 9, "method": "session/resume", - "params": {"sessionId": session_id, "cwd": "/w", - "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 9, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + }), + ) + .await; let mut answered = false; while !answered { let f = recv(&mut b).await; if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { - send(&mut b, json!({ - "jsonrpc": "2.0", "id": f["id"].clone(), - "result": {"connectionId": "conn-b"} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-b"} + }), + ) + .await; } else if handled_inner_lifecycle(&mut b, &f).await == Some("initialize") { answered = true; } @@ -4995,7 +5501,10 @@ mod acp_ws_integration { loop { let f = recv(&mut c).await; if f.get("method").and_then(Value::as_str) == Some("mcp/disconnect") { - return f["params"]["connectionId"].as_str().unwrap_or("?").to_string(); + return f["params"]["connectionId"] + .as_str() + .unwrap_or("?") + .to_string(); } } }) @@ -5007,7 +5516,11 @@ mod acp_ws_integration { late resume wins precisely because it ran late", stolen.ok() ); - assert_eq!(registry.lock().unwrap().len(), 1, "exactly one tunnel must hold the slot"); + assert_eq!( + registry.lock().unwrap().len(), + 1, + "exactly one tunnel must hold the slot" + ); } /// An older connection's late resume must not retire a NEWER connection's tunnel. @@ -5032,26 +5545,38 @@ mod acp_ws_integration { // Connection C (newer) resumes the same session and adds srv-3 under a different name. let (mut c, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut c, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut c, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut c).await; - send(&mut c, json!({ - "jsonrpc": "2.0", "id": 2, "method": "session/resume", - "params": {"sessionId": session_id.clone(), "cwd": "/w", - "mcpServers": [{"type": "acp", "id": "srv-2", "name": "katashiro"}, - {"type": "acp", "id": "srv-3", "name": "notes"}]} - })).await; + send( + &mut c, + json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id.clone(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-2", "name": "katashiro"}, + {"type": "acp", "id": "srv-3", "name": "notes"}]} + }), + ) + .await; loop { let f = recv(&mut c).await; if f.get("method").and_then(Value::as_str) == Some("mcp/connect") && f["params"]["acpId"] == json!("srv-3") { - send(&mut c, json!({ - "jsonrpc": "2.0", "id": f["id"].clone(), - "result": {"connectionId": "conn-c"} - })).await; + send( + &mut c, + json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-c"} + }), + ) + .await; } else if handled_inner_lifecycle(&mut c, &f).await == Some("initialize") { break; } @@ -5059,11 +5584,15 @@ mod acp_ws_integration { wait_for_tunnels(®istry, 2).await; // Now the OLDER connection resumes, still declaring only what it knew about. - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 9, "method": "session/resume", - "params": {"sessionId": session_id, "cwd": "/w", - "mcpServers": [{"type": "acp", "id": "srv-2", "name": "katashiro"}]} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 9, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-2", "name": "katashiro"}]} + }), + ) + .await; // srv-3 belongs to a newer connection and must survive. Poll: a wrongful retire is async. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); @@ -5099,16 +5628,24 @@ mod acp_ws_integration { wait_for_tunnels(®istry, 1).await; let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut b).await; // No `mcpServers` key at all. - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 2, "method": "session/resume", - "params": {"sessionId": session_id, "cwd": "/w"} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w"} + }), + ) + .await; let resumed = recv(&mut b).await; assert!(resumed.get("result").is_some(), "resume failed: {resumed}"); @@ -5120,12 +5657,19 @@ mod acp_ws_integration { // reads as if it were the only thing standing between `null` and a full sweep, and the next // person to relax the schema needs the two facts in one place. for shape in [json!(null), json!({}), json!("nonsense")] { - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 3, "method": "session/resume", - "params": {"sessionId": session_id, "cwd": "/w", "mcpServers": shape} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 3, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", "mcpServers": shape} + }), + ) + .await; let r = recv(&mut b).await; - assert!(r.get("result").is_some() || r.get("error").is_some(), "no reply: {r}"); + assert!( + r.get("result").is_some() || r.get("error").is_some(), + "no reply: {r}" + ); } // The tunnel must stay. Poll, because a wrongful teardown is asynchronous. @@ -5169,11 +5713,15 @@ mod acp_ws_integration { wait_for_tunnels(®istry, 1).await; // Same connection, same id, re-declared. - send(&mut a, json!({ - "jsonrpc": "2.0", "id": 3, "method": "session/resume", - "params": {"sessionId": session_id, "cwd": "/w", - "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} - })).await; + send( + &mut a, + json!({ + "jsonrpc": "2.0", "id": 3, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + }), + ) + .await; // Nothing may be disconnected. A sweep-everything implementation retires conn-1 here and // then re-establishes it, which this catches; the correct implementation sends no @@ -5182,7 +5730,10 @@ mod acp_ws_integration { loop { let f = recv(&mut a).await; if f.get("method").and_then(Value::as_str) == Some("mcp/disconnect") { - return f["params"]["connectionId"].as_str().unwrap_or("?").to_string(); + return f["params"]["connectionId"] + .as_str() + .unwrap_or("?") + .to_string(); } } }) @@ -5193,7 +5744,11 @@ mod acp_ws_integration { set is not 'registered minus declared', it is 'everything'", quiet.ok() ); - assert_eq!(registry.lock().unwrap().len(), 1, "the tunnel must still be registered"); + assert_eq!( + registry.lock().unwrap().len(), + 1, + "the tunnel must still be registered" + ); } /// A resume on a NEW connection that stops declaring a server retires its tunnel. @@ -5220,15 +5775,23 @@ mod acp_ws_integration { // Connection 2 — a genuine reconnect — resumes the same session declaring NOTHING. let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut b).await; - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 2, "method": "session/resume", - "params": {"sessionId": session_id, "cwd": "/w", "mcpServers": []} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", "mcpServers": []} + }), + ) + .await; let resumed = recv(&mut b).await; assert!(resumed.get("result").is_some(), "resume failed: {resumed}"); @@ -5285,10 +5848,14 @@ mod acp_ws_integration { // Socket A: declare `katashiro` as srv-1, then STALL — do not answer its `mcp/connect`. let (mut a, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut a, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut a, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut a).await; send(&mut a, json!({ "jsonrpc": "2.0", "id": 2, "method": "session/new", @@ -5311,23 +5878,35 @@ mod acp_ws_integration { // Socket B: resume the SAME session — same channel — declaring the same name as srv-2, and // carry it all the way to registered. This is the newer attach. let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut b).await; - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 2, "method": "session/resume", - "params": {"sessionId": session_id, "cwd": "/w", - "mcpServers": [{"type": "acp", "id": "srv-2", "name": "katashiro"}]} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-2", "name": "katashiro"}]} + }), + ) + .await; loop { let f = recv(&mut b).await; if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { - send(&mut b, json!({ - "jsonrpc": "2.0", "id": f["id"].clone(), - "result": {"connectionId": "conn-new"} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-new"} + }), + ) + .await; } else if handled_inner_lifecycle(&mut b, &f).await == Some("initialize") { break; } @@ -5335,10 +5914,14 @@ mod acp_ws_integration { wait_for_tunnels(®istry, 1).await; // Now let the OLDER establish finish. - send(&mut a, json!({ - "jsonrpc": "2.0", "id": a_connect_id.unwrap(), - "result": {"connectionId": "conn-old"} - })).await; + send( + &mut a, + json!({ + "jsonrpc": "2.0", "id": a_connect_id.unwrap(), + "result": {"connectionId": "conn-old"} + }), + ) + .await; loop { let f = recv(&mut a).await; if handled_inner_lifecycle(&mut a, &f).await == Some("initialize") { @@ -5404,10 +5987,14 @@ mod acp_ws_integration { // Socket A declares srv-1 and stalls with its `mcp/connect` unanswered. let (mut a, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut a, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut a, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut a).await; send(&mut a, json!({ "jsonrpc": "2.0", "id": 2, "method": "session/new", @@ -5426,23 +6013,35 @@ mod acp_ws_integration { // Socket B reconnects and re-declares THE SAME id, completing fully. let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut b).await; - send(&mut b, json!({ - "jsonrpc": "2.0", "id": 2, "method": "session/resume", - "params": {"sessionId": session_id.unwrap(), "cwd": "/w", - "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id.unwrap(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + }), + ) + .await; loop { let f = recv(&mut b).await; if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { - send(&mut b, json!({ - "jsonrpc": "2.0", "id": f["id"].clone(), - "result": {"connectionId": "conn-new"} - })).await; + send( + &mut b, + json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-new"} + }), + ) + .await; } else if handled_inner_lifecycle(&mut b, &f).await == Some("initialize") { break; } @@ -5450,10 +6049,14 @@ mod acp_ws_integration { wait_for_tunnels(®istry, 1).await; // Now let the OLDER establish finish, on the same key. - send(&mut a, json!({ - "jsonrpc": "2.0", "id": a_connect_id.unwrap(), - "result": {"connectionId": "conn-old"} - })).await; + send( + &mut a, + json!({ + "jsonrpc": "2.0", "id": a_connect_id.unwrap(), + "result": {"connectionId": "conn-old"} + }), + ) + .await; loop { let f = recv(&mut a).await; if handled_inner_lifecycle(&mut a, &f).await == Some("initialize") { @@ -5472,15 +6075,21 @@ mod acp_ws_integration { } } }; - let disconnected = - tokio::time::timeout(std::time::Duration::from_secs(10), wait_disconnect) - .await - .expect( - "an older establish reusing the same server_id overwrote the newer live handle \ + let disconnected = tokio::time::timeout( + std::time::Duration::from_secs(10), + wait_disconnect, + ) + .await + .expect( + "an older establish reusing the same server_id overwrote the newer live handle \ instead of standing down — same-key ordering is not enforced", - ); + ); assert_eq!(disconnected, "conn-old"); - assert_eq!(registry.lock().unwrap().len(), 1, "exactly one tunnel must remain"); + assert_eq!( + registry.lock().unwrap().len(), + 1, + "exactly one tunnel must remain" + ); } /// A server that *succeeds* at `initialize` but answers a protocol version we do not speak @@ -5501,10 +6110,14 @@ mod acp_ws_integration { async fn a_server_answering_an_unsupported_protocol_version_is_not_registered() { let (url, registry) = serve().await; let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut ws).await; send(&mut ws, json!({ "jsonrpc": "2.0", "id": 2, "method": "session/new", @@ -5528,20 +6141,28 @@ mod acp_ws_integration { let f = recv(&mut ws).await; match f.get("method").and_then(Value::as_str) { Some("mcp/connect") => { - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": f["id"].clone(), - "result": {"connectionId": "conn-1"} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-1"} + }), + ) + .await; } Some("mcp/message") if f["params"]["method"] == json!("initialize") => { - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": f["id"].clone(), - "result": { - "protocolVersion": "2019-01-01", - "capabilities": { "tools": {} }, - "serverInfo": { "name": "old-ext", "version": "0" } - } - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": { + "protocolVersion": "2019-01-01", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "old-ext", "version": "0" } + } + }), + ) + .await; answered = true; } _ => {} @@ -5600,10 +6221,15 @@ mod acp_ws_integration { // Server side, exactly as core reaches a session's tunnel. let handle = { let reg = registry.lock().unwrap(); - reg.values().next().expect("a tunnel must be registered").clone() + reg.values() + .next() + .expect("a tunnel must be registered") + .clone() }; let call = tokio::spawn(async move { - handle.mcp_message("tools/call", Some(json!({"name": "katashiro.click"})), 5).await + handle + .mcp_message("tools/call", Some(json!({"name": "katashiro.click"})), 5) + .await }); let framed = recv(&mut ws).await; @@ -5612,10 +6238,14 @@ mod acp_ws_integration { assert_eq!(framed["params"]["method"], json!("tools/call")); assert_eq!(framed["params"]["params"]["name"], json!("katashiro.click")); - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": framed["id"].clone(), - "result": {"content": [{"type": "text", "text": "clicked"}]} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": framed["id"].clone(), + "result": {"content": [{"type": "text", "text": "clicked"}]} + }), + ) + .await; let got = call.await.unwrap().expect("the call must succeed"); assert_eq!(got["content"][0]["text"], json!("clicked")); @@ -5637,12 +6267,19 @@ mod acp_ws_integration { let call = tokio::spawn(async move { handle.mcp_message("tools/call", None, 5).await }); let framed = recv(&mut ws).await; - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": framed["id"].clone(), - "error": {"code": -32603, "message": "no active tab"} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": framed["id"].clone(), + "error": {"code": -32603, "message": "no active tab"} + }), + ) + .await; - let err = call.await.unwrap().expect_err("a remote error must not read as success"); + let err = call + .await + .unwrap() + .expect_err("a remote error must not read as success"); assert!( err.contains("no active tab"), "the client's message must reach the caller, got: {err}" @@ -5690,7 +6327,10 @@ mod acp_ws_integration { "a cancellation is a notification: giving it an `id` would oblige a reply nobody reads" ); - let err = call.await.unwrap().expect_err("a timed-out call must not read as success"); + let err = call + .await + .unwrap() + .expect_err("a timed-out call must not read as success"); assert!(err.contains("timed out"), "got: {err}"); } @@ -5708,10 +6348,14 @@ mod acp_ws_integration { async fn pending_tunnel_establishes_do_not_consume_the_prompt_budget() { let (url, _registry) = serve().await; let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut ws).await; // Enough sessions to park strictly more than MAX_INFLIGHT_PROMPTS establishes. @@ -5734,10 +6378,14 @@ mod acp_ws_integration { let servers: Vec = (0..MAX_ACP_SERVERS_PER_SESSION) .map(|i| json!({"type": "acp", "id": format!("s{n}-{i}"), "name": format!("n{n}-{i}")})) .collect(); - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": req_id, "method": "session/new", - "params": {"cwd": "/w", "mcpServers": servers} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": req_id, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": servers} + }), + ) + .await; // Collect this session's response; mcp/connect frames are observed and NEVER answered, // which is what keeps each establish task in flight. let mut got_resp = false; @@ -5810,11 +6458,12 @@ mod acp_ws_integration { // substring check is satisfied by every OTHER error too — a session-not-found, a params // error, or the budget message itself once someone rewords it — so it could only ever fail // for one spelling of one regression, and would pass silently through the rest. - let err = f - .get("error") - .unwrap_or_else(|| panic!("the prompt must reach the backend and fail there, got: {f}")); + let err = f.get("error").unwrap_or_else(|| { + panic!("the prompt must reach the backend and fail there, got: {f}") + }); assert_eq!( - err["message"], json!("No agent backend connected"), + err["message"], + json!("No agent backend connected"), "{connects} parked mcp/connects must not spend the prompt budget; the prompt must \ reach the backend and fail only for the missing backend, got: {f}" ); @@ -5832,18 +6481,26 @@ mod acp_ws_integration { let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); // Declare TWO servers, answer both mcp/connect calls. - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut ws).await; - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": 2, "method": "session/new", - "params": {"cwd": "/w", "mcpServers": [ - {"type": "acp", "id": "keep-1", "name": "katashiro"}, - {"type": "acp", "id": "drop-1", "name": "other"} - ]} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": [ + {"type": "acp", "id": "keep-1", "name": "katashiro"}, + {"type": "acp", "id": "drop-1", "name": "other"} + ]} + }), + ) + .await; // Two servers: two `mcp/connect`s AND two lifecycle pairs (initialize + notification). // Exiting on the connects alone leaves the handshakes unread and both establishes fail. let mut session_id = None; @@ -5857,10 +6514,14 @@ mod acp_ws_integration { } if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { let acp_id = f["params"]["acpId"].as_str().unwrap().to_string(); - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": f["id"].clone(), - "result": {"connectionId": format!("conn-{acp_id}")} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": format!("conn-{acp_id}")} + }), + ) + .await; connects += 1; } else if f.get("id") == Some(&json!(2)) { session_id = Some(f["result"]["sessionId"].as_str().unwrap().to_string()); @@ -5869,14 +6530,18 @@ mod acp_ws_integration { wait_for_tunnels(®istry, 2).await; // Resume declaring ONLY the first — "other" is withdrawn. - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": 3, "method": "session/resume", - "params": { - "sessionId": session_id.unwrap(), - "cwd": "/w", - "mcpServers": [{"type": "acp", "id": "keep-2", "name": "katashiro"}] - } - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": 3, "method": "session/resume", + "params": { + "sessionId": session_id.unwrap(), + "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "keep-2", "name": "katashiro"}] + } + }), + ) + .await; // TWO disconnects are owed here, for different reasons, and both are correct: // - `conn-drop-1` because the resume WITHDREW that declaration (this item), and @@ -5897,10 +6562,14 @@ mod acp_ws_integration { disconnected.push(f["params"]["connectionId"].as_str().unwrap().to_string()); } Some("mcp/connect") => { - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": f["id"].clone(), - "result": {"connectionId": "conn-keep-2"} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-keep-2"} + }), + ) + .await; reconnected = true; } _ => {} @@ -5944,8 +6613,11 @@ mod acp_ws_integration { let (url, _registry) = serve().await; let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); let oversized = "x".repeat(super::MAX_FRAME_BYTES + 64); - send(&mut ws, json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "pad": oversized})) - .await; + send( + &mut ws, + json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "pad": oversized}), + ) + .await; // The connection must go away rather than answer. Bounded so a regression that keeps it // open fails here instead of hanging. @@ -5979,22 +6651,36 @@ mod acp_ws_integration { // Over 1 MiB, comfortably under 8 MiB — so it reaches the per-kind check, not the // transport one. Asserting the gap between the two ceilings is the point. let pad = "y".repeat(super::MAX_NON_TUNNEL_FRAME_BYTES + 4096); - assert!(pad.len() < super::MAX_FRAME_BYTES, "must not trip the transport ceiling"); - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": 7, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}, "pad": pad} - })).await; + assert!( + pad.len() < super::MAX_FRAME_BYTES, + "must not trip the transport ceiling" + ); + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": 7, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}, "pad": pad} + }), + ) + .await; let resp = recv(&mut ws).await; assert_eq!(resp["id"], json!(7)); - assert!(resp.get("error").is_some(), "an oversized request must be answered with an error: {resp}"); + assert!( + resp.get("error").is_some(), + "an oversized request must be answered with an error: {resp}" + ); // And the connection still works — the discriminating half. A regression that closed here // would satisfy every assertion above. - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": 8, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": 8, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let after = recv(&mut ws).await; assert_eq!(after["id"], json!(8)); assert!( @@ -6048,24 +6734,32 @@ mod acp_ws_integration { async fn two_same_name_servers_in_one_session_evict_down_to_one() { let (url, registry) = serve().await; let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let init = recv(&mut ws).await; assert!(init.get("result").is_some(), "initialize failed: {init}"); // Same name, different ids — accepted, because the dedup key is the id. - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": 2, "method": "session/new", - "params": { - "cwd": "/w", - "mcpServers": [ - {"type": "acp", "id": "uuid-a", "name": "katashiro"}, - {"type": "acp", "id": "uuid-b", "name": "katashiro"} - ] - } - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": { + "cwd": "/w", + "mcpServers": [ + {"type": "acp", "id": "uuid-a", "name": "katashiro"}, + {"type": "acp", "id": "uuid-b", "name": "katashiro"} + ] + } + }), + ) + .await; // Answer both connects, absorb both inner lifecycles, and wait for the disconnect the // loser is owed. Bounded so a regression fails naming what was missing rather than @@ -6093,28 +6787,45 @@ mod acp_ws_integration { } match frame.get("method").and_then(Value::as_str) { Some("mcp/connect") => { - let acp_id = frame["params"]["acpId"].as_str().expect("acpId").to_string(); + let acp_id = frame["params"]["acpId"] + .as_str() + .expect("acpId") + .to_string(); let conn = format!("conn-{}", acp_id.trim_start_matches("uuid-")); connected.insert(acp_id, conn.clone()); - send(&mut ws, json!({ - "jsonrpc": "2.0", "id": frame["id"].clone(), - "result": {"connectionId": conn} - })).await; + send( + &mut ws, + json!({ + "jsonrpc": "2.0", "id": frame["id"].clone(), + "result": {"connectionId": conn} + }), + ) + .await; } Some("mcp/disconnect") => { disconnected = Some( - frame["params"]["connectionId"].as_str().expect("connectionId").to_string(), + frame["params"]["connectionId"] + .as_str() + .expect("connectionId") + .to_string(), ); } _ => { if frame.get("id") == Some(&json!(2)) { - assert!(frame.get("result").is_some(), "session/new refused: {frame}"); + assert!( + frame.get("result").is_some(), + "session/new refused: {frame}" + ); session_seen = true; } } } } - assert_eq!(connected.len(), 2, "both declared servers must be asked to connect"); + assert_eq!( + connected.len(), + 2, + "both declared servers must be asked to connect" + ); let evicted = disconnected.expect( "the evicted tunnel is owed an mcp/disconnect — if this is missing, the eviction \ branch did not run and this scenario no longer covers it", @@ -6125,7 +6836,11 @@ mod acp_ws_integration { let reg = registry.lock().unwrap(); reg.values().map(|h| h.connection_id.clone()).collect() }; - assert_eq!(survivors.len(), 1, "last-attach-wins must collapse the name to one tunnel"); + assert_eq!( + survivors.len(), + 1, + "last-attach-wins must collapse the name to one tunnel" + ); assert_ne!( survivors[0], evicted, "the disconnect must name the tunnel that LOST, not the one still registered" @@ -6154,19 +6869,27 @@ mod acp_ws_integration { // The client comes back on a fresh socket and resumes, re-declaring under the same name // with the fresh id its runtime mints per connection. let (mut second, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - send(&mut second, json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}} - })).await; + send( + &mut second, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + }), + ) + .await; let _ = recv(&mut second).await; - send(&mut second, json!({ - "jsonrpc": "2.0", "id": 2, "method": "session/resume", - "params": { - "sessionId": session_id, - "cwd": "/w", - "mcpServers": [{"type": "acp", "id": "uuid-new", "name": "katashiro"}] - } - })).await; + send( + &mut second, + json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": { + "sessionId": session_id, + "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "uuid-new", "name": "katashiro"}] + } + }), + ) + .await; let mut connected_new = false; let mut new_lifecycle = 0; while !connected_new || new_lifecycle < 2 { @@ -6176,10 +6899,14 @@ mod acp_ws_integration { continue; } if frame.get("method").and_then(Value::as_str) == Some("mcp/connect") { - send(&mut second, json!({ - "jsonrpc": "2.0", "id": frame["id"].clone(), - "result": {"connectionId": "conn-new"} - })).await; + send( + &mut second, + json!({ + "jsonrpc": "2.0", "id": frame["id"].clone(), + "result": {"connectionId": "conn-new"} + }), + ) + .await; connected_new = true; } if frame.get("id") == Some(&json!(2)) { @@ -6214,7 +6941,8 @@ mod acp_ws_integration { let framed = recv(&mut first).await; assert_eq!(framed["method"], json!("mcp/disconnect")); assert_eq!( - framed["params"]["connectionId"], json!("conn-old"), + framed["params"]["connectionId"], + json!("conn-old"), "the evicted connection is the one owed a disconnect, not its replacement" ); @@ -6223,7 +6951,11 @@ mod acp_ws_integration { let reg = registry.lock().unwrap(); reg.values().map(|h| h.connection_id.clone()).collect() }; - assert_eq!(names, vec!["conn-new".to_string()], "only the newest tunnel may remain"); + assert_eq!( + names, + vec!["conn-new".to_string()], + "only the newest tunnel may remain" + ); // The survivor must WORK, not merely be present in the registry: a replacement that // registers a handle whose socket is gone satisfies every assertion above. This is the @@ -6236,23 +6968,36 @@ mod acp_ws_integration { // the reason, where a missing-frame check could only time out. let handle = { let reg = registry.lock().unwrap(); - reg.values().next().expect("the survivor must be registered").clone() + reg.values() + .next() + .expect("the survivor must be registered") + .clone() }; let call = tokio::spawn(async move { - handle.mcp_message("tools/call", Some(json!({"name": "katashiro.click"})), 5).await + handle + .mcp_message("tools/call", Some(json!({"name": "katashiro.click"})), 5) + .await }); let request = recv(&mut second).await; assert_eq!( - request["method"], json!("mcp/message"), + request["method"], + json!("mcp/message"), "the next frame owed to the surviving connection is the tool call, not a disconnect" ); assert_eq!(request["params"]["connectionId"], json!("conn-new")); - send(&mut second, json!({ - "jsonrpc": "2.0", "id": request["id"].clone(), - "result": {"content": [{"type": "text", "text": "clicked"}]} - })).await; - let got = call.await.unwrap().expect("the surviving tunnel must carry a call"); + send( + &mut second, + json!({ + "jsonrpc": "2.0", "id": request["id"].clone(), + "result": {"content": [{"type": "text", "text": "clicked"}]} + }), + ) + .await; + let got = call + .await + .unwrap() + .expect("the surviving tunnel must carry a call"); assert_eq!(got["content"][0]["text"], json!("clicked")); } } @@ -6293,14 +7038,25 @@ mod acp_log_redaction { let channel_id = format!("acp_{uuid}"); let tag = redact_id(&channel_id); - assert_eq!(tag, redact_id(&channel_id), "same id must tag identically, or logs stop \ - being correlatable across lines"); - assert!(!tag.contains(&uuid.to_string()), "the uuid must not survive into the tag"); + assert_eq!( + tag, + redact_id(&channel_id), + "same id must tag identically, or logs stop \ + being correlatable across lines" + ); + assert!( + !tag.contains(&uuid.to_string()), + "the uuid must not survive into the tag" + ); assert!( !tag.contains(&channel_id) && !channel_id.contains(&tag[1..]), "the tag must not be a substring of the id or vice versa" ); - assert_ne!(tag, redact_id(&format!("acp_{}", Uuid::new_v4())), "different ids differ"); + assert_ne!( + tag, + redact_id(&format!("acp_{}", Uuid::new_v4())), + "different ids differ" + ); } /// No `info!`/`warn!`/`error!` in this file may carry a raw channel or session id. @@ -6343,7 +7099,10 @@ mod acp_log_redaction { let line = src[..start].matches('\n').count() + 1; offenders.push(format!( "{line}: {}", - body.split_whitespace().take(8).collect::>().join(" ") + body.split_whitespace() + .take(8) + .collect::>() + .join(" ") )); } } @@ -6391,7 +7150,10 @@ mod acp_teardown_ownership { { let mut r = reg.lock().unwrap(); // The successor: same channel, fresh server id, different connection. - r.insert(("acp_x".into(), "srv-new".into()), tunnel("conn-B", "cid-new")); + r.insert( + ("acp_x".into(), "srv-new".into()), + tunnel("conn-B", "cid-new"), + ); } teardown(®, &["acp_x"], "conn-A"); @@ -6414,8 +7176,14 @@ mod acp_teardown_ownership { teardown(®, &["acp_x"], "conn-A"); let r = reg.lock().unwrap(); - assert!(!r.contains_key(&("acp_x".to_string(), "srv-a".to_string())), "conn-A's own tunnel must go"); - assert!(r.contains_key(&("acp_x".to_string(), "srv-b".to_string())), "conn-B's must stay"); + assert!( + !r.contains_key(&("acp_x".to_string(), "srv-a".to_string())), + "conn-A's own tunnel must go" + ); + assert!( + r.contains_key(&("acp_x".to_string(), "srv-b".to_string())), + "conn-B's must stay" + ); assert_eq!(r.len(), 1); } @@ -6430,7 +7198,12 @@ mod acp_teardown_ownership { let (tx, _rx) = mpsc::unbounded_channel::(); reg.lock().unwrap().insert( "acp_x".into(), - ReplySink { turn_id: "evt_new".into(), tx, owner: "conn-B".into(), generation: 0 }, + ReplySink { + turn_id: "evt_new".into(), + tx, + owner: "conn-B".into(), + generation: 0, + }, ); } { @@ -6445,5 +7218,4 @@ mod acp_teardown_ownership { "conn-A's cleanup must leave conn-B's sink in place, or the live session goes mute" ); } - } diff --git a/crates/openab-gateway/src/adapters/feishu.rs b/crates/openab-gateway/src/adapters/feishu.rs index c3df29e99..135c28b15 100644 --- a/crates/openab-gateway/src/adapters/feishu.rs +++ b/crates/openab-gateway/src/adapters/feishu.rs @@ -453,7 +453,9 @@ mod event_types { return None; } - let content_json: serde_json::Value = msg.content.as_deref() + let content_json: serde_json::Value = msg + .content + .as_deref() .and_then(|s| serde_json::from_str(s).ok())?; let message_id = msg.message_id.as_deref()?; @@ -462,9 +464,8 @@ mod event_types { let (clean_text, mention_ids, media_refs) = match msg_type { "image" => { let image_key = content_json.get("image_key")?.as_str()?; - let mentions = extract_mentions( - "", msg.mentions.as_deref().unwrap_or(&[]), bot_open_id, - ); + let mentions = + extract_mentions("", msg.mentions.as_deref().unwrap_or(&[]), bot_open_id); let refs = vec![MediaRef::Image { message_id: message_id.to_string(), image_key: image_key.to_string(), @@ -473,12 +474,12 @@ mod event_types { } "file" => { let file_key = content_json.get("file_key")?.as_str()?; - let file_name = content_json.get("file_name") + let file_name = content_json + .get("file_name") .and_then(|v| v.as_str()) .unwrap_or("unknown"); - let mentions = extract_mentions( - "", msg.mentions.as_deref().unwrap_or(&[]), bot_open_id, - ); + let mentions = + extract_mentions("", msg.mentions.as_deref().unwrap_or(&[]), bot_open_id); let refs = vec![MediaRef::File { message_id: message_id.to_string(), file_key: file_key.to_string(), @@ -488,9 +489,8 @@ mod event_types { } "audio" => { let file_key = content_json.get("file_key")?.as_str()?; - let mentions = extract_mentions( - "", msg.mentions.as_deref().unwrap_or(&[]), bot_open_id, - ); + let mentions = + extract_mentions("", msg.mentions.as_deref().unwrap_or(&[]), bot_open_id); let refs = vec![MediaRef::Audio { message_id: message_id.to_string(), file_key: file_key.to_string(), @@ -512,7 +512,9 @@ mod event_types { } } Some("img") => { - if let Some(key) = el.get("image_key").and_then(|v| v.as_str()) { + if let Some(key) = + el.get("image_key").and_then(|v| v.as_str()) + { refs.push(MediaRef::Image { message_id: message_id.to_string(), image_key: key.to_string(), @@ -543,7 +545,10 @@ mod event_types { } _ => { // text - let raw_text = content_json.get("text").and_then(|v| v.as_str()).unwrap_or(""); + let raw_text = content_json + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or(""); if raw_text.trim().is_empty() { return None; } @@ -768,7 +773,8 @@ impl FeishuTokenCache { let expire = body.get("expire").and_then(|v| v.as_u64()).unwrap_or(7200); - let token = body.get("tenant_access_token") + let token = body + .get("tenant_access_token") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .ok_or_else(|| anyhow::anyhow!("feishu token refresh: missing tenant_access_token"))?; @@ -902,7 +908,10 @@ async fn get_ws_endpoint( let body: serde_json::Value = resp.json().await?; let code = body.get("code").and_then(|v| v.as_i64()).unwrap_or(-1); if code != 0 { - let msg = body.get("msg").and_then(|v| v.as_str()).unwrap_or("unknown"); + let msg = body + .get("msg") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); anyhow::bail!("feishu ws endpoint error: code={code} msg={msg}"); } body.get("data") @@ -1135,10 +1144,16 @@ async fn handle_ws_message( // - MultibotMentions: bypass only if participated AND no other bot in thread // - Mentions: never bypass let bypass_mention = detect_and_mark_multibot( - &envelope, bot_id_ref, config, participated_threads, multibot_threads, + &envelope, + bot_id_ref, + config, + participated_threads, + multibot_threads, ); - if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, config, bypass_mention) { + if let Some((mut gateway_event, media_refs)) = + parse_message_event(&envelope, bot_id_ref, config, bypass_mention) + { // Also dedupe by message_id if dedupe.is_duplicate(&gateway_event.message_id) { return; @@ -1170,8 +1185,13 @@ async fn handle_ws_message( // Resolve sender display name (lazy, cached) let name = resolve_user_name( - &gateway_event.sender.id, name_cache, token_cache, client, &config.api_base(), - ).await; + &gateway_event.sender.id, + name_cache, + token_cache, + client, + &config.api_base(), + ) + .await; gateway_event.sender.name = name.clone(); gateway_event.sender.display_name = name; @@ -1181,14 +1201,29 @@ async fn handle_ws_message( let api_base = config.api_base(); for media_ref in &media_refs { let attachment = match media_ref { - MediaRef::Image { message_id, image_key } => { - download_feishu_image(client, &api_base, &token, message_id, image_key).await + MediaRef::Image { + message_id, + image_key, + } => { + download_feishu_image(client, &api_base, &token, message_id, image_key) + .await } - MediaRef::File { message_id, file_key, file_name } => { - download_feishu_file(client, &api_base, &token, message_id, file_key, file_name).await + MediaRef::File { + message_id, + file_key, + file_name, + } => { + download_feishu_file( + client, &api_base, &token, message_id, file_key, file_name, + ) + .await } - MediaRef::Audio { message_id, file_key } => { - download_feishu_audio(client, &api_base, &token, message_id, file_key).await + MediaRef::Audio { + message_id, + file_key, + } => { + download_feishu_audio(client, &api_base, &token, message_id, file_key) + .await } }; gateway_event.content.attachments.push(attachment); @@ -1197,7 +1232,9 @@ async fn handle_ws_message( } // Skip if no text and no attachments (e.g. unsupported file type) - if gateway_event.content.text.trim().is_empty() && gateway_event.content.attachments.is_empty() { + if gateway_event.content.text.trim().is_empty() + && gateway_event.content.attachments.is_empty() + { return; } @@ -1293,10 +1330,7 @@ fn is_feishu_cap_reached_body(body: &str) -> bool { .get("code") .and_then(|c| c.as_i64()) .is_some_and(|code| code == 230072), - Err(_) => { - body.contains("230072") - || body.contains("number of times it can be edited") - } + Err(_) => body.contains("230072") || body.contains("number of times it can be edited"), } } @@ -1321,10 +1355,7 @@ pub enum EditOutcome { /// over `EDIT_COUNTS_CACHE_MAX`, the oldest *insertions* are evicted (not the /// lowest-count entries) so active streams are not bumped out from under /// themselves. -fn increment_edit_count( - cache: &Arc>, - message_id: &str, -) { +fn increment_edit_count(cache: &Arc>, message_id: &str) { let mut c = cache.lock(); let was_new = !c.counts.contains_key(message_id); let entry = c.counts.entry(message_id.to_string()).or_insert(0); @@ -1340,10 +1371,7 @@ fn increment_edit_count( /// Mark a message_id as cap-reached; subsequent edit attempts skip the API /// call and signal `EditOutcome::CapReached` directly so the core finalize /// path can take over. -fn mark_edit_cap( - cache: &Arc>, - message_id: &str, -) { +fn mark_edit_cap(cache: &Arc>, message_id: &str) { let mut c = cache.lock(); let was_new = !c.counts.contains_key(message_id); c.counts.insert(message_id.to_string(), u32::MAX); @@ -1371,10 +1399,7 @@ fn evict_if_overcap(c: &mut EditCountsCache) { /// Return true if this message_id has already reached the edit cap (either /// tracked locally or marked via 230072 sentinel). -fn is_edit_cap_reached( - cache: &Arc>, - message_id: &str, -) -> bool { +fn is_edit_cap_reached(cache: &Arc>, message_id: &str) -> bool { let c = cache.lock(); c.counts .get(message_id) @@ -1387,11 +1412,7 @@ fn is_edit_cap_reached( /// and generic failure. Performs a preemptive local cap check (`FEISHU_EDIT_CAP`) /// before hitting the network, and detects the server-side errcode 230072 via /// body-code-first parsing if the local count drifts from reality. -async fn edit_feishu_message( - adapter: &FeishuAdapter, - message_id: &str, - text: &str, -) -> EditOutcome { +async fn edit_feishu_message(adapter: &FeishuAdapter, message_id: &str, text: &str) -> EditOutcome { // Pre-check: if we've already tracked >= FEISHU_EDIT_CAP edits (or the sentinel // u32::MAX from a 230072 response), skip the API call and signal CapReached so // the caller can stop attempting edits and let the core finalize path recover. @@ -1413,9 +1434,14 @@ async fn edit_feishu_message( "msg_type": "post", "content": post_content.to_string(), }); - match adapter.client.put(&url).bearer_auth(&token) + match adapter + .client + .put(&url) + .bearer_auth(&token) .header("Content-Type", "application/json; charset=utf-8") - .json(&body).send().await + .json(&body) + .send() + .await { Ok(resp) => { let status = resp.status(); @@ -1498,10 +1524,7 @@ async fn edit_feishu_message( /// `message_id` shape is validated by the caller (`handle_reply` dispatch seam, /// via `is_valid_feishu_message_id`) before this is reached, so it is safe to /// interpolate into the URL path here. -async fn delete_feishu_message( - adapter: &FeishuAdapter, - message_id: &str, -) -> Result<(), String> { +async fn delete_feishu_message(adapter: &FeishuAdapter, message_id: &str) -> Result<(), String> { let token = adapter .token_cache .get_token(&adapter.client) @@ -1509,13 +1532,7 @@ async fn delete_feishu_message( .map_err(|e| format!("token error: {e}"))?; let api_base = adapter.config.api_base(); let url = format!("{}/open-apis/im/v1/messages/{}", api_base, message_id); - match adapter - .client - .delete(&url) - .bearer_auth(&token) - .send() - .await - { + match adapter.client.delete(&url).bearer_auth(&token).send().await { Ok(resp) if resp.status().is_success() => { tracing::info!(message_id = %message_id, "feishu message deleted"); Ok(()) @@ -1582,9 +1599,7 @@ fn has_code_fence(text: &str) -> bool { fn has_markdown_table(text: &str) -> bool { text.split('\n').any(|line| { let t = line.trim(); - t.contains('|') - && t.contains('-') - && t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) + t.contains('|') && t.contains('-') && t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) }) } @@ -1783,9 +1798,19 @@ fn try_parse_link(chars: &[char], start: usize) -> Option<(String, String, usize /// Reference to a media resource that needs async download after parse_message_event. pub enum MediaRef { - Image { message_id: String, image_key: String }, - File { message_id: String, file_key: String, file_name: String }, - Audio { message_id: String, file_key: String }, + Image { + message_id: String, + image_key: String, + }, + File { + message_id: String, + file_key: String, + file_name: String, + }, + Audio { + message_id: String, + file_key: String, + }, } const IMAGE_MAX_DIMENSION_PX: u32 = 1200; @@ -1861,13 +1886,21 @@ pub async fn download_feishu_image( if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { if size > IMAGE_MAX_DOWNLOAD { - tracing::warn!(image_key, size, "feishu image Content-Length exceeds 10MB limit, skipping download"); + tracing::warn!( + image_key, + size, + "feishu image Content-Length exceeds 10MB limit, skipping download" + ); return crate::schema::Attachment::rejected( "image", format!("{}.jpg", image_key), "application/octet-stream", size, - format!("size exceeded: {} exceeds {}", format_bytes(size), format_bytes(IMAGE_MAX_DOWNLOAD)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(size), + format_bytes(IMAGE_MAX_DOWNLOAD) + ), ); } } @@ -1887,13 +1920,21 @@ pub async fn download_feishu_image( }; // Fallback check (Content-Length may be absent or misreported) if bytes.len() as u64 > IMAGE_MAX_DOWNLOAD { - tracing::warn!(image_key, size = bytes.len(), "feishu image exceeds 10MB limit"); + tracing::warn!( + image_key, + size = bytes.len(), + "feishu image exceeds 10MB limit" + ); return crate::schema::Attachment::rejected( "image", format!("{}.jpg", image_key), "application/octet-stream", bytes.len() as u64, - format!("size exceeded: {} exceeds {}", format_bytes(bytes.len() as u64), format_bytes(IMAGE_MAX_DOWNLOAD)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(bytes.len() as u64), + format_bytes(IMAGE_MAX_DOWNLOAD) + ), ); } let (compressed, mime) = match resize_and_compress(&bytes) { @@ -1946,9 +1987,9 @@ pub async fn download_feishu_file( // Only download text-like files let ext = file_name.rsplit('.').next().unwrap_or("").to_lowercase(); const TEXT_EXTS: &[&str] = &[ - "txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml", - "rs", "py", "js", "ts", "jsx", "tsx", "go", "java", "c", "cpp", "h", "hpp", - "rb", "sh", "bash", "sql", "html", "css", "ini", "cfg", "conf", "env", + "txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml", "rs", "py", "js", + "ts", "jsx", "tsx", "go", "java", "c", "cpp", "h", "hpp", "rb", "sh", "bash", "sql", + "html", "css", "ini", "cfg", "conf", "env", ]; if !TEXT_EXTS.contains(&ext.as_str()) { tracing::debug!(file_name, "skipping non-text file attachment"); @@ -1992,13 +2033,21 @@ pub async fn download_feishu_file( if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { if size > FILE_MAX_DOWNLOAD { - tracing::warn!(file_name, size, "feishu file Content-Length exceeds 512KB limit, skipping download"); + tracing::warn!( + file_name, + size, + "feishu file Content-Length exceeds 512KB limit, skipping download" + ); return crate::schema::Attachment::rejected( "text_file", file_name.to_string(), "application/octet-stream", size, - format!("size exceeded: {} exceeds {}", format_bytes(size), format_bytes(FILE_MAX_DOWNLOAD)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(size), + format_bytes(FILE_MAX_DOWNLOAD) + ), ); } } @@ -2018,13 +2067,21 @@ pub async fn download_feishu_file( }; // Fallback check (Content-Length may be absent or misreported) if bytes.len() as u64 > FILE_MAX_DOWNLOAD { - tracing::warn!(file_name, size = bytes.len(), "feishu file exceeds 512KB limit"); + tracing::warn!( + file_name, + size = bytes.len(), + "feishu file exceeds 512KB limit" + ); return crate::schema::Attachment::rejected( "text_file", file_name.to_string(), "application/octet-stream", bytes.len() as u64, - format!("size exceeded: {} exceeds {}", format_bytes(bytes.len() as u64), format_bytes(FILE_MAX_DOWNLOAD)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(bytes.len() as u64), + format_bytes(FILE_MAX_DOWNLOAD) + ), ); } let path = match crate::store::store_media(&bytes).await { @@ -2064,7 +2121,9 @@ pub async fn download_feishu_audio( use urlencoding::encode; let url = format!( "{}/open-apis/im/v1/messages/{}/resources/{}?type=file", - api_base, encode(message_id), encode(file_key) + api_base, + encode(message_id), + encode(file_key) ); let resp = match client.get(&url).bearer_auth(token).send().await { Ok(r) => r, @@ -2105,7 +2164,11 @@ pub async fn download_feishu_audio( format!("{}.ogg", file_key), "audio/ogg", size, - format!("size exceeded: {} exceeds {}", format_bytes(size), format_bytes(AUDIO_MAX_DOWNLOAD)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(size), + format_bytes(AUDIO_MAX_DOWNLOAD) + ), ); } } @@ -2124,13 +2187,21 @@ pub async fn download_feishu_audio( } }; if bytes.len() as u64 > AUDIO_MAX_DOWNLOAD { - tracing::warn!(file_key, size = bytes.len(), "feishu audio exceeds 25MB limit"); + tracing::warn!( + file_key, + size = bytes.len(), + "feishu audio exceeds 25MB limit" + ); return crate::schema::Attachment::rejected( "audio", format!("{}.ogg", file_key), "audio/ogg", bytes.len() as u64, - format!("size exceeded: {} exceeds {}", format_bytes(bytes.len() as u64), format_bytes(AUDIO_MAX_DOWNLOAD)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(bytes.len() as u64), + format_bytes(AUDIO_MAX_DOWNLOAD) + ), ); } tracing::debug!(file_key, size = bytes.len(), "feishu audio downloaded"); @@ -2180,7 +2251,10 @@ pub async fn send_post_message( ) } else { ( - format!("{}/open-apis/im/v1/messages?receive_id_type=chat_id", api_base), + format!( + "{}/open-apis/im/v1/messages?receive_id_type=chat_id", + api_base + ), serde_json::json!({ "receive_id": chat_id, "msg_type": "post", @@ -2315,13 +2389,18 @@ async fn add_reaction(adapter: &FeishuAdapter, message_id: &str, emoji: &str) { }; let token = match adapter.token_cache.get_token(&adapter.client).await { Ok(t) => t, - Err(e) => { tracing::error!(err = %e, "feishu: cannot get token for reaction"); return; } + Err(e) => { + tracing::error!(err = %e, "feishu: cannot get token for reaction"); + return; + } }; let url = format!( "{}/open-apis/im/v1/messages/{}/reactions", - adapter.config.api_base(), message_id + adapter.config.api_base(), + message_id ); - let _ = adapter.client + let _ = adapter + .client .post(&url) .bearer_auth(&token) .json(&serde_json::json!({"reaction_type": {"emoji_type": reaction_type}})) @@ -2337,15 +2416,26 @@ async fn remove_reaction(adapter: &FeishuAdapter, message_id: &str, emoji: &str) }; let token = match adapter.token_cache.get_token(&adapter.client).await { Ok(t) => t, - Err(e) => { tracing::error!(err = %e, "feishu: cannot get token for reaction"); return; } + Err(e) => { + tracing::error!(err = %e, "feishu: cannot get token for reaction"); + return; + } }; // Feishu remove reaction needs reaction_id. Simpler approach: delete by type. // GET reactions, find matching, DELETE by id. let list_url = format!( "{}/open-apis/im/v1/messages/{}/reactions?reaction_type={}", - adapter.config.api_base(), message_id, reaction_type + adapter.config.api_base(), + message_id, + reaction_type ); - let resp = match adapter.client.get(&list_url).bearer_auth(&token).send().await { + let resp = match adapter + .client + .get(&list_url) + .bearer_auth(&token) + .send() + .await + { Ok(r) => r, Err(_) => return, }; @@ -2357,15 +2447,24 @@ async fn remove_reaction(adapter: &FeishuAdapter, message_id: &str, emoji: &str) if let Some(items) = body.pointer("/data/items").and_then(|v| v.as_array()) { let bot_id = adapter.bot_open_id.read().await; for item in items { - let is_ours = item.pointer("/operator/operator_id/open_id") - .and_then(|v| v.as_str()) == bot_id.as_deref(); + let is_ours = item + .pointer("/operator/operator_id/open_id") + .and_then(|v| v.as_str()) + == bot_id.as_deref(); if is_ours { if let Some(reaction_id) = item.get("reaction_id").and_then(|v| v.as_str()) { let del_url = format!( "{}/open-apis/im/v1/messages/{}/reactions/{}", - adapter.config.api_base(), message_id, reaction_id + adapter.config.api_base(), + message_id, + reaction_id ); - let _ = adapter.client.delete(&del_url).bearer_auth(&token).send().await; + let _ = adapter + .client + .delete(&del_url) + .bearer_auth(&token) + .send() + .await; return; } } @@ -2394,7 +2493,8 @@ fn check_thread_participated( // Intentionally recover from poisoned mutex — cache data loss is acceptable // and preferable to panicking the gateway. let c = cache.lock(); - c.get(tid).is_some_and(|ts| ts.elapsed().as_secs() < session_ttl_secs) + c.get(tid) + .is_some_and(|ts| ts.elapsed().as_secs() < session_ttl_secs) }) .unwrap_or(false) } @@ -2417,9 +2517,8 @@ fn detect_and_mark_multibot( participated_threads: &Arc>>, multibot_threads: &Arc>>, ) -> bool { - let self_participated = check_thread_participated( - envelope, participated_threads, config.session_ttl_secs, - ); + let self_participated = + check_thread_participated(envelope, participated_threads, config.session_ttl_secs); let thread_id_for_check = envelope .event @@ -2438,14 +2537,15 @@ fn detect_and_mark_multibot( .and_then(|m| m.mentions.as_ref()); if let Some(mention_list) = mentions { let bot_self_id = bot_open_id.unwrap_or(""); - let mention_ids: Vec<_> = mention_list.iter().filter_map(|m| { - m.id.as_ref().and_then(|id| id.open_id.as_deref()) - }).collect(); + let mention_ids: Vec<_> = mention_list + .iter() + .filter_map(|m| m.id.as_ref().and_then(|id| id.open_id.as_deref())) + .collect(); let mentions_other_bot = if !config.trusted_bot_ids.is_empty() { - mention_ids.iter().any(|oid| { - config.trusted_bot_ids.iter().any(|bid| bid == oid) - }) + mention_ids + .iter() + .any(|oid| config.trusted_bot_ids.iter().any(|bid| bid == oid)) } else if !config.allowed_users.is_empty() { mention_ids.iter().any(|oid| { *oid != bot_self_id && !config.allowed_users.iter().any(|u| u == oid) @@ -2591,9 +2691,7 @@ pub async fn handle_reply( // behavior). The card_message_id comes from our own session state // (gateway-internal, trusted) so it bypasses the shape check. let card_msg = { - let mut reg = adapter - .stream_sessions - .lock(); + let mut reg = adapter.stream_sessions.lock(); reg.remove(&reply.reply_to).map(|s| s.card_message_id) }; let target = card_msg.as_deref().unwrap_or(&reply.reply_to); @@ -2647,7 +2745,9 @@ pub async fn handle_reply( } // quote_message_id (agent-controlled reply-to) takes priority over thread_id - let reply_target = reply.quote_message_id.as_deref() + let reply_target = reply + .quote_message_id + .as_deref() .or(reply.channel.thread_id.as_deref()); let thread_id = reply.channel.thread_id.as_deref(); @@ -2656,11 +2756,27 @@ pub async fn handle_reply( // Use post (rich text) format for markdown rendering. // When in a thread (thread_id present), use reply API to stay in the same thread. if text.len() <= limit { - let result = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, reply_target, text).await; + let result = send_post_message( + &adapter.client, + &api_base, + &token, + &reply.channel.id, + reply_target, + text, + ) + .await; // Fallback: if quote_message_id caused failure, retry without it let result = if result.is_none() && reply.quote_message_id.is_some() { tracing::warn!(quote_message_id = ?reply.quote_message_id, channel_id = %reply.channel.id, "reply-to failed, falling back to plain send"); - send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, text).await + send_post_message( + &adapter.client, + &api_base, + &token, + &reply.channel.id, + thread_id, + text, + ) + .await } else { result }; @@ -2669,7 +2785,11 @@ pub async fn handle_reply( adapter.dedupe.is_duplicate(&msg_id); // Record thread participation for mention bypass if let Some(tid) = thread_id { - record_participation(&adapter.participated_threads, tid, adapter.config.session_ttl_secs); + record_participation( + &adapter.participated_threads, + tid, + adapter.config.session_ttl_secs, + ); } // Send response with message_id back to OAB core (for streaming edit) if let Some(ref req_id) = reply.request_id { @@ -2714,7 +2834,16 @@ pub async fn handle_reply( let mut succeeded = 0usize; let mut last_msg_id: Option = None; for chunk in &chunks { - if let Some(msg_id) = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, reply_target, chunk).await { + if let Some(msg_id) = send_post_message( + &adapter.client, + &api_base, + &token, + &reply.channel.id, + reply_target, + chunk, + ) + .await + { adapter.dedupe.is_duplicate(&msg_id); succeeded += 1; last_msg_id = Some(msg_id); @@ -2724,7 +2853,16 @@ pub async fn handle_reply( if succeeded == 0 && reply.quote_message_id.is_some() { tracing::warn!(quote_message_id = ?reply.quote_message_id, channel_id = %reply.channel.id, "chunked reply-to failed, falling back to plain send"); for chunk in &chunks { - if let Some(msg_id) = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, chunk).await { + if let Some(msg_id) = send_post_message( + &adapter.client, + &api_base, + &token, + &reply.channel.id, + thread_id, + chunk, + ) + .await + { adapter.dedupe.is_duplicate(&msg_id); succeeded += 1; last_msg_id = Some(msg_id); @@ -2733,7 +2871,11 @@ pub async fn handle_reply( } if succeeded > 0 { if let Some(tid) = thread_id { - record_participation(&adapter.participated_threads, tid, adapter.config.session_ttl_secs); + record_participation( + &adapter.participated_threads, + tid, + adapter.config.session_ttl_secs, + ); } } // Report back to core. Success requires every chunk delivered — partial @@ -2818,9 +2960,7 @@ async fn handle_card_edit( Active { card_id: String, seq: i64 }, } let existing = { - let mut reg = adapter - .stream_sessions - .lock(); + let mut reg = adapter.stream_sessions.lock(); match reg.get_mut(&om_post) { None => Existing::None, Some(s) if s.finalized => Existing::Finalized, @@ -2882,9 +3022,7 @@ async fn handle_card_edit( // the full reply as a fresh message (reusing #1122 recovery). feishu_card::CardOutcome::Failed { code, message } => { let card_msg = { - let mut reg = adapter - .stream_sessions - .lock(); + let mut reg = adapter.stream_sessions.lock(); reg.remove(&om_post).map(|s| s.card_message_id) }; if adapter.config.card_fallback_to_post { @@ -2963,15 +3101,17 @@ async fn promote_and_respond( }; // 1. Create the card entity seeded with the current full text. - let card_id = match feishu_card::create_streaming_card(&adapter.client, &api_base, &token, text) - .await - { - Ok(id) => id, - Err(outcome) => { - tracing::warn!(?outcome, "feishu card promote: create failed; post-edit fallback"); - return promote_fallback_post(reply, adapter, event_tx).await; - } - }; + let card_id = + match feishu_card::create_streaming_card(&adapter.client, &api_base, &token, text).await { + Ok(id) => id, + Err(outcome) => { + tracing::warn!( + ?outcome, + "feishu card promote: create failed; post-edit fallback" + ); + return promote_fallback_post(reply, adapter, event_tx).await; + } + }; // 2. Send the interactive card message into the same thread. let reply_target = reply @@ -3001,9 +3141,7 @@ async fn promote_and_respond( // 4. Register the session keyed by om_post (core stays oblivious). Seed // last_text with the current full text for the finalize rebuild. { - let mut reg = adapter - .stream_sessions - .lock(); + let mut reg = adapter.stream_sessions.lock(); reg.promote(&om_post, card_id, card_msg_id, text.to_string()); } tracing::info!(om_post = %om_post, "feishu reply promoted post → card"); @@ -3081,9 +3219,7 @@ async fn try_send_initial_card( // Session keyed by the card's own message_id — core edits this id directly, // so subsequent edit_message calls land on the Active path with no swap. { - let mut reg = adapter - .stream_sessions - .lock(); + let mut reg = adapter.stream_sessions.lock(); reg.promote(&card_msg_id, card_id, card_msg_id.clone(), text.to_string()); } // Mirror the post path's self-echo dedupe + thread participation tracking. @@ -3255,11 +3391,9 @@ fn verify_signature( fn decrypt_event(encrypt_key: &str, encrypted: &str) -> anyhow::Result { use sha2::{Digest, Sha256}; let key = Sha256::digest(encrypt_key.as_bytes()); - let cipher_bytes = base64::Engine::decode( - &base64::engine::general_purpose::STANDARD, - encrypted, - ) - .map_err(|e| anyhow::anyhow!("base64 decode failed: {e}"))?; + let cipher_bytes = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encrypted) + .map_err(|e| anyhow::anyhow!("base64 decode failed: {e}"))?; if cipher_bytes.len() < 16 { anyhow::bail!("encrypted data too short"); @@ -3308,7 +3442,10 @@ pub async fn webhook( .and_then(|v| v.to_str().ok()) .unwrap_or("unknown"); if feishu.rate_limiter.check(ip) { - return (axum::http::StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded") + return ( + axum::http::StatusCode::TOO_MANY_REQUESTS, + "rate limit exceeded", + ) .into_response(); } @@ -3432,16 +3569,25 @@ pub async fn webhook( // Check participated threads and multibot detection for mention bypass let bypass_mention = detect_and_mark_multibot( - &envelope, bot_id_ref, &feishu.config, - &feishu.participated_threads, &feishu.multibot_threads, + &envelope, + bot_id_ref, + &feishu.config, + &feishu.participated_threads, + &feishu.multibot_threads, ); - if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, &feishu.config, bypass_mention) { + if let Some((mut gateway_event, media_refs)) = + parse_message_event(&envelope, bot_id_ref, &feishu.config, bypass_mention) + { if !feishu.dedupe.is_duplicate(&gateway_event.message_id) { let name = resolve_user_name( - &gateway_event.sender.id, &feishu.name_cache, &feishu.token_cache, - &feishu.client, &feishu.config.api_base(), - ).await; + &gateway_event.sender.id, + &feishu.name_cache, + &feishu.token_cache, + &feishu.client, + &feishu.config.api_base(), + ) + .await; gateway_event.sender.name = name.clone(); gateway_event.sender.display_name = name; @@ -3451,14 +3597,46 @@ pub async fn webhook( let api_base = feishu.config.api_base(); for media_ref in &media_refs { let attachment = match media_ref { - MediaRef::Image { message_id, image_key } => { - download_feishu_image(&feishu.client, &api_base, &token, message_id, image_key).await + MediaRef::Image { + message_id, + image_key, + } => { + download_feishu_image( + &feishu.client, + &api_base, + &token, + message_id, + image_key, + ) + .await } - MediaRef::File { message_id, file_key, file_name } => { - download_feishu_file(&feishu.client, &api_base, &token, message_id, file_key, file_name).await + MediaRef::File { + message_id, + file_key, + file_name, + } => { + download_feishu_file( + &feishu.client, + &api_base, + &token, + message_id, + file_key, + file_name, + ) + .await } - MediaRef::Audio { message_id, file_key } => { - download_feishu_audio(&feishu.client, &api_base, &token, message_id, file_key).await + MediaRef::Audio { + message_id, + file_key, + } => { + download_feishu_audio( + &feishu.client, + &api_base, + &token, + message_id, + file_key, + ) + .await } }; gateway_event.content.attachments.push(attachment); @@ -3467,7 +3645,9 @@ pub async fn webhook( } // Skip if no text and no attachments (e.g. unsupported file type) - if gateway_event.content.text.trim().is_empty() && gateway_event.content.attachments.is_empty() { + if gateway_event.content.text.trim().is_empty() + && gateway_event.content.attachments.is_empty() + { return axum::http::StatusCode::OK.into_response(); } @@ -3591,12 +3771,24 @@ mod tests { #[test] fn should_use_card_auto_promotes_on_code_fence() { - assert!(should_use_card("here:\n```\ncode\n```", StreamingMode::Auto, 4000)); - assert!(should_use_card("```python\nprint(1)\n```", StreamingMode::Auto, 4000)); + assert!(should_use_card( + "here:\n```\ncode\n```", + StreamingMode::Auto, + 4000 + )); + assert!(should_use_card( + "```python\nprint(1)\n```", + StreamingMode::Auto, + 4000 + )); // Indented fence (markdown_to_post trims leading whitespace too). assert!(should_use_card(" ```\ncode", StreamingMode::Auto, 4000)); // Inline single backticks are NOT a fence. - assert!(!should_use_card("use `cargo` to build", StreamingMode::Auto, 4000)); + assert!(!should_use_card( + "use `cargo` to build", + StreamingMode::Auto, + 4000 + )); } #[test] @@ -3612,13 +3804,29 @@ mod tests { #[test] fn should_use_card_auto_plain_short_text_stays_post() { - assert!(!should_use_card("just a short reply", StreamingMode::Auto, 4000)); + assert!(!should_use_card( + "just a short reply", + StreamingMode::Auto, + 4000 + )); // A line with a pipe but no delimiter row is not a table. - assert!(!should_use_card("a | b but not a table", StreamingMode::Auto, 4000)); + assert!(!should_use_card( + "a | b but not a table", + StreamingMode::Auto, + 4000 + )); // Bullet list with dashes is not a table (no pipe). - assert!(!should_use_card("- item one\n- item two", StreamingMode::Auto, 4000)); + assert!(!should_use_card( + "- item one\n- item two", + StreamingMode::Auto, + 4000 + )); // Thematic break (---) is not a table (no pipe). - assert!(!should_use_card("above\n\n---\n\nbelow", StreamingMode::Auto, 4000)); + assert!(!should_use_card( + "above\n\n---\n\nbelow", + StreamingMode::Auto, + 4000 + )); } // --- Token tests --- @@ -3866,7 +4074,7 @@ mod tests { fn parse_group_without_mention_filtered() { let env = make_envelope("group", "just chatting", "ou_user1", None); let cfg = test_config(); // require_mention = true - // Gateway-side mention gating: group message without bot mention is filtered + // Gateway-side mention gating: group message without bot mention is filtered assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } @@ -3882,7 +4090,13 @@ mod tests { #[test] fn parse_skips_bot_sender() { let mut env = make_envelope("p2p", "hello", "ou_bot", None); - env.event.as_mut().unwrap().sender.as_mut().unwrap().sender_type = Some("bot".into()); + env.event + .as_mut() + .unwrap() + .sender + .as_mut() + .unwrap() + .sender_type = Some("bot".into()); let cfg = test_config(); assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } @@ -3897,7 +4111,13 @@ mod tests { #[test] fn parse_skips_non_text_message() { let mut env = make_envelope("p2p", "hello", "ou_user1", None); - env.event.as_mut().unwrap().message.as_mut().unwrap().message_type = Some("sticker".into()); + env.event + .as_mut() + .unwrap() + .message + .as_mut() + .unwrap() + .message_type = Some("sticker".into()); let cfg = test_config(); assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } @@ -3991,11 +4211,25 @@ mod tests { let name_cache = Arc::new(parking_lot::Mutex::new(HashMap::new())); let client = reqwest::Client::new(); - let name = resolve_user_name("ou_user1", &name_cache, &token_cache, &client, &server.uri()).await; + let name = resolve_user_name( + "ou_user1", + &name_cache, + &token_cache, + &client, + &server.uri(), + ) + .await; assert_eq!(name, "Alice"); // Second call should use cache (expect(1) above ensures no second API call) - let name2 = resolve_user_name("ou_user1", &name_cache, &token_cache, &client, &server.uri()).await; + let name2 = resolve_user_name( + "ou_user1", + &name_cache, + &token_cache, + &client, + &server.uri(), + ) + .await; assert_eq!(name2, "Alice"); } @@ -4022,7 +4256,14 @@ mod tests { let name_cache = Arc::new(parking_lot::Mutex::new(HashMap::new())); let client = reqwest::Client::new(); - let name = resolve_user_name("ou_unknown", &name_cache, &token_cache, &client, &server.uri()).await; + let name = resolve_user_name( + "ou_unknown", + &name_cache, + &token_cache, + &client, + &server.uri(), + ) + .await; assert_eq!(name, "ou_unknown"); } @@ -4033,10 +4274,17 @@ mod tests { // If mention key appears in normal text too, only the first occurrence is removed let mentions = vec![FeishuMention { key: Some("@_user_1".into()), - id: Some(FeishuMentionId { open_id: Some("ou_bot".into()) }), + id: Some(FeishuMentionId { + open_id: Some("ou_bot".into()), + }), name: Some("Bot".into()), }]; - let env = make_envelope("group", "@_user_1 tell me about @_user_1 patterns", "ou_user1", Some(mentions)); + let env = make_envelope( + "group", + "@_user_1 tell me about @_user_1 patterns", + "ou_user1", + Some(mentions), + ); let cfg = test_config(); let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap(); // Only first @_user_1 removed, second preserved @@ -4067,7 +4315,9 @@ mod tests { fn parse_allowed_groups_blocks_unlisted() { let mentions = vec![FeishuMention { key: Some("@_user_1".into()), - id: Some(FeishuMentionId { open_id: Some("ou_bot".into()) }), + id: Some(FeishuMentionId { + open_id: Some("ou_bot".into()), + }), name: Some("Bot".into()), }]; let env = make_envelope("group", "@_user_1 hello", "ou_user1", Some(mentions)); @@ -4080,7 +4330,9 @@ mod tests { fn parse_allowed_groups_permits_listed() { let mentions = vec![FeishuMention { key: Some("@_user_1".into()), - id: Some(FeishuMentionId { open_id: Some("ou_bot".into()) }), + id: Some(FeishuMentionId { + open_id: Some("ou_bot".into()), + }), name: Some("Bot".into()), }]; let env = make_envelope("group", "@_user_1 hello", "ou_user1", Some(mentions)); @@ -4141,7 +4393,13 @@ mod tests { #[test] fn parse_thread_id_from_root_id() { let mut env = make_envelope("p2p", "reply", "ou_user1", None); - env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("om_root".into()); + env.event + .as_mut() + .unwrap() + .message + .as_mut() + .unwrap() + .root_id = Some("om_root".into()); let cfg = test_config(); let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap(); assert_eq!(evt.channel.thread_id, Some("om_root".into())); @@ -4150,7 +4408,13 @@ mod tests { #[test] fn parse_thread_id_from_parent_id() { let mut env = make_envelope("p2p", "reply", "ou_user1", None); - env.event.as_mut().unwrap().message.as_mut().unwrap().parent_id = Some("om_parent".into()); + env.event + .as_mut() + .unwrap() + .message + .as_mut() + .unwrap() + .parent_id = Some("om_parent".into()); let cfg = test_config(); let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap(); assert_eq!(evt.channel.thread_id, Some("om_parent".into())); @@ -4175,9 +4439,15 @@ mod tests { #[test] fn participated_thread_bypasses_mention_gating() { let cfg = test_config(); // require_mention = true - // Build envelope with root_id (in a thread) + // Build envelope with root_id (in a thread) let mut env = make_envelope("group", "Hello", "ou_user1", None); - env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_123".into()); + env.event + .as_mut() + .unwrap() + .message + .as_mut() + .unwrap() + .root_id = Some("root_123".into()); // Without participation: no @mention → None assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); // With participation: no @mention → Some (bypass) @@ -4190,7 +4460,7 @@ mod tests { #[test] fn participated_no_effect_without_thread() { let cfg = test_config(); // require_mention = true - // Message in main channel (no thread_id) — participated flag doesn't help + // Message in main channel (no thread_id) — participated flag doesn't help let env = make_envelope("group", "Hello", "ou_user1", None); assert!(parse_message_event(&env, Some("ou_bot"), &cfg, true).is_none()); } @@ -4216,7 +4486,13 @@ mod tests { let mut cfg = test_config(); cfg.allow_user_messages = AllowUsers::MultibotMentions; let mut env = make_envelope("group", "Hello", "ou_user1", None); - env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_456".into()); + env.event + .as_mut() + .unwrap() + .message + .as_mut() + .unwrap() + .root_id = Some("root_456".into()); // participated + no other bot → bypass_mention_gating=true let result = parse_message_event(&env, Some("ou_bot"), &cfg, true); assert!(result.is_some()); @@ -4227,7 +4503,13 @@ mod tests { let mut cfg = test_config(); cfg.allow_user_messages = AllowUsers::MultibotMentions; let mut env = make_envelope("group", "Hello", "ou_user1", None); - env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_456".into()); + env.event + .as_mut() + .unwrap() + .message + .as_mut() + .unwrap() + .root_id = Some("root_456".into()); // not participated → bypass_mention_gating=false assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } @@ -4237,7 +4519,13 @@ mod tests { let mut cfg = test_config(); cfg.allow_user_messages = AllowUsers::Mentions; let mut env = make_envelope("group", "Hello", "ou_user1", None); - env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_789".into()); + env.event + .as_mut() + .unwrap() + .message + .as_mut() + .unwrap() + .root_id = Some("root_789".into()); // Even with bypass_mention_gating=true, Mentions mode never bypasses // (caller would pass false because Mentions mode always returns false) assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); @@ -4245,7 +4533,7 @@ mod tests { #[test] fn quote_message_id_takes_priority_over_thread_id() { - use crate::schema::{GatewayReply, ReplyChannel, Content}; + use crate::schema::{Content, GatewayReply, ReplyChannel}; let reply = GatewayReply { schema: "openab.gateway.reply.v1".into(), reply_to: "evt_123".into(), @@ -4264,14 +4552,16 @@ mod tests { quote_message_id: Some("om_specific".into()), }; // quote_message_id should take priority - let reply_target = reply.quote_message_id.as_deref() + let reply_target = reply + .quote_message_id + .as_deref() .or(reply.channel.thread_id.as_deref()); assert_eq!(reply_target, Some("om_specific")); } #[test] fn reply_target_falls_back_to_thread_id_when_no_quote() { - use crate::schema::{GatewayReply, ReplyChannel, Content}; + use crate::schema::{Content, GatewayReply, ReplyChannel}; let reply = GatewayReply { schema: "openab.gateway.reply.v1".into(), reply_to: "evt_123".into(), @@ -4289,14 +4579,16 @@ mod tests { request_id: None, quote_message_id: None, }; - let reply_target = reply.quote_message_id.as_deref() + let reply_target = reply + .quote_message_id + .as_deref() .or(reply.channel.thread_id.as_deref()); assert_eq!(reply_target, Some("om_root")); } #[test] fn reply_target_is_none_when_both_absent() { - use crate::schema::{GatewayReply, ReplyChannel, Content}; + use crate::schema::{Content, GatewayReply, ReplyChannel}; let reply = GatewayReply { schema: "openab.gateway.reply.v1".into(), reply_to: "evt_123".into(), @@ -4314,7 +4606,9 @@ mod tests { request_id: None, quote_message_id: None, }; - let reply_target = reply.quote_message_id.as_deref() + let reply_target = reply + .quote_message_id + .as_deref() .or(reply.channel.thread_id.as_deref()); assert_eq!(reply_target, None); } @@ -4506,7 +4800,9 @@ mod tests { #[test] fn message_id_validation_accepts_valid_shapes() { - assert!(is_valid_feishu_message_id("om_dc13264520392907fcq2e6kpngacls")); + assert!(is_valid_feishu_message_id( + "om_dc13264520392907fcq2e6kpngacls" + )); assert!(is_valid_feishu_message_id("om_abc123")); assert!(is_valid_feishu_message_id("om_A_B_c_1_2_3")); } @@ -4709,7 +5005,10 @@ mod tests { "report.pdf", ) .await; - assert!(att.status.is_some(), "non-text extension must have status set"); + assert!( + att.status.is_some(), + "non-text extension must have status set" + ); let reason = att.status.unwrap(); assert!(reason.contains("unsupported format"), "got: {reason}"); } @@ -4767,7 +5066,12 @@ mod tests { let adapter = FeishuAdapter::new(config); let (tx, mut rx) = tokio::sync::broadcast::channel(16); - handle_reply(&edit_reply("om_ph1", "short reply", None, Some("r1")), &adapter, &tx).await; + handle_reply( + &edit_reply("om_ph1", "short reply", None, Some("r1")), + &adapter, + &tx, + ) + .await; assert!( adapter.stream_sessions.lock().is_empty(), @@ -4921,7 +5225,10 @@ mod tests { ); let resp: crate::schema::GatewayResponse = serde_json::from_str(&rx.try_recv().unwrap()).unwrap(); - assert!(!resp.success, "failure must surface so core finalize takes over"); + assert!( + !resp.success, + "failure must surface so core finalize takes over" + ); } /// Card mode: the FIRST reply (command=None) goes straight to a card with no @@ -4974,7 +5281,9 @@ mod tests { // Session keyed by the card's own message_id (no post→card swap). { let reg = adapter.stream_sessions.lock(); - let s = reg.get("om_cardZ").expect("session keyed by card message id"); + let s = reg + .get("om_cardZ") + .expect("session keyed by card message id"); assert_eq!(s.card_id, "cardZ"); } let resp: crate::schema::GatewayResponse = diff --git a/crates/openab-gateway/src/adapters/feishu_card.rs b/crates/openab-gateway/src/adapters/feishu_card.rs index 9c5577644..f2ff5a39c 100644 --- a/crates/openab-gateway/src/adapters/feishu_card.rs +++ b/crates/openab-gateway/src/adapters/feishu_card.rs @@ -850,7 +850,9 @@ mod tests { .and(path("/open-apis/cardkit/v1/cards")) // Only assert the envelope; the escaped `data` string is covered by // the pure card_v2 test above. - .and(body_partial_json(serde_json::json!({ "type": "card_json" }))) + .and(body_partial_json( + serde_json::json!({ "type": "card_json" }), + )) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "code": 0, "msg": "success", @@ -998,8 +1000,7 @@ mod tests { .mount(&server) .await; let client = reqwest::Client::new(); - let out = - finish_card_stream(&client, &server.uri(), "tok", card_id, "final text", 9).await; + let out = finish_card_stream(&client, &server.uri(), "tok", card_id, "final text", 9).await; assert_eq!(out, CardOutcome::Updated); } @@ -1030,8 +1031,15 @@ mod tests { .mount(&server) .await; let client = reqwest::Client::new(); - let mid = - send_card_message(&client, &server.uri(), "tok", "oc_chat", Some(root), "cardabc").await; + let mid = send_card_message( + &client, + &server.uri(), + "tok", + "oc_chat", + Some(root), + "cardabc", + ) + .await; assert_eq!(mid.as_deref(), Some("om_sent789")); } @@ -1052,7 +1060,8 @@ mod tests { .mount(&server) .await; let client = reqwest::Client::new(); - let mid = send_card_message(&client, &server.uri(), "tok", "oc_chat", None, "cardabc").await; + let mid = + send_card_message(&client, &server.uri(), "tok", "oc_chat", None, "cardabc").await; assert_eq!(mid.as_deref(), Some("om_new456")); } @@ -1088,7 +1097,12 @@ mod tests { fn registry_promote_get_remove() { let mut reg = FeishuStreamRegistry::default(); assert!(!reg.contains("om_post1")); - reg.promote("om_post1", "card_a".into(), "om_card_msg".into(), "seed".into()); + reg.promote( + "om_post1", + "card_a".into(), + "om_card_msg".into(), + "seed".into(), + ); assert!(reg.contains("om_post1")); let s = reg.get("om_post1").expect("session present"); assert_eq!(s.card_id, "card_a"); @@ -1109,8 +1123,8 @@ mod tests { let mut reg = FeishuStreamRegistry::default(); reg.promote("om_post1", "card_a".into(), "m1".into(), "t1".into()); reg.get_mut("om_post1").unwrap().next_sequence(); // seq = 1 - // Re-promoting the same key replaces the session and keeps a single - // order entry (a fresh session, sequence reset). + // Re-promoting the same key replaces the session and keeps a single + // order entry (a fresh session, sequence reset). reg.promote("om_post1", "card_b".into(), "m2".into(), "t2".into()); assert_eq!(reg.len(), 1); let s = reg.get("om_post1").unwrap(); diff --git a/crates/openab-gateway/src/adapters/googlechat.rs b/crates/openab-gateway/src/adapters/googlechat.rs index 03866cdb0..0ddc9d026 100644 --- a/crates/openab-gateway/src/adapters/googlechat.rs +++ b/crates/openab-gateway/src/adapters/googlechat.rs @@ -34,8 +34,7 @@ const CHAT_API_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_ /// End-to-end budget for one outbound Google Chat reply. Core waits 35 seconds /// for an acknowledged gateway reply; finishing within 30 seconds leaves room /// for response serialization and WebSocket/broadcast scheduling. -const GOOGLE_CHAT_DELIVERY_DEADLINE: std::time::Duration = - std::time::Duration::from_secs(30); +const GOOGLE_CHAT_DELIVERY_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30); /// Cap on text file attachments per message (matches Discord/Slack). const TEXT_FILE_COUNT_CAP: usize = 5; /// Cap on aggregate text file bytes per message (matches Discord/Slack 1 MB). @@ -300,9 +299,9 @@ fn normalize_adc_target_service_account(value: Option) -> Result {} @@ -763,10 +760,7 @@ pub async fn webhook( if let Some(ref adapter) = state.google_chat { if let Some(ref verifier) = adapter.jwt_verifier { - let auth_header = match headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - { + let auth_header = match headers.get("authorization").and_then(|v| v.to_str().ok()) { Some(h) => h, None => { warn!("googlechat webhook: missing authorization header"); @@ -843,12 +837,7 @@ pub async fn webhook( let thread_id = msg.thread.as_ref().map(|t| t.name.clone()); - let message_id = msg - .name - .rsplit('/') - .next() - .unwrap_or(&msg.name) - .to_string(); + let message_id = msg.name.rsplit('/').next().unwrap_or(&msg.name).to_string(); // No attachments → emit event synchronously and respond 200 if media_refs.is_empty() { @@ -1106,7 +1095,9 @@ impl GoogleChatTokenCache { } async fn refresh(&self, client: &reqwest::Client) -> Result<(String, u64), String> { - let jwt = self.build_jwt().map_err(|e| format!("JWT build error: {e}"))?; + let jwt = self + .build_jwt() + .map_err(|e| format!("JWT build error: {e}"))?; let resp = client .post("https://oauth2.googleapis.com/token") .timeout(TOKEN_REQUEST_TIMEOUT) @@ -1163,8 +1154,7 @@ impl GoogleChatTokenCache { let key = jsonwebtoken::EncodingKey::from_rsa_pem(self.private_key.as_bytes()) .map_err(|e| format!("RSA key parse error: {e}"))?; let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256); - jsonwebtoken::encode(&header, &claims, &key) - .map_err(|e| format!("JWT encode error: {e}")) + jsonwebtoken::encode(&header, &claims, &key).map_err(|e| format!("JWT encode error: {e}")) } } @@ -1366,9 +1356,7 @@ impl MetadataTokenSource { if elapsed < ttl { *self.refresh_retry_after.write().await = Some( Instant::now() - + std::time::Duration::from_secs( - ADC_REFRESH_RETRY_COOLDOWN_SECS, - ), + + std::time::Duration::from_secs(ADC_REFRESH_RETRY_COOLDOWN_SECS), ); warn!( "googlechat ADC refresh failed ({e}); serving cached token \ @@ -1827,9 +1815,9 @@ fn split_text(text: &str, limit: usize) -> Vec<&str> { /// Whitelist of text-like file extensions for `download_googlechat_file`. const TEXT_EXTS: &[&str] = &[ - "txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml", - "rs", "py", "js", "ts", "jsx", "tsx", "go", "java", "c", "cpp", "h", "hpp", - "rb", "sh", "bash", "sql", "html", "css", "ini", "cfg", "conf", + "txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml", "rs", "py", "js", + "ts", "jsx", "tsx", "go", "java", "c", "cpp", "h", "hpp", "rb", "sh", "bash", "sql", "html", + "css", "ini", "cfg", "conf", ]; /// Parse Google Chat attachment array into media references for async download. @@ -1931,7 +1919,13 @@ pub async fn download_googlechat_image( content_name: &str, ) -> crate::schema::Attachment { let url = media_url(api_base, resource_name); - let resp = match client.get(&url).bearer_auth(token).timeout(MEDIA_REQUEST_TIMEOUT).send().await { + let resp = match client + .get(&url) + .bearer_auth(token) + .timeout(MEDIA_REQUEST_TIMEOUT) + .send() + .await + { Ok(r) => r, Err(e) => { warn!(content_name, error = %e, "googlechat image download failed"); @@ -1958,13 +1952,20 @@ pub async fn download_googlechat_image( if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { if size > IMAGE_MAX_DOWNLOAD { - warn!(content_name, size, "googlechat image Content-Length exceeds 10MB limit"); + warn!( + content_name, + size, "googlechat image Content-Length exceeds 10MB limit" + ); return crate::schema::Attachment::rejected( "image", content_name.to_string(), "image/jpeg", size, - format!("size exceeded: {} exceeds {}", format_bytes(size), format_bytes(IMAGE_MAX_DOWNLOAD)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(size), + format_bytes(IMAGE_MAX_DOWNLOAD) + ), ); } } @@ -1983,13 +1984,21 @@ pub async fn download_googlechat_image( } }; if bytes.len() as u64 > IMAGE_MAX_DOWNLOAD { - warn!(content_name, size = bytes.len(), "googlechat image exceeds 10MB limit"); + warn!( + content_name, + size = bytes.len(), + "googlechat image exceeds 10MB limit" + ); return crate::schema::Attachment::rejected( "image", content_name.to_string(), "image/jpeg", bytes.len() as u64, - format!("size exceeded: {} exceeds {}", format_bytes(bytes.len() as u64), format_bytes(IMAGE_MAX_DOWNLOAD)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(bytes.len() as u64), + format_bytes(IMAGE_MAX_DOWNLOAD) + ), ); } let (compressed, mime) = match resize_and_compress(&bytes) { @@ -2052,7 +2061,13 @@ pub async fn download_googlechat_file( } let max_size = FILE_MAX_DOWNLOAD.min(remaining_budget); let url = media_url(api_base, resource_name); - let resp = match client.get(&url).bearer_auth(token).timeout(MEDIA_REQUEST_TIMEOUT).send().await { + let resp = match client + .get(&url) + .bearer_auth(token) + .timeout(MEDIA_REQUEST_TIMEOUT) + .send() + .await + { Ok(r) => r, Err(e) => { warn!(content_name, error = %e, "googlechat file download failed"); @@ -2079,13 +2094,22 @@ pub async fn download_googlechat_file( if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { if size > max_size { - warn!(content_name, size, limit = max_size, "googlechat file Content-Length exceeds limit"); + warn!( + content_name, + size, + limit = max_size, + "googlechat file Content-Length exceeds limit" + ); return crate::schema::Attachment::rejected( "text_file", content_name.to_string(), "text/plain", size, - format!("size exceeded: {} exceeds {}", format_bytes(size), format_bytes(max_size)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(size), + format_bytes(max_size) + ), ); } } @@ -2104,13 +2128,22 @@ pub async fn download_googlechat_file( } }; if bytes.len() as u64 > max_size { - warn!(content_name, size = bytes.len(), limit = max_size, "googlechat file exceeds size limit"); + warn!( + content_name, + size = bytes.len(), + limit = max_size, + "googlechat file exceeds size limit" + ); return crate::schema::Attachment::rejected( "text_file", content_name.to_string(), "text/plain", bytes.len() as u64, - format!("size exceeded: {} exceeds {}", format_bytes(bytes.len() as u64), format_bytes(max_size)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(bytes.len() as u64), + format_bytes(max_size) + ), ); } let path = match crate::store::store_media(&bytes).await { @@ -2148,7 +2181,13 @@ pub async fn download_googlechat_audio( content_type: &str, ) -> crate::schema::Attachment { let url = media_url(api_base, resource_name); - let resp = match client.get(&url).bearer_auth(token).timeout(MEDIA_REQUEST_TIMEOUT).send().await { + let resp = match client + .get(&url) + .bearer_auth(token) + .timeout(MEDIA_REQUEST_TIMEOUT) + .send() + .await + { Ok(r) => r, Err(e) => { warn!(content_name, error = %e, "googlechat audio download failed"); @@ -2175,13 +2214,20 @@ pub async fn download_googlechat_audio( if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { if size > AUDIO_MAX_DOWNLOAD { - warn!(content_name, size, "googlechat audio Content-Length exceeds 25MB limit"); + warn!( + content_name, + size, "googlechat audio Content-Length exceeds 25MB limit" + ); return crate::schema::Attachment::rejected( "audio", content_name.to_string(), "audio/ogg", size, - format!("size exceeded: {} exceeds {}", format_bytes(size), format_bytes(AUDIO_MAX_DOWNLOAD)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(size), + format_bytes(AUDIO_MAX_DOWNLOAD) + ), ); } } @@ -2200,13 +2246,21 @@ pub async fn download_googlechat_audio( } }; if bytes.len() as u64 > AUDIO_MAX_DOWNLOAD { - warn!(content_name, size = bytes.len(), "googlechat audio exceeds 25MB limit"); + warn!( + content_name, + size = bytes.len(), + "googlechat audio exceeds 25MB limit" + ); return crate::schema::Attachment::rejected( "audio", content_name.to_string(), "audio/ogg", bytes.len() as u64, - format!("size exceeded: {} exceeds {}", format_bytes(bytes.len() as u64), format_bytes(AUDIO_MAX_DOWNLOAD)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(bytes.len() as u64), + format_bytes(AUDIO_MAX_DOWNLOAD) + ), ); } let path = match crate::store::store_media(&bytes).await { @@ -2311,7 +2365,10 @@ mod tests { let msg = payload.message.as_ref().unwrap(); assert_eq!(msg.argument_text.as_deref(), Some("hi")); assert_eq!(msg.thread.as_ref().unwrap().name, "spaces/SP/threads/t1"); - assert_eq!(payload.space.as_ref().unwrap().space_type.as_deref(), Some("ROOM")); + assert_eq!( + payload.space.as_ref().unwrap().space_type.as_deref(), + Some("ROOM") + ); } #[test] @@ -2546,10 +2603,7 @@ mod tests { server.uri(), ); - let token = src - .get_token() - .await - .expect("should mint a chat.bot token"); + let token = src.get_token().await.expect("should mint a chat.bot token"); assert_eq!(token, "chat-bot-tok"); } @@ -2578,11 +2632,7 @@ mod tests { .mount(&server) .await; - let src = MetadataTokenSource::with_bases( - same_sa.into(), - server.uri(), - server.uri(), - ); + let src = MetadataTokenSource::with_bases(same_sa.into(), server.uri(), server.uri()); let err = src .get_token() .await @@ -2636,12 +2686,10 @@ mod tests { // Start both production policy helpers from a builder carrying the // same explicit proxy. Metadata must clear it; IAM must retain it. let metadata_client = MetadataTokenSource::build_metadata_client( - reqwest::Client::builder() - .proxy(reqwest::Proxy::all(iam_proxy.uri()).unwrap()), + reqwest::Client::builder().proxy(reqwest::Proxy::all(iam_proxy.uri()).unwrap()), ); let iam_client = MetadataTokenSource::build_iam_client( - reqwest::Client::builder() - .proxy(reqwest::Proxy::all(iam_proxy.uri()).unwrap()), + reqwest::Client::builder().proxy(reqwest::Proxy::all(iam_proxy.uri()).unwrap()), ); let src = MetadataTokenSource::with_bases_and_clients( "chat-bot@project.iam.gserviceaccount.com".into(), @@ -2821,7 +2869,10 @@ mod tests { adc_target_service_account: Some("chat-bot@project.iam.gserviceaccount.com".into()), ..Default::default() }); - assert!(adapter.token_cache.is_none(), "unreadable key file → no cache"); + assert!( + adapter.token_cache.is_none(), + "unreadable key file → no cache" + ); assert!( adapter.metadata_source.is_some(), "use_adc=true → ADC source installed when the key file could not be read" @@ -2880,7 +2931,10 @@ mod tests { "api_not_enabled" ); // Anything else stays unclassified rather than guessing. - assert_eq!(classify_generate_access_token_error(500, "boom"), "unclassified"); + assert_eq!( + classify_generate_access_token_error(500, "boom"), + "unclassified" + ); } #[test] @@ -2990,9 +3044,13 @@ mod tests { assert!(!is_google_chat_message_name("spaces/")); assert!(!is_google_chat_message_name("spaces/SP")); assert!(!is_google_chat_message_name("spaces/SP/messages/")); - assert!(!is_google_chat_message_name("spaces/SP/messages/msg1/extra")); + assert!(!is_google_chat_message_name( + "spaces/SP/messages/msg1/extra" + )); assert!(!is_google_chat_message_name("spaces/SP/messages/msg1?x=1")); - assert!(!is_google_chat_message_name("spaces/SP/messages/msg1#fragment")); + assert!(!is_google_chat_message_name( + "spaces/SP/messages/msg1#fragment" + )); assert!(!is_google_chat_message_name("spaces/SP/messages/msg%2F1")); } @@ -3093,7 +3151,10 @@ mod tests { server.uri(), server.uri(), ); - let err = src.get_token().await.expect_err("blank token must be rejected"); + let err = src + .get_token() + .await + .expect_err("blank token must be rejected"); assert!( err.contains("missing or empty accessToken"), "unexpected error: {err}" @@ -3402,15 +3463,16 @@ mod tests { #[tokio::test] async fn handle_reply_sends_gateway_response_success() { - use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; let mock_server = MockServer::start().await; Mock::given(method("POST")) .and(path_regex("/spaces/.*/messages")) - .respond_with(ResponseTemplate::new(200).set_body_json( - serde_json::json!({"name": "spaces/TEST/messages/msg_abc"}), - )) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"name": "spaces/TEST/messages/msg_abc"})), + ) .mount(&mock_server) .await; @@ -3448,8 +3510,8 @@ mod tests { #[tokio::test] async fn handle_reply_sends_failure_response_on_api_error() { - use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; let mock_server = MockServer::start().await; Mock::given(method("POST")) @@ -3489,13 +3551,17 @@ mod tests { assert!(!resp.success); assert!(resp.message_id.is_none()); let err = resp.error.expect("error should be set on send failure"); - assert!(err.contains("500"), "error should include status code, got: {}", err); + assert!( + err.contains("500"), + "error should include status code, got: {}", + err + ); } #[tokio::test] async fn handle_reply_empty_message_short_circuits() { - use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; let mock_server = MockServer::start().await; // Mount a mock that would fail the test if called @@ -3531,7 +3597,10 @@ mod tests { adapter.handle_reply(&reply, &event_tx).await; let received = event_rx.try_recv(); - assert!(received.is_ok(), "expected failure GatewayResponse for empty message"); + assert!( + received.is_ok(), + "expected failure GatewayResponse for empty message" + ); let resp: GatewayResponse = serde_json::from_str(&received.unwrap()).unwrap(); assert_eq!(resp.request_id, "req_empty"); assert!(!resp.success); @@ -3540,8 +3609,8 @@ mod tests { #[tokio::test] async fn handle_reply_multi_chunk_failure_includes_error() { - use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; let mock_server = MockServer::start().await; Mock::given(method("POST")) @@ -3620,15 +3689,16 @@ mod tests { #[tokio::test] async fn handle_reply_edit_message_does_not_send_response() { - use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; let mock_server = MockServer::start().await; Mock::given(method("PATCH")) .and(path_regex("/spaces/.*/messages/.*")) - .respond_with(ResponseTemplate::new(200).set_body_json( - serde_json::json!({"name": "spaces/SP/messages/msg1"}), - )) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"name": "spaces/SP/messages/msg1"})), + ) .expect(1) .mount(&mock_server) .await; @@ -3663,15 +3733,16 @@ mod tests { #[tokio::test] async fn handle_reply_multi_chunk_sends_gateway_response() { - use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; let mock_server = MockServer::start().await; Mock::given(method("POST")) .and(path_regex("/spaces/.*/messages")) - .respond_with(ResponseTemplate::new(200).set_body_json( - serde_json::json!({"name": "spaces/TEST/messages/first_chunk"}), - )) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"name": "spaces/TEST/messages/first_chunk"})), + ) .mount(&mock_server) .await; @@ -3705,7 +3776,10 @@ mod tests { let resp: GatewayResponse = serde_json::from_str(&received.unwrap()).unwrap(); assert_eq!(resp.request_id, "req_multi"); assert!(resp.success); - assert_eq!(resp.message_id, Some("spaces/TEST/messages/first_chunk".into())); + assert_eq!( + resp.message_id, + Some("spaces/TEST/messages/first_chunk".into()) + ); } #[tokio::test] @@ -3714,16 +3788,17 @@ mod tests { // Result contract reports no overall message receipt, but the error // retains explicit partial-delivery context so operators know one chunk // already reached the space and should not blindly retry the whole turn. - use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; let mock_server = MockServer::start().await; // First request: 200 OK with message name Mock::given(method("POST")) .and(path_regex("/spaces/.*/messages")) - .respond_with(ResponseTemplate::new(200).set_body_json( - serde_json::json!({"name": "spaces/TEST/messages/first_chunk"}), - )) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"name": "spaces/TEST/messages/first_chunk"})), + ) .up_to_n_times(1) .mount(&mock_server) .await; @@ -3764,7 +3839,10 @@ mod tests { let resp: GatewayResponse = serde_json::from_str(&received.unwrap()).unwrap(); assert_eq!(resp.request_id, "req_partial"); assert!(!resp.success, "partial failure must report success=false"); - assert!(resp.message_id.is_none(), "partial delivery is not an overall receipt"); + assert!( + resp.message_id.is_none(), + "partial delivery is not an overall receipt" + ); let err = resp.error.expect("partial failure should set error"); assert!(err.contains("chunk 2/2"), "{err}"); assert!(err.contains("after 1 successful chunk(s)"), "{err}"); @@ -3780,9 +3858,10 @@ mod tests { let server = MockServer::start().await; Mock::given(method("POST")) .and(path_regex("/spaces/.*/messages")) - .respond_with(ResponseTemplate::new(200).set_body_json( - serde_json::json!({"name": "spaces/TEST/messages/first_chunk"}), - )) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"name": "spaces/TEST/messages/first_chunk"})), + ) .up_to_n_times(1) .mount(&server) .await; @@ -3791,9 +3870,7 @@ mod tests { .respond_with( ResponseTemplate::new(200) .set_delay(std::time::Duration::from_millis(250)) - .set_body_json( - serde_json::json!({"name": "spaces/TEST/messages/late_chunk"}), - ), + .set_body_json(serde_json::json!({"name": "spaces/TEST/messages/late_chunk"})), ) .mount(&server) .await; @@ -3923,7 +4000,10 @@ mod tests { #[test] fn media_url_preserves_slashes_and_encodes_specials() { - let url = media_url("https://chat.googleapis.com/v1", "spaces/SP/messages/MSG/attachments/ATT"); + let url = media_url( + "https://chat.googleapis.com/v1", + "spaces/SP/messages/MSG/attachments/ATT", + ); assert_eq!( url, "https://chat.googleapis.com/v1/media/spaces/SP/messages/MSG/attachments/ATT?alt=media" @@ -3937,8 +4017,8 @@ mod tests { #[tokio::test] async fn download_googlechat_image_resizes_and_returns_attachment() { - use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; // Generate a small valid PNG let img = image::RgbImage::from_pixel(10, 10, image::Rgb([255, 0, 0])); @@ -3989,22 +4069,23 @@ mod tests { ) .await; let att = result; - assert!(att.status.is_some(), "non-text extension must have status set"); + assert!( + att.status.is_some(), + "non-text extension must have status set" + ); let reason = att.status.unwrap(); assert!(reason.contains("unsupported format"), "got: {reason}"); } #[tokio::test] async fn download_googlechat_file_text_extension_succeeds() { - use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; let mock_server = MockServer::start().await; Mock::given(method("GET")) .and(path_regex("/media/.*")) - .respond_with( - ResponseTemplate::new(200).set_body_bytes(b"hello world".to_vec()), - ) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"hello world".to_vec())) .mount(&mock_server) .await; @@ -4026,8 +4107,8 @@ mod tests { #[tokio::test] async fn download_googlechat_audio_returns_attachment() { - use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; let mock_server = MockServer::start().await; let audio_bytes = vec![0u8; 1024]; @@ -4056,8 +4137,8 @@ mod tests { #[tokio::test] async fn download_googlechat_image_rejects_oversized_content_length() { - use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; let mock_server = MockServer::start().await; Mock::given(method("GET")) diff --git a/crates/openab-gateway/src/adapters/lineworks.rs b/crates/openab-gateway/src/adapters/lineworks.rs index 036542c36..9cd53ab92 100644 --- a/crates/openab-gateway/src/adapters/lineworks.rs +++ b/crates/openab-gateway/src/adapters/lineworks.rs @@ -1251,8 +1251,7 @@ mod tests { tokio::spawn(async move { // Concurrent task refreshes the token (as a successful // get_token re-exchange would). - *cache.token.write().await = - Some(("tok_new".to_string(), Instant::now(), 3600)); + *cache.token.write().await = Some(("tok_new".to_string(), Instant::now(), 3600)); barrier.wait().await; }) }; diff --git a/crates/openab-gateway/src/adapters/mod.rs b/crates/openab-gateway/src/adapters/mod.rs index 105f51081..e35c2bcf1 100644 --- a/crates/openab-gateway/src/adapters/mod.rs +++ b/crates/openab-gateway/src/adapters/mod.rs @@ -1,23 +1,23 @@ -#[cfg(feature = "telegram")] -pub mod telegram; -#[cfg(feature = "line")] -pub mod line; +#[cfg(feature = "acp")] +#[allow(clippy::all, dead_code, unused)] +pub mod acp_schema; +#[cfg(feature = "acp")] +pub mod acp_server; #[cfg(feature = "feishu")] pub mod feishu; #[cfg(feature = "feishu")] pub mod feishu_card; #[cfg(feature = "googlechat")] pub mod googlechat; -#[cfg(feature = "wecom")] -pub mod wecom; -#[cfg(feature = "teams")] -pub mod teams; -#[cfg(feature = "acp")] -pub mod acp_server; -#[cfg(feature = "acp")] -#[allow(clippy::all, dead_code, unused)] -pub mod acp_schema; +#[cfg(feature = "line")] +pub mod line; #[cfg(feature = "lineworks")] pub mod lineworks; #[cfg(feature = "lineworks")] pub mod lineworks_flex; +#[cfg(feature = "teams")] +pub mod teams; +#[cfg(feature = "telegram")] +pub mod telegram; +#[cfg(feature = "wecom")] +pub mod wecom; diff --git a/crates/openab-gateway/src/adapters/teams.rs b/crates/openab-gateway/src/adapters/teams.rs index 0ee438d11..a097df9a0 100644 --- a/crates/openab-gateway/src/adapters/teams.rs +++ b/crates/openab-gateway/src/adapters/teams.rs @@ -282,7 +282,9 @@ impl TeamsAdapter { } // B2: Validate channel endorsements — key must endorse the activity's channelId - let channel_id = activity.channel_id.as_deref() + let channel_id = activity + .channel_id + .as_deref() .ok_or_else(|| anyhow::anyhow!("activity missing channelId"))?; if key.endorsements.is_empty() { anyhow::bail!("JWK has no endorsements — cannot verify channelId={channel_id}"); @@ -308,9 +310,13 @@ impl TeamsAdapter { let token_data = decode::(token, &decoding_key, &validation)?; // B1: Validate serviceUrl claim matches activity's serviceUrl - let activity_service_url = activity.service_url.as_deref() + let activity_service_url = activity + .service_url + .as_deref() .ok_or_else(|| anyhow::anyhow!("activity missing serviceUrl"))?; - let token_service_url = token_data.claims.get("serviceurl") + let token_service_url = token_data + .claims + .get("serviceurl") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("JWT missing serviceurl claim"))?; if token_service_url != activity_service_url { @@ -806,7 +812,9 @@ mod tests { async fn jwt_rejects_garbage_token() { let adapter = TeamsAdapter::new(make_config(vec![])); let activity = make_activity_with_tenant(Some("t1")); - let result = adapter.validate_jwt("Bearer not.a.valid.jwt", &activity).await; + let result = adapter + .validate_jwt("Bearer not.a.valid.jwt", &activity) + .await; assert!(result.is_err()); } diff --git a/crates/openab-gateway/src/adapters/telegram.rs b/crates/openab-gateway/src/adapters/telegram.rs index f4c381c1a..53ee66b6e 100644 --- a/crates/openab-gateway/src/adapters/telegram.rs +++ b/crates/openab-gateway/src/adapters/telegram.rs @@ -1,4 +1,7 @@ -use crate::media::{format_bytes, resize_and_compress, MediaKind, AUDIO_MAX_DOWNLOAD, FILE_MAX_DOWNLOAD, IMAGE_MAX_DOWNLOAD}; +use crate::media::{ + format_bytes, resize_and_compress, MediaKind, AUDIO_MAX_DOWNLOAD, FILE_MAX_DOWNLOAD, + IMAGE_MAX_DOWNLOAD, +}; use crate::schema::*; use crate::store; use axum::extract::State; @@ -106,12 +109,19 @@ pub async fn webhook( // Priority: CF-Connecting-IP (edge proxy, non-spoofable) > X-Real-IP > X-Forwarded-For // NOTE: XFF leftmost entry is client-supplied and spoofable. Phase 2 must implement // trusted proxy configuration for accurate IP extraction when enforcement is enabled. - let source_ip = headers.get("cf-connecting-ip") + let source_ip = headers + .get("cf-connecting-ip") .and_then(|v| v.to_str().ok()) .map(|s| s.trim().to_string()) - .or_else(|| headers.get("x-real-ip").and_then(|v| v.to_str().ok()).map(|s| s.trim().to_string())) .or_else(|| { - headers.get("x-forwarded-for") + headers + .get("x-real-ip") + .and_then(|v| v.to_str().ok()) + .map(|s| s.trim().to_string()) + }) + .or_else(|| { + headers + .get("x-forwarded-for") .and_then(|v| v.to_str().ok()) .and_then(|v| v.split(',').next()) .map(|s| s.trim().to_string()) @@ -159,19 +169,25 @@ pub async fn webhook( let client = &state.client; if is_photo { if let Some(largest) = msg.photo.iter().max_by_key(|p| p.width * p.height) { - let att = download_telegram_media(client, token, &largest.file_id, MediaKind::Image).await; + let att = + download_telegram_media(client, token, &largest.file_id, MediaKind::Image) + .await; attachments.push(att); } } else if let Some(doc) = msg.document { let file_name = doc.file_name.unwrap_or_else(|| "unknown.txt".to_string()); let mime_type = doc.mime_type.unwrap_or_else(|| "text/plain".to_string()); - let att = download_telegram_document(client, token, &doc.file_id, &file_name, &mime_type).await; + let att = + download_telegram_document(client, token, &doc.file_id, &file_name, &mime_type) + .await; attachments.push(att); } else if let Some(voice) = msg.voice { - let att = download_telegram_media(client, token, &voice.file_id, MediaKind::Audio).await; + let att = + download_telegram_media(client, token, &voice.file_id, MediaKind::Audio).await; attachments.push(att); } else if let Some(audio) = msg.audio { - let att = download_telegram_media(client, token, &audio.file_id, MediaKind::Audio).await; + let att = + download_telegram_media(client, token, &audio.file_id, MediaKind::Audio).await; attachments.push(att); } } @@ -277,7 +293,8 @@ fn check_telegram_subnet(ip_str: &str) -> bool { std::net::IpAddr::V4(v4) => { let octets = v4.octets(); // 149.154.160.0/20 → 149.154.160.0 - 149.154.175.255 - let in_range1 = octets[0] == 149 && octets[1] == 154 && (160..=175).contains(&octets[2]); + let in_range1 = + octets[0] == 149 && octets[1] == 154 && (160..=175).contains(&octets[2]); // 91.108.4.0/22 → 91.108.4.0 - 91.108.7.255 let in_range2 = octets[0] == 91 && octets[1] == 108 && (4..=7).contains(&octets[2]); in_range1 || in_range2 @@ -338,7 +355,11 @@ fn is_complex_markdown(text: &str) -> bool { /// Compute a stable draft_id from channel + thread to avoid collisions in forum topics. fn compute_draft_id(chat_id: &str, thread_id: &Option) -> i64 { let chan: i64 = chat_id.parse::().unwrap_or(1).abs(); - let tid: i64 = thread_id.as_deref().and_then(|t| t.parse::().ok()).unwrap_or(0).abs(); + let tid: i64 = thread_id + .as_deref() + .and_then(|t| t.parse::().ok()) + .unwrap_or(0) + .abs(); (chan.wrapping_add(tid)) % 1_000_000 + 1 } @@ -360,12 +381,20 @@ async fn send_rich_message( "message_thread_id": thread_id, "rich_message": { "markdown": text }, }); - let resp = client.post(&url).json(&body).send().await.map_err(|e| e.to_string())?; + let resp = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| e.to_string())?; let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; if json["ok"].as_bool() == Some(true) { Ok(json) } else { - Err(json["description"].as_str().unwrap_or("unknown error").to_string()) + Err(json["description"] + .as_str() + .unwrap_or("unknown error") + .to_string()) } } @@ -391,12 +420,20 @@ async fn send_rich_message_draft( "draft_id": draft_id, "rich_message": if text.contains(" return, Err(e) => warn!("sendRichMessage failed ({e}), falling back to sendMessage"), } @@ -680,25 +733,48 @@ async fn download_telegram_media( let fallback_filename = format!("{}.{}", file_id, ext); let get_file_url = format!("{TELEGRAM_API_BASE}/bot{}/getFile", bot_token); - let resp = match client.get(&get_file_url).query(&[("file_id", file_id)]).send().await { + let resp = match client + .get(&get_file_url) + .query(&[("file_id", file_id)]) + .send() + .await + { Ok(r) => r, Err(e) => { warn!(file_id, error = %e, kind = ?kind, "Telegram getFile request failed"); - return Attachment::rejected(att_type, fallback_filename, default_mime, 0, "download failed: network error"); + return Attachment::rejected( + att_type, + fallback_filename, + default_mime, + 0, + "download failed: network error", + ); } }; let body: serde_json::Value = match resp.json().await { Ok(v) => v, Err(e) => { warn!(file_id, error = %e, kind = ?kind, "Telegram getFile JSON parse failed"); - return Attachment::rejected(att_type, fallback_filename, default_mime, 0, "download failed: invalid API response"); + return Attachment::rejected( + att_type, + fallback_filename, + default_mime, + 0, + "download failed: invalid API response", + ); } }; let file_path = match body["result"]["file_path"].as_str() { Some(p) => p, None => { warn!(file_id, kind = ?kind, "Telegram getFile response missing file_path"); - return Attachment::rejected(att_type, fallback_filename, default_mime, 0, "download failed: invalid API response"); + return Attachment::rejected( + att_type, + fallback_filename, + default_mime, + 0, + "download failed: invalid API response", + ); } }; @@ -707,7 +783,13 @@ async fn download_telegram_media( Ok(r) => r, Err(e) => { warn!(file_id, error = %e, kind = ?kind, "Telegram media download request failed"); - return Attachment::rejected(att_type, fallback_filename, default_mime, 0, "download failed: network error"); + return Attachment::rejected( + att_type, + fallback_filename, + default_mime, + 0, + "download failed: network error", + ); } }; @@ -732,7 +814,11 @@ async fn download_telegram_media( fallback_filename, default_mime, size, - format!("size exceeded: {} exceeds {}", format_bytes(size), format_bytes(max_size)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(size), + format_bytes(max_size) + ), ); } } @@ -749,7 +835,13 @@ async fn download_telegram_media( Ok(b) => b, Err(e) => { warn!(file_id, error = %e, kind = ?kind, "Telegram media body read failed"); - return Attachment::rejected(att_type, fallback_filename, default_mime, 0, "download failed: body read error"); + return Attachment::rejected( + att_type, + fallback_filename, + default_mime, + 0, + "download failed: body read error", + ); } }; if bytes.len() as u64 > max_size { @@ -759,7 +851,11 @@ async fn download_telegram_media( fallback_filename, default_mime, bytes.len() as u64, - format!("size exceeded: {} exceeds {}", format_bytes(bytes.len() as u64), format_bytes(max_size)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(bytes.len() as u64), + format_bytes(max_size) + ), ); } @@ -768,7 +864,13 @@ async fn download_telegram_media( Ok((c, m)) => (c, m), Err(e) => { error!(err = %e, "Telegram image processing failed"); - return Attachment::rejected(att_type, fallback_filename, default_mime, bytes.len() as u64, "processing failed: image encoding error"); + return Attachment::rejected( + att_type, + fallback_filename, + default_mime, + bytes.len() as u64, + "processing failed: image encoding error", + ); } }, MediaKind::Audio => (bytes.to_vec(), content_type), @@ -779,17 +881,27 @@ async fn download_telegram_media( Some(p) => p, None => { warn!(file_id, kind = ?kind, "Telegram media store failed"); - return Attachment::rejected(att_type, fallback_filename, default_mime, data_bytes.len() as u64, "processing failed: storage error"); + return Attachment::rejected( + att_type, + fallback_filename, + default_mime, + data_bytes.len() as u64, + "processing failed: storage error", + ); } }; info!(file_id, size = data_bytes.len(), kind = ?kind, "Telegram media stored"); Attachment { attachment_type: att_type.into(), - filename: format!("{}.{}", file_id, match kind { - MediaKind::Image => "jpg", - MediaKind::Audio => crate::media::audio_extension(&mime), - }), + filename: format!( + "{}.{}", + file_id, + match kind { + MediaKind::Image => "jpg", + MediaKind::Audio => crate::media::audio_extension(&mime), + } + ), mime_type: mime, data: String::new(), // No base64 — using file path size: data_bytes.len() as u64, @@ -819,25 +931,51 @@ async fn download_telegram_document( } let get_file_url = format!("{TELEGRAM_API_BASE}/bot{}/getFile", bot_token); - let resp = match client.get(&get_file_url).query(&[("file_id", file_id)]).send().await { + let resp = match client + .get(&get_file_url) + .query(&[("file_id", file_id)]) + .send() + .await + { Ok(r) => r, Err(e) => { warn!(file_id, error = %e, "Telegram document getFile request failed"); - return Attachment::rejected("text_file", file_name.to_string(), mime_type, 0, "download failed: network error"); + return Attachment::rejected( + "text_file", + file_name.to_string(), + mime_type, + 0, + "download failed: network error", + ); } }; let body: serde_json::Value = match resp.json().await { Ok(v) => v, Err(e) => { warn!(file_id, error = %e, "Telegram document getFile JSON parse failed"); - return Attachment::rejected("text_file", file_name.to_string(), mime_type, 0, "download failed: invalid API response"); + return Attachment::rejected( + "text_file", + file_name.to_string(), + mime_type, + 0, + "download failed: invalid API response", + ); } }; let file_path = match body["result"]["file_path"].as_str() { Some(p) => p, None => { - warn!(file_id, "Telegram document getFile response missing file_path"); - return Attachment::rejected("text_file", file_name.to_string(), mime_type, 0, "download failed: invalid API response"); + warn!( + file_id, + "Telegram document getFile response missing file_path" + ); + return Attachment::rejected( + "text_file", + file_name.to_string(), + mime_type, + 0, + "download failed: invalid API response", + ); } }; @@ -846,12 +984,22 @@ async fn download_telegram_document( Ok(r) => r, Err(e) => { warn!(file_id, err = %e, "Telegram document network error"); - return Attachment::rejected("text_file", file_name.to_string(), mime_type, 0, "download failed: network error"); + return Attachment::rejected( + "text_file", + file_name.to_string(), + mime_type, + 0, + "download failed: network error", + ); } }; if !resp.status().is_success() { let status = resp.status(); - warn!(file_id, status = status.as_u16(), "Telegram document HTTP error"); + warn!( + file_id, + status = status.as_u16(), + "Telegram document HTTP error" + ); return Attachment::rejected( "text_file", file_name.to_string(), @@ -864,13 +1012,20 @@ async fn download_telegram_document( if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { if size > FILE_MAX_DOWNLOAD { - warn!(file_id, size, "Telegram document Content-Length exceeds limit"); + warn!( + file_id, + size, "Telegram document Content-Length exceeds limit" + ); return Attachment::rejected( "text_file", file_name.to_string(), mime_type, size, - format!("size exceeded: {} exceeds {}", format_bytes(size), format_bytes(FILE_MAX_DOWNLOAD)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(size), + format_bytes(FILE_MAX_DOWNLOAD) + ), ); } } @@ -880,23 +1035,40 @@ async fn download_telegram_document( Ok(b) => b, Err(e) => { warn!(file_id, error = %e, "Telegram document body read failed"); - return Attachment::rejected("text_file", file_name.to_string(), mime_type, 0, "download failed: body read error"); + return Attachment::rejected( + "text_file", + file_name.to_string(), + mime_type, + 0, + "download failed: body read error", + ); } }; if bytes.len() as u64 > FILE_MAX_DOWNLOAD { - warn!(file_id, size = bytes.len(), "Telegram document exceeds limit"); + warn!( + file_id, + size = bytes.len(), + "Telegram document exceeds limit" + ); return Attachment::rejected( "text_file", file_name.to_string(), mime_type, bytes.len() as u64, - format!("size exceeded: {} exceeds {}", format_bytes(bytes.len() as u64), format_bytes(FILE_MAX_DOWNLOAD)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(bytes.len() as u64), + format_bytes(FILE_MAX_DOWNLOAD) + ), ); } // Validate UTF-8 — reject binary files if String::from_utf8(bytes.to_vec()).is_err() { - warn!(file_id, file_name, "Telegram document is not valid UTF-8, skipping"); + warn!( + file_id, + file_name, "Telegram document is not valid UTF-8, skipping" + ); return Attachment::rejected( "text_file", file_name.to_string(), @@ -910,10 +1082,21 @@ async fn download_telegram_document( Some(p) => p, None => { warn!(file_id, file_name, "Telegram document store failed"); - return Attachment::rejected("text_file", file_name.to_string(), mime_type, bytes.len() as u64, "processing failed: storage error"); + return Attachment::rejected( + "text_file", + file_name.to_string(), + mime_type, + bytes.len() as u64, + "processing failed: storage error", + ); } }; - info!(file_id, file_name, size = bytes.len(), "Telegram document stored"); + info!( + file_id, + file_name, + size = bytes.len(), + "Telegram document stored" + ); Attachment { attachment_type: "text_file".into(), @@ -941,17 +1124,29 @@ mod tests { "application/pdf", ) .await; - assert!(att.status.is_some(), "non-text extension must have status set"); + assert!( + att.status.is_some(), + "non-text extension must have status set" + ); let reason = att.status.unwrap(); assert!(reason.contains("unsupported format"), "got: {reason}"); - assert!(reason.contains("pdf"), "expected file extension in reason, got: {reason}"); + assert!( + reason.contains("pdf"), + "expected file extension in reason, got: {reason}" + ); } #[test] fn test_is_markdown_parse_error() { - assert!(is_markdown_parse_error("Bad Request: can't find end of italic entity at byte offset 37")); - assert!(is_markdown_parse_error("Bad Request: can't parse entities: Can't find end of bold entity")); - assert!(is_markdown_parse_error("can't parse entities in message text")); + assert!(is_markdown_parse_error( + "Bad Request: can't find end of italic entity at byte offset 37" + )); + assert!(is_markdown_parse_error( + "Bad Request: can't parse entities: Can't find end of bold entity" + )); + assert!(is_markdown_parse_error( + "can't parse entities in message text" + )); assert!(!is_markdown_parse_error("Unauthorized")); assert!(!is_markdown_parse_error("Bad Request: chat not found")); } @@ -982,8 +1177,12 @@ mod tests { fn test_is_complex_markdown() { // Tables assert!(is_complex_markdown("| Col1 | Col2 |\n|---|---|\n| a | b |")); - assert!(is_complex_markdown("| Col1 | Col2 |\n| :--- | ---: |\n| a | b |")); - assert!(is_complex_markdown("| A | B |\n| :---: | :---: |\n| x | y |")); + assert!(is_complex_markdown( + "| Col1 | Col2 |\n| :--- | ---: |\n| a | b |" + )); + assert!(is_complex_markdown( + "| A | B |\n| :---: | :---: |\n| x | y |" + )); // Code blocks — intentionally NOT complex (preserves syntax highlighting on legacy path) assert!(!is_complex_markdown("```rust\nfn main() {}\n```")); assert!(!is_complex_markdown("~~~\ncode\n~~~")); diff --git a/crates/openab-gateway/src/adapters/wecom.rs b/crates/openab-gateway/src/adapters/wecom.rs index 97aa84551..9bac4ab82 100644 --- a/crates/openab-gateway/src/adapters/wecom.rs +++ b/crates/openab-gateway/src/adapters/wecom.rs @@ -1,6 +1,6 @@ +use crate::media::format_bytes; use anyhow::Result; use axum::extract::State; -use crate::media::format_bytes; use std::sync::Arc; use tokio::sync::RwLock; use tracing::{info, warn}; @@ -47,7 +47,10 @@ impl WecomConfig { .unwrap_or(3); if encoding_aes_key.len() != 43 { - warn!("WECOM_ENCODING_AES_KEY must be 43 characters, got {}", encoding_aes_key.len()); + warn!( + "WECOM_ENCODING_AES_KEY must be 43 characters, got {}", + encoding_aes_key.len() + ); return None; } @@ -138,7 +141,10 @@ fn decrypt_message( .map_err(|e| anyhow::anyhow!("base64 decode failed: {e}"))?; if cipher_bytes.is_empty() || !cipher_bytes.len().is_multiple_of(16) { - anyhow::bail!("ciphertext length {} not a multiple of 16", cipher_bytes.len()); + anyhow::bail!( + "ciphertext length {} not a multiple of 16", + cipher_bytes.len() + ); } type Aes256CbcDec = cbc::Decryptor; @@ -153,12 +159,17 @@ fn decrypt_message( .map_err(|e| anyhow::anyhow!("aes decrypt failed: {e}"))?; // Strip WeCom PKCS7 padding (block_size=32): last byte indicates pad length (1-32) - let pad_byte = *plaintext.last().ok_or_else(|| anyhow::anyhow!("empty plaintext"))? as usize; + let pad_byte = *plaintext + .last() + .ok_or_else(|| anyhow::anyhow!("empty plaintext"))? as usize; if pad_byte == 0 || pad_byte > 32 || pad_byte > plaintext.len() { anyhow::bail!("invalid wecom padding value: {pad_byte}"); } let pad_start = plaintext.len() - pad_byte; - if !plaintext[pad_start..].iter().all(|&b| b as usize == pad_byte) { + if !plaintext[pad_start..] + .iter() + .all(|&b| b as usize == pad_byte) + { anyhow::bail!("invalid PKCS#7 padding: not all padding bytes match"); } let plaintext = &plaintext[..pad_start]; @@ -342,7 +353,6 @@ impl WecomAdapter { } } - pub async fn handle_reply( &self, reply: &crate::schema::GatewayReply, @@ -375,7 +385,10 @@ impl WecomAdapter { .unwrap_or(&reply.channel.id); let has_pending = { - let pending = self.pending_streams.lock().unwrap_or_else(|e| e.into_inner()); + let pending = self + .pending_streams + .lock() + .unwrap_or_else(|e| e.into_inner()); pending.contains_key(&reply.channel.id) }; let is_streaming_placeholder = reply.request_id.is_some() && !has_pending; @@ -398,10 +411,16 @@ impl WecomAdapter { let (text_tx, text_rx) = tokio::sync::watch::channel(String::new()); { - let mut pending = self.pending_streams.lock().unwrap_or_else(|e| e.into_inner()); - pending.insert(reply.channel.id.clone(), PendingStream { - text_watch: text_tx, - }); + let mut pending = self + .pending_streams + .lock() + .unwrap_or_else(|e| e.into_inner()); + pending.insert( + reply.channel.id.clone(), + PendingStream { + text_watch: text_tx, + }, + ); } let client = self.client.clone(); let token_cache = self.token_cache.clone(); @@ -453,9 +472,16 @@ impl WecomAdapter { return; } flush_thinking( - &client, &token_cache, &corp_id, &secret, &agent_id, - thinking_id.as_deref(), &flush_to_user, &last_text, - ).await; + &client, + &token_cache, + &corp_id, + &secret, + &agent_id, + thinking_id.as_deref(), + &flush_to_user, + &last_text, + ) + .await; }); if let Some(ref req_id) = reply.request_id { @@ -479,7 +505,10 @@ impl WecomAdapter { // between our earlier read of `has_pending` and now. If it did, // fall through to the direct-send path so the chunk isn't lost. let appended = { - let pending = self.pending_streams.lock().unwrap_or_else(|e| e.into_inner()); + let pending = self + .pending_streams + .lock() + .unwrap_or_else(|e| e.into_inner()); if let Some(stream) = pending.get(&reply.channel.id) { let current = stream.text_watch.borrow().clone(); let combined = if current.is_empty() { @@ -548,15 +577,21 @@ impl WecomAdapter { if text.is_empty() { return; } - let pending = self.pending_streams.lock().unwrap_or_else(|e| e.into_inner()); + let pending = self + .pending_streams + .lock() + .unwrap_or_else(|e| e.into_inner()); if let Some(stream) = pending.get(&reply.channel.id) { let _ = stream.text_watch.send(text.to_string()); } } - async fn send_text(&self, to_user: &str, text: &str) -> Result { - let agent_id: u64 = self.config.agent_id.parse().expect("agent_id validated at startup"); + let agent_id: u64 = self + .config + .agent_id + .parse() + .expect("agent_id validated at startup"); let body = serde_json::json!({ "touser": to_user, "msgtype": "text", @@ -590,16 +625,30 @@ async fn post_with_token_retry( body: &serde_json::Value, ) -> Result { let token = token_cache.get_token(client, corp_id, secret).await?; - let url = format!("{}{}?access_token={}", token_cache.base_url, api_path, token); + let url = format!( + "{}{}?access_token={}", + token_cache.base_url, api_path, token + ); let resp: serde_json::Value = client.post(&url).json(body).send().await?.json().await?; let errcode = resp["errcode"].as_i64().unwrap_or(-1); if errcode == 42001 { - warn!(api_path, "wecom: access_token expired, refreshing and retrying"); + warn!( + api_path, + "wecom: access_token expired, refreshing and retrying" + ); let new_token = token_cache.force_refresh(client, corp_id, secret).await?; - let retry_url = format!("{}{}?access_token={}", token_cache.base_url, api_path, new_token); - let retry_resp: serde_json::Value = - client.post(&retry_url).json(body).send().await?.json().await?; + let retry_url = format!( + "{}{}?access_token={}", + token_cache.base_url, api_path, new_token + ); + let retry_resp: serde_json::Value = client + .post(&retry_url) + .json(body) + .send() + .await? + .json() + .await?; let retry_code = retry_resp["errcode"].as_i64().unwrap_or(-1); if retry_code != 0 { anyhow::bail!( @@ -780,7 +829,11 @@ async fn flush_thinking( to_user: &str, text: &str, ) { - info!(?thinking_msg_id, text_len = text.len(), "wecom: flush_thinking starting"); + info!( + ?thinking_msg_id, + text_len = text.len(), + "wecom: flush_thinking starting" + ); // Recall thinking placeholder (only when streaming was enabled) if let Some(id) = thinking_msg_id { @@ -947,7 +1000,10 @@ pub async fn webhook( if let Ok(ts) = timestamp.parse::() { let now = chrono::Utc::now().timestamp(); if (now - ts).abs() > 300 { - warn!(timestamp_age_secs = now - ts, "wecom webhook: rejecting stale callback"); + warn!( + timestamp_age_secs = now - ts, + "wecom webhook: rejecting stale callback" + ); return axum::http::StatusCode::FORBIDDEN.into_response(); } } @@ -988,7 +1044,10 @@ pub async fn webhook( return axum::http::StatusCode::FORBIDDEN.into_response(); } - info!(encrypt_len = envelope.encrypt.len(), "wecom: decrypting callback"); + info!( + encrypt_len = envelope.encrypt.len(), + "wecom: decrypting callback" + ); let decrypted = match decrypt_message( &wecom.config.encoding_aes_key, &envelope.encrypt, @@ -999,7 +1058,10 @@ pub async fn webhook( d } Err(e) => { - warn!(encrypt_len = envelope.encrypt.len(), "wecom decrypt failed: {e}"); + warn!( + encrypt_len = envelope.encrypt.len(), + "wecom decrypt failed: {e}" + ); return "success".into_response(); } }; @@ -1076,7 +1138,12 @@ pub async fn webhook( ); event.content.attachments = attachments; - let att_sizes: Vec = event.content.attachments.iter().map(|a| a.data.len()).collect(); + let att_sizes: Vec = event + .content + .attachments + .iter() + .map(|a| a.data.len()) + .collect(); info!( attachments = event.content.attachments.len(), text_len = event.content.text.len(), @@ -1151,7 +1218,11 @@ async fn download_wecom_image( "wecom_image.jpg", "image/jpeg", size, - format!("size exceeded: {} exceeds {}", format_bytes(size), format_bytes(IMAGE_MAX_DOWNLOAD)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(size), + format_bytes(IMAGE_MAX_DOWNLOAD) + ), ); } } @@ -1176,7 +1247,11 @@ async fn download_wecom_image( "wecom_image.jpg", "image/jpeg", bytes.len() as u64, - format!("size exceeded: {} exceeds {}", format_bytes(bytes.len() as u64), format_bytes(IMAGE_MAX_DOWNLOAD)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(bytes.len() as u64), + format_bytes(IMAGE_MAX_DOWNLOAD) + ), ); } let (compressed, mime) = match resize_and_compress(&bytes) { @@ -1222,13 +1297,21 @@ const FILE_MAX_DOWNLOAD: u64 = 20 * 1024 * 1024; const TEXT_EXTENSIONS: &[&str] = &[ "txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml", "rs", "py", "js", "ts", "jsx", "tsx", "go", "java", "c", "cpp", "h", "hpp", "rb", "sh", "bash", "zsh", "fish", - "ps1", "bat", "sql", "html", "css", "scss", "less", "ini", "cfg", "conf", "env", - "swift", "kt", "scala", "r", "pl", "lua", "graphql", "tsv", + "ps1", "bat", "sql", "html", "css", "scss", "less", "ini", "cfg", "conf", "env", "swift", "kt", + "scala", "r", "pl", "lua", "graphql", "tsv", ]; const TEXT_FILENAMES: &[&str] = &[ - "dockerfile", "makefile", "justfile", "rakefile", "gemfile", - "procfile", "vagrantfile", ".gitignore", ".dockerignore", ".editorconfig", + "dockerfile", + "makefile", + "justfile", + "rakefile", + "gemfile", + "procfile", + "vagrantfile", + ".gitignore", + ".dockerignore", + ".editorconfig", ]; fn is_text_file(filename: &str) -> bool { @@ -1326,7 +1409,11 @@ async fn download_wecom_file( filename.to_string(), "application/octet-stream", size, - format!("size exceeded: {} exceeds {}", format_bytes(size), format_bytes(FILE_MAX_DOWNLOAD)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(size), + format_bytes(FILE_MAX_DOWNLOAD) + ), ); } } @@ -1351,7 +1438,11 @@ async fn download_wecom_file( filename.to_string(), "application/octet-stream", bytes.len() as u64, - format!("size exceeded: {} exceeds {}", format_bytes(bytes.len() as u64), format_bytes(FILE_MAX_DOWNLOAD)), + format!( + "size exceeded: {} exceeds {}", + format_bytes(bytes.len() as u64), + format_bytes(FILE_MAX_DOWNLOAD) + ), ); } @@ -1451,7 +1542,10 @@ mod tests { ("WECOM_CORP_ID", "ww_test_corp"), ("WECOM_SECRET", "test_secret"), ("WECOM_TOKEN", "test_token"), - ("WECOM_ENCODING_AES_KEY", "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"), + ( + "WECOM_ENCODING_AES_KEY", + "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", + ), ("WECOM_AGENT_ID", "1000002"), ]); let config = WecomConfig::from_reader(env).unwrap(); @@ -1613,11 +1707,17 @@ mod tests { let cache = WecomTokenCache::with_base_url(server.uri()); let client = reqwest::Client::new(); - let token = cache.get_token(&client, "ww_test_corp", "test_secret").await.unwrap(); + let token = cache + .get_token(&client, "ww_test_corp", "test_secret") + .await + .unwrap(); assert_eq!(token, "test_token_abc"); // Second call uses cache (mock expects exactly 1 call) - let token2 = cache.get_token(&client, "ww_test_corp", "test_secret").await.unwrap(); + let token2 = cache + .get_token(&client, "ww_test_corp", "test_secret") + .await + .unwrap(); assert_eq!(token2, "test_token_abc"); } diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index 587cc15fe..523207740 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -97,7 +97,6 @@ pub struct AppState { pub client: reqwest::Client, } - impl AppState { /// Create a minimal AppState for testing. Only requires an `event_tx` sender; /// all adapter fields default to `None`/empty. This decouples adapter tests @@ -141,9 +140,11 @@ impl AppState { event_tx, reply_token_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), line_webhook_semaphore: Arc::new(Semaphore::new(LINE_WEBHOOK_CONCURRENCY_MAX)), - lineworks_webhook_semaphore: Arc::new(Semaphore::new(LINEWORKS_WEBHOOK_CONCURRENCY_MAX)), - lineworks_ingress_queue: Arc::new(Semaphore::new(LINEWORKS_INGRESS_QUEUE_MAX)), - trust_probe: None, + lineworks_webhook_semaphore: Arc::new(Semaphore::new( + LINEWORKS_WEBHOOK_CONCURRENCY_MAX, + )), + lineworks_ingress_queue: Arc::new(Semaphore::new(LINEWORKS_INGRESS_QUEUE_MAX)), + trust_probe: None, client: reqwest::Client::new(), } } @@ -184,8 +185,8 @@ impl AppState { // Feishu #[cfg(feature = "feishu")] - let feishu = adapters::feishu::FeishuConfig::from_env() - .map(adapters::feishu::FeishuAdapter::new); + let feishu = + adapters::feishu::FeishuConfig::from_env().map(adapters::feishu::FeishuAdapter::new); // Google Chat #[cfg(feature = "googlechat")] @@ -206,16 +207,20 @@ impl AppState { // WeCom #[cfg(feature = "wecom")] - let wecom = adapters::wecom::WecomConfig::from_env() - .map(adapters::wecom::WecomAdapter::new); + let wecom = + adapters::wecom::WecomConfig::from_env().map(adapters::wecom::WecomAdapter::new); // ACP Server #[cfg(feature = "acp")] let acp = adapters::acp_server::AcpConfig::from_env(); #[cfg(feature = "acp")] - let acp_reply_registry = acp.as_ref().map(|_| adapters::acp_server::new_reply_registry()); + let acp_reply_registry = acp + .as_ref() + .map(|_| adapters::acp_server::new_reply_registry()); #[cfg(feature = "acp")] - let acp_tunnel_registry = acp.as_ref().map(|_| adapters::acp_server::new_tunnel_registry()); + let acp_tunnel_registry = acp + .as_ref() + .map(|_| adapters::acp_server::new_tunnel_registry()); // LINE WORKS #[cfg(feature = "lineworks")] let lineworks = adapters::lineworks::LineWorksConfig::from_env().map(|config| { @@ -260,9 +265,11 @@ impl AppState { event_tx, reply_token_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), line_webhook_semaphore: Arc::new(Semaphore::new(LINE_WEBHOOK_CONCURRENCY_MAX)), - lineworks_webhook_semaphore: Arc::new(Semaphore::new(LINEWORKS_WEBHOOK_CONCURRENCY_MAX)), - lineworks_ingress_queue: Arc::new(Semaphore::new(LINEWORKS_INGRESS_QUEUE_MAX)), - trust_probe: None, + lineworks_webhook_semaphore: Arc::new(Semaphore::new( + LINEWORKS_WEBHOOK_CONCURRENCY_MAX, + )), + lineworks_ingress_queue: Arc::new(Semaphore::new(LINEWORKS_INGRESS_QUEUE_MAX)), + trust_probe: None, client, } } @@ -422,7 +429,12 @@ impl AppState { /// no adapter, matching env-only semantics. #[cfg(feature = "wecom")] pub fn apply_wecom_config(&mut self, cfg: GatewayWecomConfig) { - let streaming = if cfg.streaming_enabled { "true" } else { "false" }.to_string(); + let streaming = if cfg.streaming_enabled { + "true" + } else { + "false" + } + .to_string(); let debounce = cfg.debounce_secs.to_string(); self.wecom = adapters::wecom::WecomConfig::from_reader(|k| match k { "WECOM_CORP_ID" => cfg.corp_id.clone(), @@ -601,10 +613,16 @@ impl Default for ServeConfig { /// Start the standalone gateway server. This is the main entry point extracted /// from the gateway binary — the binary becomes a thin wrapper around this. pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { - use axum::{routing::{get, post}, Router}; + use axum::{ + routing::{get, post}, + Router, + }; use tracing::{info, warn}; - let ServeConfig { listen_addr, ws_token } = config; + let ServeConfig { + listen_addr, + ws_token, + } = config; if ws_token.is_none() { warn!("GATEWAY_WS_TOKEN not set — WebSocket connections are NOT authenticated (insecure)"); @@ -620,7 +638,10 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { // ACP Server adapter. Fail-open (no transport key) is only allowed on a loopback // bind; a non-loopback bind without OPENAB_ACP_AUTH_KEY refuses to mount /acp. #[cfg(feature = "acp")] - if std::env::var("OPENAB_ACP_ENABLED").map(|v| v == "true" || v == "1").unwrap_or(false) { + if std::env::var("OPENAB_ACP_ENABLED") + .map(|v| v == "true" || v == "1") + .unwrap_or(false) + { let acp_key = std::env::var("OPENAB_ACP_AUTH_KEY").ok(); match adapters::acp_server::acp_auth_ok_for_bind(acp_key.as_deref(), &listen_addr) { Ok(()) => { @@ -732,7 +753,11 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { let api_base = f.config.api_base(); let idle_ms = f.config.card_idle_finalize_ms; tokio::spawn(adapters::feishu::run_idle_reaper( - sessions, token_cache, client, api_base, idle_ms, + sessions, + token_cache, + client, + api_base, + idle_ms, )); info!(idle_ms, "feishu card-streaming idle reaper started"); } @@ -740,8 +765,8 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { // Google Chat adapter #[cfg(feature = "googlechat")] - let googlechat_webhook_path = std::env::var("GOOGLE_CHAT_WEBHOOK_PATH") - .unwrap_or_else(|_| "/webhook/googlechat".into()); + let googlechat_webhook_path = + std::env::var("GOOGLE_CHAT_WEBHOOK_PATH").unwrap_or_else(|_| "/webhook/googlechat".into()); #[cfg(feature = "googlechat")] let google_chat = { let enabled = std::env::var("GOOGLE_CHAT_ENABLED") @@ -749,7 +774,10 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { .unwrap_or(false); if enabled { info!(path = %googlechat_webhook_path, "googlechat adapter enabled"); - app = app.route(&googlechat_webhook_path, post(adapters::googlechat::webhook)); + app = app.route( + &googlechat_webhook_path, + post(adapters::googlechat::webhook), + ); Some(adapters::googlechat::GoogleChatAdapter::from_parts( adapters::googlechat::GoogleChatParts::from_env(), )) @@ -879,7 +907,11 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { cache.retain(|_, (_, t)| t.elapsed().as_secs() < REPLY_TOKEN_TTL_SECS); let after = cache.len(); if before != after { - info!(removed = before - after, remaining = after, "reply token cache sweep"); + info!( + removed = before - after, + remaining = after, + "reply token cache sweep" + ); } } }); @@ -896,7 +928,11 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { urls.retain(|_, (_, t)| t.elapsed().as_secs() < 4 * 3600); let after = urls.len(); if before != after { - info!(removed = before - after, remaining = after, "teams service_url cache cleanup"); + info!( + removed = before - after, + remaining = after, + "teams service_url cache cleanup" + ); } } }); @@ -1097,9 +1133,7 @@ async fn handle_oab_connection(state: Arc, socket: axum::extract::ws:: "lineworks" => { if let Some(ref lineworks) = state_for_recv.lineworks { let ok = adapters::lineworks::dispatch_lineworks_reply( - &client, - lineworks, - &reply, + &client, lineworks, &reply, ) .await; if !ok { diff --git a/crates/openab-gateway/src/store.rs b/crates/openab-gateway/src/store.rs index b08e69903..8927a8c5a 100644 --- a/crates/openab-gateway/src/store.rs +++ b/crates/openab-gateway/src/store.rs @@ -54,7 +54,11 @@ const MAX_STORE_SIZE: usize = 20 * 1024 * 1024; // 20 MB (matches AUDIO_MAX_DOWN /// Rejects files exceeding MAX_STORE_SIZE as a defense-in-depth measure. pub async fn store_media(bytes: &[u8]) -> Option { if bytes.len() > MAX_STORE_SIZE { - error!(size = bytes.len(), max = MAX_STORE_SIZE, "store_media rejected: exceeds size limit"); + error!( + size = bytes.len(), + max = MAX_STORE_SIZE, + "store_media rejected: exceeds size limit" + ); return None; } let dir = media_dir().await; diff --git a/crates/openab-mcp/src/auth.rs b/crates/openab-mcp/src/auth.rs index 7a8f2fd38..3c21b09c6 100644 --- a/crates/openab-mcp/src/auth.rs +++ b/crates/openab-mcp/src/auth.rs @@ -2355,14 +2355,14 @@ mod tests { let map = read_auth_file(&path).unwrap(); assert!( - map.get("mcp-pending:stale").is_none(), + !map.contains_key("mcp-pending:stale"), "stale pending swept" ); assert!( matches!(map.get("mcp-pending:fresh"), Some(AuthEntry::Pending(_))), "fresh pending kept" ); - assert!(map.get("codex").is_some(), "real tenant untouched"); + assert!(map.contains_key("codex"), "real tenant untouched"); } #[cfg(unix)] diff --git a/crates/openab-mcp/src/mcp/runtime.rs b/crates/openab-mcp/src/mcp/runtime.rs index 29f91b29f..d89ef2fdd 100644 --- a/crates/openab-mcp/src/mcp/runtime.rs +++ b/crates/openab-mcp/src/mcp/runtime.rs @@ -2927,7 +2927,10 @@ mod tests { custom_headers, ) .await; - assert!(result.is_err(), "redirect response must not complete MCP POST"); + assert!( + result.is_err(), + "redirect response must not complete MCP POST" + ); assert_eq!( *original_headers.lock().await, ( @@ -3069,7 +3072,10 @@ mod tests { assert!(err.contains(header_name), "got: {err}"); assert!(!err.contains("secret-must-not-leak"), "value leaked: {err}"); assert!(!err.contains("circuit-breaker open"), "got: {err}"); - assert!(matches!(&mgr.statuses().await[0].1, ServerStatus::Failed(_))); + assert!(matches!( + &mgr.statuses().await[0].1, + ServerStatus::Failed(_) + )); } } } @@ -3096,7 +3102,10 @@ mod tests { !err.contains("custom-secret-must-not-leak"), "header value leaked: {err}" ); - assert!(matches!(&mgr.statuses().await[0].1, ServerStatus::Failed(_))); + assert!(matches!( + &mgr.statuses().await[0].1, + ServerStatus::Failed(_) + )); } #[tokio::test] @@ -3308,6 +3317,11 @@ mod tests { assert!(err.contains("ghost"), "got: {err}"); } + // The env lock is held across the await on purpose: it serializes this + // test's process-wide `remove_var` against every other test that mutates + // the same variables. Dropping it early would reintroduce the race it + // exists to prevent. + #[allow(clippy::await_holding_lock)] #[tokio::test] async fn start_paste_login_builtin_without_env_var_errors_loud() { let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/crates/openab-mcp/src/mcp/sources.rs b/crates/openab-mcp/src/mcp/sources.rs index c2f0e729e..22c160d2a 100644 --- a/crates/openab-mcp/src/mcp/sources.rs +++ b/crates/openab-mcp/src/mcp/sources.rs @@ -47,6 +47,10 @@ pub struct SessionCtx { /// operator's grant: sources are code-wired by the broker, so unlike /// `mcp.json` servers there is no per-source `tool_filter` — do not register /// a source whose full tool set you don't intend to expose. +// `#[async_trait]` stamps a bare `#[must_use]` on every method it expands to, +// which `clippy::double_must_use` flags even though the attribute is generated +// and cannot carry a message. +#[allow(clippy::double_must_use)] #[async_trait::async_trait] pub trait CapabilitySource: Send + Sync { /// Provider label surfaced in discovery entries and audit lines. diff --git a/crates/openab-mcp/src/native/gmail.rs b/crates/openab-mcp/src/native/gmail.rs index 0549b0935..26288b5a5 100644 --- a/crates/openab-mcp/src/native/gmail.rs +++ b/crates/openab-mcp/src/native/gmail.rs @@ -121,12 +121,6 @@ impl GmailNative { } } - #[cfg(test)] - fn with_api_base(mut self, base: &str) -> Self { - self.api_base = base.to_string(); - self - } - /// Valid bearer token: cached when fresh, refreshed via the stored /// refresh token when near expiry (60s buffer, same policy as the MCP /// runtime), otherwise an actionable re-login error. diff --git a/docs/adr/agent-control-plane.md b/docs/adr/agent-control-plane.md index f5a5195d0..e46bd0265 100644 --- a/docs/adr/agent-control-plane.md +++ b/docs/adr/agent-control-plane.md @@ -525,6 +525,19 @@ recovery semantics: deregistration is keyed by handle and capacity is released only for in-flight entries actually removed, so the guard and the sweeper can both run over the same handle without double-releasing. +- **The sweeper is supervised, and its liveness is observable.** Lease expiry + and deadline sweeping live in one background task. A silent death of that task + (panic, unexpected return, or a stall inside a pass) is the same class of bug + as an unreaped connection task: a maintenance function quietly becomes + unbounded state growth — leases never expire, deadlines never fire — while + every other CP surface keeps answering normally. So the task is supervised + (restart with a doubling backoff that resets only after a run which both + lasted a full stall window and kept sweeping through it) and its liveness is + exposed: the sweeper records a pass per tick, + the supervisor records deaths, and `/health` answers `503 sweeper degraded` + from a death until the replacement's first pass. A CP that cannot prove it is + still sweeping must not claim to be healthy, because nothing else it answers + depends on the sweeper surviving. - **Capacity release follows entry removal.** Whichever path removes an in-flight entry (result commit, cancel, deadline sweep, instance failure, or a failed forward's rollback) releases its capacity reservation — and diff --git a/docs/control-plane.md b/docs/control-plane.md index e29564dc9..eed926eb9 100644 --- a/docs/control-plane.md +++ b/docs/control-plane.md @@ -49,8 +49,38 @@ rationale. The essentials: ## Health -`GET /health` answers `ok` (liveness only; deeper checks are tracked in -issue #1474). +`GET /health` answers `ok` while the background sweeper is alive, and +`503 sweeper degraded` from the moment the sweeper dies until its replacement +completes a sweep pass. + +The sweeper is what expires leases and fires delegation deadlines, so a dead +one is not cosmetic: leases of disconnected agents would never expire and +overdue delegations would never resolve, while everything else the CP answers +stays green. It is therefore supervised rather than fire-and-forget: + +- a **panic**, an **unexpected return**, or a **stall** (no completed pass + within 30 s — the only signal available for a task wedged mid-pass, since a + wedged task never returns) all count as death; +- each death is recorded before the restart, so `/health` is degraded for the + whole window in which sweeping is stopped, and the replacement's first pass + clears it — a replacement never comes up while the CP still claims to be + healthy; +- restarts wait out a backoff — 500 ms for the first restart of an incident, + doubling to a 30 s ceiling. The budget resets only for a sweeper that lasted a + full stall window *and* kept completing passes through it, so a crash loop + costs a bounded number of attempts instead of a hot loop, while a sweeper + that failed once after hours of healthy service still recovers immediately. + +A `503` therefore means "this CP is serving but not sweeping". The supervisor +usually recovers on its own, so the useful response is an alert, not a +restart-on-every-probe. + +> **Behavior change.** `/health` used to answer `200 ok` unconditionally. If a +> deployment wires it to a liveness probe, that probe will now see `503` while +> the sweeper is being restarted — prefer it as a readiness signal, or alert on +> it without restarting the pod, since the supervisor recovers on its own. No +> probe in this repository targets the CP (the Helm/Docker probes are for +> `openab-gateway`, a different server on a different port). ## Client behavior to expect diff --git a/src/acp_tunnel_source.rs b/src/acp_tunnel_source.rs index 25440f6bb..efd0bb4d0 100644 --- a/src/acp_tunnel_source.rs +++ b/src/acp_tunnel_source.rs @@ -484,8 +484,8 @@ impl openab_core::acp_mcp::SessionTokenRegistrar for FacadeRegistrar { mod tests { use super::{AcpTunnelSource, CapabilitySource, SessionCtx, Tool}; use openab_core::acp_mcp::AcpMcpTunnel; - use std::collections::HashSet; use serde_json::{json, Map, Value}; + use std::collections::HashSet; use std::sync::Arc; /// Tunnel double: reports declared servers, records forwarded `tools/call`s, @@ -518,17 +518,16 @@ mod tests { /// Make `tools/list` answer with these tool names. fn set_tools_list(&self, names: &[&str]) { - *self.tools_list.lock().unwrap() = - Some(names.iter().map(|n| n.to_string()).collect()); + *self.tools_list.lock().unwrap() = Some(names.iter().map(|n| n.to_string()).collect()); } /// Make a specific server's `tools/list` return exactly `names`, verbatim — the tool names /// need not be prefixed with the server's name. fn set_server_tools(&self, server_name: &str, names: &[&str]) { - self.server_tools - .lock() - .unwrap() - .insert(server_name.to_string(), names.iter().map(|n| n.to_string()).collect()); + self.server_tools.lock().unwrap().insert( + server_name.to_string(), + names.iter().map(|n| n.to_string()).collect(), + ); } /// Make `tools/list` fail (no answer configured). @@ -615,9 +614,9 @@ mod tests { shared .into_iter() .filter(|n| match &server_name { - Some(name) => n - .split_once('.') - .is_some_and(|(prefix, _)| prefix == name), + Some(name) => { + n.split_once('.').is_some_and(|(prefix, _)| prefix == name) + } None => true, }) .collect() @@ -717,7 +716,10 @@ mod tests { .call(Some(&ctx()), "notes.anything", &Map::new()) .await .unwrap(); - assert!(!is_err, "no allowlist gate: a connected server's declared tool dispatches"); + assert!( + !is_err, + "no allowlist gate: a connected server's declared tool dispatches" + ); let fwd = tunnel.forwarded.lock().unwrap(); assert_eq!(fwd[0].1, "uuid-n"); assert_eq!(fwd[0].2["name"], "notes.anything"); @@ -819,18 +821,36 @@ mod tests { // `attached_server_names`, but a name already in the cache keeps its tools even while it is // momentarily not attached — the UNION in `tools()` is what preserves this. let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); - tunnel.set_tools_list(&["katashiro.click", "katashiro.navigate", "katashiro.read_dom", "katashiro.screenshot", "katashiro.type"]); + tunnel.set_tools_list(&[ + "katashiro.click", + "katashiro.navigate", + "katashiro.read_dom", + "katashiro.screenshot", + "katashiro.type", + ]); let src = AcpTunnelSource::new(tunnel.clone()); let _ = src.tools(Some(&ctx())); settle().await; - let before: Vec = - src.tools(Some(&ctx())).iter().map(|t| t.name.to_string()).collect(); - assert!(!before.is_empty(), "precondition: discovery must have populated the catalog"); + let before: Vec = src + .tools(Some(&ctx())) + .iter() + .map(|t| t.name.to_string()) + .collect(); + assert!( + !before.is_empty(), + "precondition: discovery must have populated the catalog" + ); tunnel.detach("katashiro"); - let after: Vec = - src.tools(Some(&ctx())).iter().map(|t| t.name.to_string()).collect(); - assert_eq!(after, before, "a detached tunnel must not shrink the catalog"); + let after: Vec = src + .tools(Some(&ctx())) + .iter() + .map(|t| t.name.to_string()) + .collect(); + assert_eq!( + after, before, + "a detached tunnel must not shrink the catalog" + ); } #[tokio::test] @@ -838,13 +858,22 @@ mod tests { // Once a catalog has been discovered, a LATER failed fetch must not empty it — the // invariant that protects a running deployment through a flap. let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); - tunnel.set_tools_list(&["katashiro.click", "katashiro.navigate", "katashiro.read_dom", "katashiro.screenshot", "katashiro.type"]); + tunnel.set_tools_list(&[ + "katashiro.click", + "katashiro.navigate", + "katashiro.read_dom", + "katashiro.screenshot", + "katashiro.type", + ]); let src = AcpTunnelSource::new(tunnel.clone()); let _ = src.tools(Some(&ctx())); settle().await; let discovered = src.tools(Some(&ctx())).len(); - assert!(discovered > 0, "precondition: discovery must have populated the catalog"); + assert!( + discovered > 0, + "precondition: discovery must have populated the catalog" + ); tunnel.fail_tools_list(); let _ = src.tools(Some(&ctx())); @@ -967,10 +996,16 @@ mod tests { .call(Some(&ctx()), "notes.click", &Map::new()) .await .unwrap(); - assert!(!err, "prefix selects the tunnel; the server decides what it implements"); + assert!( + !err, + "prefix selects the tunnel; the server decides what it implements" + ); let fwd = tunnel.forwarded.lock().unwrap(); assert_eq!(fwd.len(), 1); - assert_eq!(fwd[0].1, "uuid-n", "routed to notes' tunnel, not katashiro's"); + assert_eq!( + fwd[0].1, "uuid-n", + "routed to notes' tunnel, not katashiro's" + ); assert_eq!(fwd[0].2["name"], "notes.click"); } @@ -1006,15 +1041,27 @@ mod tests { let _ = src.tools(Some(&ctx())); settle().await; - let names: Vec = - src.tools(Some(&ctx())).iter().map(|t| t.name.to_string()).collect(); + let names: Vec = src + .tools(Some(&ctx())) + .iter() + .map(|t| t.name.to_string()) + .collect(); assert_eq!(names, ["build"], "the bare tool is advertised"); let (_v, is_err) = src.call(Some(&ctx()), "build", &Map::new()).await.unwrap(); - assert!(!is_err, "a bare tool must dispatch, not be rejected as malformed"); + assert!( + !is_err, + "a bare tool must dispatch, not be rejected as malformed" + ); let fwd = tunnel.forwarded.lock().unwrap(); - assert_eq!(fwd[0].1, "uuid-p", "routed to the publishing server's tunnel"); - assert_eq!(fwd[0].2["name"], "build", "the original tool name is forwarded unchanged"); + assert_eq!( + fwd[0].1, "uuid-p", + "routed to the publishing server's tunnel" + ); + assert_eq!( + fwd[0].2["name"], "build", + "the original tool name is forwarded unchanged" + ); } #[tokio::test] @@ -1031,7 +1078,10 @@ mod tests { let _ = src.tools(Some(&ctx())); settle().await; - let (_v, is_err) = src.call(Some(&ctx()), "katashiro.click", &Map::new()).await.unwrap(); + let (_v, is_err) = src + .call(Some(&ctx()), "katashiro.click", &Map::new()) + .await + .unwrap(); assert!(!is_err); let fwd = tunnel.forwarded.lock().unwrap(); assert_eq!( @@ -1087,7 +1137,10 @@ mod tests { for tool in ["screenshot", "beta.screenshot"] { let (_v, is_err) = src.call(Some(&ctx()), tool, &Map::new()).await.unwrap(); - assert!(!is_err, "{tool} must dispatch — being advertised is the promise"); + assert!( + !is_err, + "{tool} must dispatch — being advertised is the promise" + ); } let fwd = tunnel.forwarded.lock().unwrap(); let routed: Vec<(String, String)> = fwd @@ -1256,10 +1309,22 @@ mod tests { let _ = src.tools(Some(&ctx())); settle().await; - let (_v, is_err) = src.call(Some(&ctx()), "browser.click", &Map::new()).await.unwrap(); - assert!(!is_err, "a mismatched-prefix tool must route to its publisher, not a phantom server"); + let (_v, is_err) = src + .call(Some(&ctx()), "browser.click", &Map::new()) + .await + .unwrap(); + assert!( + !is_err, + "a mismatched-prefix tool must route to its publisher, not a phantom server" + ); let fwd = tunnel.forwarded.lock().unwrap(); - assert_eq!(fwd[0].1, "uuid-k", "routed to katashiro, not the 'browser' prefix"); - assert_eq!(fwd[0].2["name"], "browser.click", "the original tool name is forwarded unchanged"); + assert_eq!( + fwd[0].1, "uuid-k", + "routed to katashiro, not the 'browser' prefix" + ); + assert_eq!( + fwd[0].2["name"], "browser.click", + "the original tool name is forwarded unchanged" + ); } } diff --git a/src/ctl.rs b/src/ctl.rs index e40fb7fc9..b767d978d 100644 --- a/src/ctl.rs +++ b/src/ctl.rs @@ -58,6 +58,10 @@ pub struct Response { /// Handler trait — `openab run` provides the concrete implementation that /// can access Discord/Slack adapters. +// `#[async_trait]` stamps a bare `#[must_use]` on every method it expands to, +// which `clippy::double_must_use` flags even though the attribute is generated +// and cannot carry a message. +#[allow(clippy::double_must_use)] #[cfg(unix)] #[async_trait::async_trait] pub trait CtlHandler: Send + Sync + 'static { @@ -68,9 +72,7 @@ pub trait CtlHandler: Send + Sync + 'static { /// Start the control socket server. Call this from `openab run` startup. /// Returns a JoinHandle; abort it on shutdown. #[cfg(unix)] -pub fn spawn_server( - handler: std::sync::Arc, -) -> tokio::task::JoinHandle<()> { +pub fn spawn_server(handler: std::sync::Arc) -> tokio::task::JoinHandle<()> { spawn_server_at(socket_path(), handler) } @@ -116,10 +118,7 @@ pub fn spawn_server_at( } #[cfg(unix)] -async fn handle_conn( - stream: UnixStream, - handler: &dyn CtlHandler, -) -> anyhow::Result<()> { +async fn handle_conn(stream: UnixStream, handler: &dyn CtlHandler) -> anyhow::Result<()> { let (reader, mut writer) = stream.into_split(); let mut lines = BufReader::new(reader).lines(); if let Some(line) = lines.next_line().await? { @@ -127,7 +126,9 @@ async fn handle_conn( let resp = match req.action { Action::Set => { let val = req.value.as_deref().unwrap_or(""); - handler.handle_set(req.thread_id.as_deref(), &req.key, val).await + handler + .handle_set(req.thread_id.as_deref(), &req.key, val) + .await } Action::Get => handler.handle_get(req.thread_id.as_deref(), &req.key).await, }; @@ -155,7 +156,10 @@ pub fn new_registry() -> ThreadRegistry { #[cfg(unix)] #[allow(dead_code)] pub async fn register_thread(registry: &ThreadRegistry, thread_id: &str, platform: &str) { - registry.write().await.insert(thread_id.to_string(), platform.to_string()); + registry + .write() + .await + .insert(thread_id.to_string(), platform.to_string()); } /// Type-alias for the Discord shard slot. When the discord feature is disabled, @@ -182,7 +186,11 @@ impl RuntimeHandler { registry: Arc>>, shard: ShardSlot, ) -> Self { - Self { adapters, registry, shard } + Self { + adapters, + registry, + shard, + } } /// Resolve which adapter to use for a given thread_id. @@ -234,7 +242,8 @@ impl CtlHandler for RuntimeHandler { let Some((adapter, tid)) = self.resolve(thread_id).await else { return Response { ok: false, - message: "unknown thread (use --thread or register via message dispatch)".into(), + message: "unknown thread (use --thread or register via message dispatch)" + .into(), value: None, }; }; @@ -262,7 +271,8 @@ impl CtlHandler for RuntimeHandler { let Some((_adapter, tid)) = self.resolve(thread_id).await else { return Response { ok: false, - message: "unknown thread (use --thread or register via message dispatch)".into(), + message: "unknown thread (use --thread or register via message dispatch)" + .into(), value: None, }; }; @@ -477,7 +487,12 @@ mod tests { struct MockHandler; #[async_trait::async_trait] impl CtlHandler for MockHandler { - async fn handle_set(&self, thread_id: Option<&str>, key: &str, value: &str) -> Response { + async fn handle_set( + &self, + thread_id: Option<&str>, + key: &str, + value: &str, + ) -> Response { Response { ok: true, message: format!("{key} = {value} (thread: {})", thread_id.unwrap_or("none")), @@ -503,24 +518,30 @@ mod tests { tokio::time::sleep(std::time::Duration::from_millis(50)).await; // Test set - let resp = send_request_to(&sock, &Request { - action: Action::Set, - key: "thread.name".into(), - value: Some("hello world".into()), - thread_id: Some("999".into()), - }) + let resp = send_request_to( + &sock, + &Request { + action: Action::Set, + key: "thread.name".into(), + value: Some("hello world".into()), + thread_id: Some("999".into()), + }, + ) .await .unwrap(); assert!(resp.ok); assert_eq!(resp.message, "thread.name = hello world (thread: 999)"); // Test get - let resp = send_request_to(&sock, &Request { - action: Action::Get, - key: "thread.name".into(), - value: None, - thread_id: None, - }) + let resp = send_request_to( + &sock, + &Request { + action: Action::Get, + key: "thread.name".into(), + value: None, + thread_id: None, + }, + ) .await .unwrap(); assert!(resp.ok); diff --git a/src/main.rs b/src/main.rs index f49879778..c3aa77326 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,7 @@ +#[cfg(feature = "acp")] +mod acp_tunnel; +#[cfg(feature = "acp")] +mod acp_tunnel_source; mod ctl; #[cfg(any( feature = "telegram", @@ -10,10 +14,6 @@ mod ctl; feature = "lineworks", ))] mod unified_adapter; -#[cfg(feature = "acp")] -mod acp_tunnel; -#[cfg(feature = "acp")] -mod acp_tunnel_source; use openab_core::acp; use openab_core::adapter::{self, AdapterRouter}; use openab_core::bot_turns; @@ -495,8 +495,8 @@ async fn main() -> anyhow::Result<()> { #[cfg(feature = "acp")] let acp_tunnel_registry = openab_gateway::adapters::acp_server::new_tunnel_registry(); #[cfg(feature = "acp")] - let acp_tunnel: Arc = Arc::new( - acp_tunnel::RootAcpTunnel::new( + let acp_tunnel: Arc = + Arc::new(acp_tunnel::RootAcpTunnel::new( acp_tunnel_registry.clone(), // Browser control requires `[mcp]`, so the absent case is unreachable in practice; // fall back through the SAME function serde uses rather than repeating the literal. @@ -511,8 +511,7 @@ async fn main() -> anyhow::Result<()> { openab_gateway::adapters::acp_server::warn_if_tunnel_timeout_is_ineffective(t); t }, - ), - ); + )); // OAB MCP Facade (`[mcp]` in config.toml — OAB MCP Adapter ADR §6.2): // serve the loopback Streamable HTTP MCP server in-process so any coding @@ -543,15 +542,13 @@ async fn main() -> anyhow::Result<()> { // be skipped in bridge mode; with the bridge gone there is no mode in which the facade // runs without it. #[cfg(feature = "acp")] - let sources: Vec> = - vec![Arc::new(acp_tunnel_source::AcpTunnelSource::new( - acp_tunnel.clone(), - ))]; + let sources: Vec> = vec![Arc::new( + acp_tunnel_source::AcpTunnelSource::new(acp_tunnel.clone()), + )]; #[cfg(not(feature = "acp"))] let sources: Vec> = Vec::new(); tokio::spawn(async move { - if let Err(e) = - openab_mcp::mcp::facade::serve_http_with(&listen, sources, tokens).await + if let Err(e) = openab_mcp::mcp::facade::serve_http_with(&listen, sources, tokens).await { tracing::error!(error = %format!("{e:#}"), listen, "OAB MCP facade exited"); std::process::exit(1); @@ -579,7 +576,10 @@ async fn main() -> anyhow::Result<()> { facade_serving.then(|| { format!( "http://{}/mcp", - cfg.mcp.as_ref().map(|m| m.listen.as_str()).unwrap_or("127.0.0.1:8848") + cfg.mcp + .as_ref() + .map(|m| m.listen.as_str()) + .unwrap_or("127.0.0.1:8848") ) }), ); @@ -915,22 +915,21 @@ async fn main() -> anyhow::Result<()> { // Initialize filestore (for uploading file attachments to S3/R2). #[cfg(feature = "filestore")] - let filestore: Option> = if let Some(ref fs_cfg) = - cfg.filestore - { - info!( - bucket = %fs_cfg.bucket, - region = %fs_cfg.region, - prefix = %fs_cfg.prefix, - presigned_ttl = fs_cfg.presigned_ttl, - "filestore enabled" - ); - Some(Arc::new( - openab_core::filestore::Filestore::new(fs_cfg).await, - )) - } else { - None - }; + let filestore: Option> = + if let Some(ref fs_cfg) = cfg.filestore { + info!( + bucket = %fs_cfg.bucket, + region = %fs_cfg.region, + prefix = %fs_cfg.prefix, + presigned_ttl = fs_cfg.presigned_ttl, + "filestore enabled" + ); + Some(Arc::new( + openab_core::filestore::Filestore::new(fs_cfg).await, + )) + } else { + None + }; #[cfg(feature = "slack")] let shared_slack_adapter: Option> = cfg.slack.as_ref().map(|s| { @@ -990,13 +989,7 @@ async fn main() -> anyhow::Result<()> { configured_platforms.push("telegram"); } #[cfg(feature = "googlechat")] - if cfg - .googlechat - .clone() - .unwrap_or_default() - .resolve() - .enabled - { + if cfg.googlechat.clone().unwrap_or_default().resolve().enabled { configured_platforms.push("googlechat"); } #[cfg(feature = "lineworks")] @@ -1111,15 +1104,15 @@ async fn main() -> anyhow::Result<()> { #[cfg(feature = "filestore")] let gw_filestore = filestore.clone(); Some(tokio::spawn(async move { - if let Err(e) = - gateway::run_gateway_adapter( - params, - shutdown_rx, - gw_dispatcher, - gw_router, - #[cfg(feature = "filestore")] - gw_filestore, - ).await + if let Err(e) = gateway::run_gateway_adapter( + params, + shutdown_rx, + gw_dispatcher, + gw_router, + #[cfg(feature = "filestore")] + gw_filestore, + ) + .await { error!("gateway adapter error: {e}"); } @@ -1199,7 +1192,6 @@ async fn main() -> anyhow::Result<()> { )); } - // First-class `[telegram]` config overrides env-derived values // (config-authoritative + ${} expansion + TELEGRAM_* env fallback). #[cfg_attr(not(feature = "telegram"), allow(unused_variables))] @@ -1399,7 +1391,10 @@ async fn main() -> anyhow::Result<()> { f.config.api_base(), idle_ms, )); - info!(idle_ms, "unified: feishu card-streaming idle reaper started"); + info!( + idle_ms, + "unified: feishu card-streaming idle reaper started" + ); } if f.config.connection_mode == feishu::ConnectionMode::Websocket { let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); @@ -1541,19 +1536,22 @@ async fn main() -> anyhow::Result<()> { info!(addr = %listen_addr, "unified webhook server starting"); - (Some(tokio::spawn(async move { - let listener = match tokio::net::TcpListener::bind(&listen_addr).await { - Ok(l) => l, - Err(e) => { - error!(addr = %listen_addr, error = %e, "unified webhook server bind failed"); - return; + ( + Some(tokio::spawn(async move { + let listener = match tokio::net::TcpListener::bind(&listen_addr).await { + Ok(l) => l, + Err(e) => { + error!(addr = %listen_addr, error = %e, "unified webhook server bind failed"); + return; + } + }; + info!(addr = %listen_addr, "unified webhook server listening"); + if let Err(e) = axum::serve(listener, app).await { + error!(error = %e, "unified webhook server error"); } - }; - info!(addr = %listen_addr, "unified webhook server listening"); - if let Err(e) = axum::serve(listener, app).await { - error!(error = %e, "unified webhook server error"); - } - })), Some(cron_unified_adapter)) + })), + Some(cron_unified_adapter), + ) } else { (None, None) } @@ -2005,13 +2003,10 @@ allowed_users = ["u1"] assert_ne!(trust.decide("c2", false, "u1"), Decision::Allow); // Empty lists → allow-all (matching the old inline filter default). - let gw_open = config::parse_config_str( - "[gateway]\nurl = \"ws://gw:8080/ws\"\n", - "test", - ) - .unwrap() - .gateway - .unwrap(); + let gw_open = config::parse_config_str("[gateway]\nurl = \"ws://gw:8080/ws\"\n", "test") + .unwrap() + .gateway + .unwrap(); let trust_open = gateway_section_trust(&gw_open); assert_eq!(trust_open.decide("any", false, "anyone"), Decision::Allow); }