From 388795706869c18872bb60fc2f2a736265ac59d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Wed, 1 Jul 2026 01:07:23 +0800 Subject: [PATCH 1/9] fix(agent): reuse cached Composio toolkit actions --- .../harness/subagent_runner/ops/runner.rs | 90 +++++++++------- .../harness/subagent_runner/ops_tests.rs | 101 ++++++++++++++++++ 2 files changed, 152 insertions(+), 39 deletions(-) diff --git a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs index a28094b593..792161737f 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs @@ -422,48 +422,60 @@ async fn run_typed_mode( .iter() .find(|ci| ci.connected && ci.toolkit.eq_ignore_ascii_case(tk)) { - let fresh_actions = match &client_kind { - Some(ComposioClientKind::Backend(client)) => { - match crate::openhuman::composio::fetch_toolkit_actions(client, tk, None) + let fresh_actions = if !cached_integration.tools.is_empty() { + tracing::debug!( + agent_id = %definition.id, + toolkit = %tk, + cached_actions = cached_integration.tools.len(), + "[subagent_runner:typed] using cached toolkit catalogue" + ); + cached_integration.tools.clone() + } else { + match &client_kind { + Some(ComposioClientKind::Backend(client)) => { + match crate::openhuman::composio::fetch_toolkit_actions( + client, tk, None, + ) .await - { - Ok(actions) if !actions.is_empty() => actions, - Ok(_) => { - tracing::debug!( - agent_id = %definition.id, - toolkit = %tk, - "[subagent_runner:typed] fresh list_tools returned empty; falling back to cached catalogue" - ); - cached_integration.tools.clone() - } - Err(e) => { - tracing::warn!( - agent_id = %definition.id, - toolkit = %tk, - error = %e, - "[subagent_runner:typed] fresh list_tools failed; falling back to cached catalogue" - ); - cached_integration.tools.clone() + { + Ok(actions) if !actions.is_empty() => actions, + Ok(_) => { + tracing::debug!( + agent_id = %definition.id, + toolkit = %tk, + "[subagent_runner:typed] fresh list_tools returned empty; falling back to cached catalogue" + ); + cached_integration.tools.clone() + } + Err(e) => { + tracing::warn!( + agent_id = %definition.id, + toolkit = %tk, + error = %e, + "[subagent_runner:typed] fresh list_tools failed; falling back to cached catalogue" + ); + cached_integration.tools.clone() + } } } - } - Some(ComposioClientKind::Direct(_)) => { - tracing::info!( - agent_id = %definition.id, - toolkit = %tk, - cached_actions = cached_integration.tools.len(), - "[composio-direct] subagent_runner:typed: direct mode active — using cached catalogue, skipping backend list_tools refresh" - ); - cached_integration.tools.clone() - } - None => { - tracing::debug!( - agent_id = %definition.id, - toolkit = %tk, - cached_actions = cached_integration.tools.len(), - "[subagent_runner:typed] composio client unavailable; using cached catalogue" - ); - cached_integration.tools.clone() + Some(ComposioClientKind::Direct(_)) => { + tracing::info!( + agent_id = %definition.id, + toolkit = %tk, + cached_actions = cached_integration.tools.len(), + "[composio-direct] subagent_runner:typed: direct mode active — using cached catalogue, skipping backend list_tools refresh" + ); + cached_integration.tools.clone() + } + None => { + tracing::debug!( + agent_id = %definition.id, + toolkit = %tk, + cached_actions = cached_integration.tools.len(), + "[subagent_runner:typed] composio client unavailable; using cached catalogue" + ); + cached_integration.tools.clone() + } } }; let integration = crate::openhuman::context::prompt::ConnectedIntegration { diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index 70faf444bd..eae6b9408f 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -204,6 +204,27 @@ use crate::openhuman::inference::provider::{ use parking_lot::Mutex; use std::sync::Arc; +struct WorkspaceEnvGuard { + previous: Option, +} + +impl WorkspaceEnvGuard { + fn set(path: &std::path::Path) -> Self { + let previous = std::env::var_os("OPENHUMAN_WORKSPACE"); + std::env::set_var("OPENHUMAN_WORKSPACE", path); + Self { previous } + } +} + +impl Drop for WorkspaceEnvGuard { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => std::env::set_var("OPENHUMAN_WORKSPACE", value), + None => std::env::remove_var("OPENHUMAN_WORKSPACE"), + } + } +} + /// Mock provider whose response queue can be inspected by the test /// to verify the bytes that arrive at the model. #[derive(Clone)] @@ -701,6 +722,86 @@ async fn typed_mode_filters_tools_by_skill_filter() { ); } +#[tokio::test] +async fn integrations_agent_reuses_cached_toolkit_actions_without_refetching_list_tools() { + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + crate::openhuman::composio::invalidate_connected_integrations_cache(); + + let mut fixture = crate::openhuman::agent::harness::test_support::ComposioFixture::realistic(); + fixture.tools = vec![serde_json::json!({ + "type": "function", + "function": { + "name": "GMAIL_SEND_EMAIL", + "description": "Send an email via Gmail", + "parameters": { + "type": "object", + "properties": { + "recipient_email": {"type": "string"}, + "subject": {"type": "string"}, + "body": {"type": "string"} + }, + "required": ["recipient_email", "subject", "body"] + } + } + })]; + let backend = + crate::openhuman::agent::harness::test_support::spawn_fake_composio_backend(fixture).await; + let (config, workspace_root) = backend.config_persisted().await; + let _workspace = WorkspaceEnvGuard::set(&workspace_root); + + let integrations = crate::openhuman::composio::fetch_connected_integrations(&config).await; + let gmail = integrations + .iter() + .find(|integration| integration.toolkit == "gmail" && integration.connected) + .expect("fixture should expose a connected gmail integration"); + assert!( + !gmail.tools.is_empty(), + "cached gmail integration should include action schemas" + ); + + let requests_before = backend.requests(); + let tools_before = requests_before + .iter() + .filter(|(_, path, _)| path == "/tools") + .count(); + + let provider = ScriptedProvider::new(vec![text_response("done")]); + let mut parent = make_parent(provider.clone(), vec![]); + parent.connected_integrations = integrations; + let mut def = make_def_named_tools(&[]); + def.id = "integrations_agent".into(); + def.tools = ToolScope::Wildcard; + + let outcome = with_parent_context(parent, async { + run_subagent( + &def, + "send a short email to the user", + SubagentRunOptions { + toolkit_override: Some("gmail".into()), + ..Default::default() + }, + ) + .await + }) + .await + .expect("integrations_agent should run using cached gmail actions"); + assert_eq!(outcome.output, "done"); + + let requests_after = backend.requests(); + let tools_after = requests_after + .iter() + .filter(|(_, path, _)| path == "/tools") + .count(); + assert_eq!( + tools_after, tools_before, + "integrations_agent should reuse the parent cached action catalogue; requests: {requests_after:?}" + ); + + crate::openhuman::composio::invalidate_connected_integrations_cache(); +} + #[tokio::test] async fn typed_mode_executes_one_tool_then_returns() { // Two-round script: round 1 returns a tool call, round 2 returns From 92ea283d6a4d296c6c6decb7eee8c1c4db8fd09f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Wed, 1 Jul 2026 01:31:07 +0800 Subject: [PATCH 2/9] fix(composio): enforce scopes on cached actions --- .../harness/subagent_runner/ops/runner.rs | 35 ++++- .../harness/subagent_runner/ops_tests.rs | 135 ++++++++++++++++++ src/openhuman/composio/action_tool.rs | 46 ++++++ src/openhuman/composio/schemas.rs | 1 + src/openhuman/composio/tools.rs | 18 +++ 5 files changed, 234 insertions(+), 1 deletion(-) diff --git a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs index 792161737f..a3ce3aa3e0 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs @@ -91,6 +91,34 @@ pub(crate) fn tier_gate_decision( Ok(()) } +async fn filter_cached_toolkit_actions_with_current_scope( + agent_id: &str, + toolkit: &str, + actions: &[crate::openhuman::context::prompt::ConnectedIntegrationTool], +) -> Vec { + let pref = crate::openhuman::composio::providers::load_user_scope_or_default(toolkit).await; + let before = actions.len(); + let filtered: Vec<_> = actions + .iter() + .filter(|action| { + crate::openhuman::composio::providers::is_action_visible_with_pref(&action.name, &pref) + }) + .cloned() + .collect(); + tracing::debug!( + agent_id = %agent_id, + toolkit = %toolkit, + cached_actions = before, + visible_actions = filtered.len(), + hidden_actions = before.saturating_sub(filtered.len()), + read = pref.read, + write = pref.write, + admin = pref.admin, + "[subagent_runner:typed] re-filtered cached toolkit catalogue with current user scope" + ); + filtered +} + /// Run a sub-agent based on its definition and a task prompt. /// /// This is the primary entry point for agent delegation. It performs the following: @@ -429,7 +457,12 @@ async fn run_typed_mode( cached_actions = cached_integration.tools.len(), "[subagent_runner:typed] using cached toolkit catalogue" ); - cached_integration.tools.clone() + filter_cached_toolkit_actions_with_current_scope( + &definition.id, + tk, + &cached_integration.tools, + ) + .await } else { match &client_kind { Some(ComposioClientKind::Backend(client)) => { diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index eae6b9408f..e061fa4ebe 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -802,6 +802,141 @@ async fn integrations_agent_reuses_cached_toolkit_actions_without_refetching_lis crate::openhuman::composio::invalidate_connected_integrations_cache(); } +#[tokio::test] +async fn integrations_agent_refilters_cached_actions_with_current_user_scope() { + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let _memory_guard = crate::openhuman::memory::ops::GLOBAL_MEMORY_TEST_LOCK + .lock() + .await; + crate::openhuman::composio::invalidate_connected_integrations_cache(); + + let mut fixture = crate::openhuman::agent::harness::test_support::ComposioFixture::realistic(); + fixture.tools = vec![ + serde_json::json!({ + "type": "function", + "function": { + "name": "GMAIL_FETCH_EMAILS", + "description": "Fetch emails from Gmail", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string"} + } + } + } + }), + serde_json::json!({ + "type": "function", + "function": { + "name": "GMAIL_SEND_EMAIL", + "description": "Send an email via Gmail", + "parameters": { + "type": "object", + "properties": { + "recipient_email": {"type": "string"}, + "subject": {"type": "string"}, + "body": {"type": "string"} + }, + "required": ["recipient_email", "subject", "body"] + } + } + }), + ]; + let backend = + crate::openhuman::agent::harness::test_support::spawn_fake_composio_backend(fixture).await; + let (config, workspace_root) = backend.config_persisted().await; + let _workspace = WorkspaceEnvGuard::set(&workspace_root); + let memory = crate::openhuman::memory::global::init(config.workspace_dir.clone()) + .expect("global memory client should initialize for scope filtering test"); + + let integrations = crate::openhuman::composio::fetch_connected_integrations(&config).await; + let gmail = integrations + .iter() + .find(|integration| integration.toolkit == "gmail" && integration.connected) + .expect("fixture should expose a connected gmail integration"); + assert!( + gmail + .tools + .iter() + .any(|tool| tool.name == "GMAIL_SEND_EMAIL"), + "warm cache should include write action before the user disables write scope" + ); + + crate::openhuman::composio::providers::user_scopes::save( + &memory, + "gmail", + crate::openhuman::composio::providers::UserScopePref { + read: true, + write: false, + admin: false, + }, + ) + .await + .expect("scope pref should persist"); + + let requests_before = backend.requests(); + let tools_before = requests_before + .iter() + .filter(|(_, path, _)| path == "/tools") + .count(); + + let provider = ScriptedProvider::new(vec![text_response("done")]); + let mut parent = make_parent(provider.clone(), vec![]); + parent.connected_integrations = integrations; + let mut def = make_def_named_tools(&[]); + def.id = "integrations_agent".into(); + def.tools = ToolScope::Wildcard; + + let outcome = with_parent_context(parent, async { + run_subagent( + &def, + "read recent email", + SubagentRunOptions { + toolkit_override: Some("gmail".into()), + ..Default::default() + }, + ) + .await + }) + .await + .expect("integrations_agent should run using scope-filtered cached gmail actions"); + assert_eq!(outcome.output, "done"); + + let captured = provider.captured.lock(); + let first_request = captured + .first() + .expect("provider should receive one integrations_agent request"); + let system_msg = first_request + .messages + .iter() + .find(|message| message.role == "system") + .expect("system prompt should be present"); + assert!( + system_msg.content.contains("GMAIL_FETCH_EMAILS"), + "read-scoped cached action should remain visible in the prompt; system: {}", + system_msg.content + ); + assert!( + !system_msg.content.contains("GMAIL_SEND_EMAIL"), + "write-scoped cached action must be hidden after the current user scope disables write; system: {}", + system_msg.content + ); + + let requests_after = backend.requests(); + let tools_after = requests_after + .iter() + .filter(|(_, path, _)| path == "/tools") + .count(); + assert_eq!( + tools_after, tools_before, + "scope re-filtering should not re-fetch list_tools; requests: {requests_after:?}" + ); + + crate::openhuman::composio::invalidate_connected_integrations_cache(); +} + #[tokio::test] async fn typed_mode_executes_one_tool_then_returns() { // Two-round script: round 1 returns a tool call, round 2 returns diff --git a/src/openhuman/composio/action_tool.rs b/src/openhuman/composio/action_tool.rs index 867a4ae8b7..bc8bc65dfe 100644 --- a/src/openhuman/composio/action_tool.rs +++ b/src/openhuman/composio/action_tool.rs @@ -184,6 +184,15 @@ impl Tool for ComposioActionTool { } } + if let Some(message) = super::tools::action_execution_block_message(&self.action_name).await + { + tracing::info!( + tool = %self.action_name, + "[composio][scopes] per-action execute blocked by user scope pref" + ); + return Ok(ToolResult::error(message)); + } + // Inject `timeZone` / `singleEvents` defaults for Google // Calendar list slugs (issue #1714). The per-action surface is // the spawn-time tool an integrations sub-agent picks when it @@ -489,6 +498,43 @@ mod tests { ); } + #[tokio::test] + async fn user_scope_blocks_per_action_write_call_before_dispatch() { + let _memory_guard = crate::openhuman::memory::ops::GLOBAL_MEMORY_TEST_LOCK + .lock() + .await; + let tmp = tempfile::tempdir().expect("tempdir"); + let memory = crate::openhuman::memory::global::init(tmp.path().join("workspace")) + .expect("global memory client should initialize for action scope test"); + crate::openhuman::composio::providers::user_scopes::save( + &memory, + "gmail", + crate::openhuman::composio::providers::UserScopePref { + read: true, + write: false, + admin: false, + }, + ) + .await + .expect("scope pref should persist"); + + let t = ComposioActionTool::new( + fake_config(), + "GMAIL_SEND_EMAIL".to_string(), + "send a gmail message".to_string(), + None, + ); + let result = t.execute(serde_json::json!({})).await.unwrap(); + assert!( + result.is_error, + "per-action Write must error when the user's current scope pref disables write" + ); + let msg = error_text(&result); + assert!(msg.contains("disabled"), "got: {msg}"); + assert!(msg.contains("`write`"), "got: {msg}"); + assert!(msg.contains("Connections"), "got: {msg}"); + } + // ── Factory routing (#1710) ────────────────────────────────────── // // Regression coverage for the bug fix: `ComposioActionTool` now diff --git a/src/openhuman/composio/schemas.rs b/src/openhuman/composio/schemas.rs index 18a93baf04..27bfe52f84 100644 --- a/src/openhuman/composio/schemas.rs +++ b/src/openhuman/composio/schemas.rs @@ -954,6 +954,7 @@ fn handle_set_user_scopes(params: Map) -> ControllerFuture { ); return Err(e); } + super::ops::invalidate_connected_integrations_cache(); tracing::debug!( method = "composio.set_user_scopes", toolkit = %toolkit, diff --git a/src/openhuman/composio/tools.rs b/src/openhuman/composio/tools.rs index f7464b8f29..978787aa3b 100644 --- a/src/openhuman/composio/tools.rs +++ b/src/openhuman/composio/tools.rs @@ -122,6 +122,24 @@ async fn evaluate_tool_visibility(slug: &str) -> ToolDecision { } } +pub(super) async fn action_execution_block_message(slug: &str) -> Option { + match evaluate_tool_visibility(slug).await { + ToolDecision::Allow | ToolDecision::PassthroughCheckScope { .. } => None, + ToolDecision::BlockedByScope { scope } => { + let toolkit = toolkit_from_slug(slug).unwrap_or_default(); + let pref = load_user_scope_or_default(&toolkit).await; + Some(scope_error_message(slug, scope, pref)) + } + ToolDecision::NotCurated => { + let toolkit = toolkit_from_slug(slug).unwrap_or_default(); + Some(format!( + "composio_execute: action `{slug}` is not in the curated whitelist for \ + toolkit `{toolkit}`. Use composio_list_tools to see available actions." + )) + } + } +} + /// Drop tools whose toolkit is not in `connected` (case-insensitive). /// Returns the number of dropped tools so callers can log it. /// `toolkit_from_slug` already lowercases its result, so the comparison From df41f749c1c454019e70d55c78016220cd23c94b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Wed, 1 Jul 2026 02:26:25 +0800 Subject: [PATCH 3/9] test(composio): isolate cached action scope state --- .../harness/subagent_runner/ops_tests.rs | 37 ++++++++++- .../agent/harness/test_support_tests.rs | 64 +++++++++++++++---- src/openhuman/composio/action_tool.rs | 13 +++- src/openhuman/composio/mod.rs | 2 + src/openhuman/composio/ops_tests.rs | 12 ++++ 5 files changed, 110 insertions(+), 18 deletions(-) diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index e061fa4ebe..3a16029905 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -225,6 +225,15 @@ impl Drop for WorkspaceEnvGuard { } } +async fn save_gmail_scope_for_test( + memory: &crate::openhuman::memory_store::MemoryClientRef, + pref: crate::openhuman::composio::providers::UserScopePref, +) { + crate::openhuman::composio::providers::user_scopes::save(memory, "gmail", pref) + .await + .expect("gmail scope pref should persist for isolated test"); +} + /// Mock provider whose response queue can be inspected by the test /// to verify the bytes that arrive at the model. #[derive(Clone)] @@ -724,9 +733,13 @@ async fn typed_mode_filters_tools_by_skill_filter() { #[tokio::test] async fn integrations_agent_reuses_cached_toolkit_actions_without_refetching_list_tools() { + let _memory_guard = crate::openhuman::memory::ops::GLOBAL_MEMORY_TEST_LOCK + .lock() + .await; let _env_guard = crate::openhuman::config::TEST_ENV_LOCK .lock() .unwrap_or_else(|e| e.into_inner()); + let _cache_guard = crate::openhuman::composio::composio_cache_test_lock(); crate::openhuman::composio::invalidate_connected_integrations_cache(); let mut fixture = crate::openhuman::agent::harness::test_support::ComposioFixture::realistic(); @@ -750,6 +763,13 @@ async fn integrations_agent_reuses_cached_toolkit_actions_without_refetching_lis crate::openhuman::agent::harness::test_support::spawn_fake_composio_backend(fixture).await; let (config, workspace_root) = backend.config_persisted().await; let _workspace = WorkspaceEnvGuard::set(&workspace_root); + let memory = crate::openhuman::memory::global::init(config.workspace_dir.clone()) + .expect("global memory client should initialize for cache reuse test"); + save_gmail_scope_for_test( + &memory, + crate::openhuman::composio::providers::UserScopePref::default(), + ) + .await; let integrations = crate::openhuman::composio::fetch_connected_integrations(&config).await; let gmail = integrations @@ -804,12 +824,13 @@ async fn integrations_agent_reuses_cached_toolkit_actions_without_refetching_lis #[tokio::test] async fn integrations_agent_refilters_cached_actions_with_current_user_scope() { - let _env_guard = crate::openhuman::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); let _memory_guard = crate::openhuman::memory::ops::GLOBAL_MEMORY_TEST_LOCK .lock() .await; + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let _cache_guard = crate::openhuman::composio::composio_cache_test_lock(); crate::openhuman::composio::invalidate_connected_integrations_cache(); let mut fixture = crate::openhuman::agent::harness::test_support::ComposioFixture::realistic(); @@ -850,6 +871,11 @@ async fn integrations_agent_refilters_cached_actions_with_current_user_scope() { let _workspace = WorkspaceEnvGuard::set(&workspace_root); let memory = crate::openhuman::memory::global::init(config.workspace_dir.clone()) .expect("global memory client should initialize for scope filtering test"); + save_gmail_scope_for_test( + &memory, + crate::openhuman::composio::providers::UserScopePref::default(), + ) + .await; let integrations = crate::openhuman::composio::fetch_connected_integrations(&config).await; let gmail = integrations @@ -934,6 +960,11 @@ async fn integrations_agent_refilters_cached_actions_with_current_user_scope() { "scope re-filtering should not re-fetch list_tools; requests: {requests_after:?}" ); + save_gmail_scope_for_test( + &memory, + crate::openhuman::composio::providers::UserScopePref::default(), + ) + .await; crate::openhuman::composio::invalidate_connected_integrations_cache(); } diff --git a/src/openhuman/agent/harness/test_support_tests.rs b/src/openhuman/agent/harness/test_support_tests.rs index b51cab57bf..34f563b8e2 100644 --- a/src/openhuman/agent/harness/test_support_tests.rs +++ b/src/openhuman/agent/harness/test_support_tests.rs @@ -13,6 +13,7 @@ use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, ChatRespon use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult, ToolScope}; use async_trait::async_trait; use serde_json::json; +use std::path::Path; use std::sync::{ atomic::{AtomicUsize, Ordering}, Arc, @@ -26,6 +27,41 @@ fn mff() -> crate::openhuman::config::MultimodalFileConfig { crate::openhuman::config::MultimodalFileConfig::default() } +struct WorkspaceEnvGuard { + previous: Option, +} + +impl WorkspaceEnvGuard { + fn set(path: &Path) -> Self { + let previous = std::env::var_os("OPENHUMAN_WORKSPACE"); + unsafe { + std::env::set_var("OPENHUMAN_WORKSPACE", path); + } + Self { previous } + } +} + +impl Drop for WorkspaceEnvGuard { + fn drop(&mut self) { + unsafe { + match self.previous.take() { + Some(value) => std::env::set_var("OPENHUMAN_WORKSPACE", value), + None => std::env::remove_var("OPENHUMAN_WORKSPACE"), + } + } + } +} + +async fn allow_gmail_scope_for_test(memory: &crate::openhuman::memory_store::MemoryClientRef) { + crate::openhuman::composio::providers::user_scopes::save( + memory, + "gmail", + crate::openhuman::composio::providers::UserScopePref::default(), + ) + .await + .expect("gmail scope pref should reset for isolated harness test"); +} + #[tokio::test] async fn keyword_provider_records_forced_then_fallback_turns() { let provider = @@ -1369,13 +1405,17 @@ async fn harness_invokes_composio_action_tool_against_fake_backend() { // only routes to the fake backend if it is the live on-disk config. // Hold `TEST_ENV_LOCK` and point `OPENHUMAN_WORKSPACE` at the // persisted fake-backend workspace. + let _memory_guard = crate::openhuman::memory::ops::GLOBAL_MEMORY_TEST_LOCK + .lock() + .await; let _env_guard = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let backend = spawn_fake_composio_backend(ComposioFixture::realistic()).await; let (config, workspace_root) = backend.config_persisted().await; - unsafe { - std::env::set_var("OPENHUMAN_WORKSPACE", &workspace_root); - } + let _workspace_guard = WorkspaceEnvGuard::set(&workspace_root); + let memory = crate::openhuman::memory::global::init(config.workspace_dir.clone()) + .expect("global memory client should initialize for harness Composio test"); + allow_gmail_scope_for_test(&memory).await; let tool = ComposioActionTool::new( config, @@ -1430,9 +1470,7 @@ async fn harness_invokes_composio_action_tool_against_fake_backend() { assert_eq!(exec.2["tool"], "GMAIL_SEND_EMAIL"); assert_eq!(exec.2["arguments"]["recipient_email"], "alice@example.com"); - unsafe { - std::env::remove_var("OPENHUMAN_WORKSPACE"); - } + allow_gmail_scope_for_test(&memory).await; } // ── 13. Orchestrator-prompt → delegation → Composio round-trip ──── @@ -1581,6 +1619,9 @@ async fn orchestrator_prompt_drives_composio_call_via_delegation_chain() { // `load_config_with_timeout()` per call. Hold `TEST_ENV_LOCK` and // point `OPENHUMAN_WORKSPACE` at the persisted fake-backend // workspace so the tool routes to the fake backend. + let _memory_guard = crate::openhuman::memory::ops::GLOBAL_MEMORY_TEST_LOCK + .lock() + .await; let _env_guard = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); // ── 1. Build the orchestrator's system prompt with gmail wired in. @@ -1629,9 +1670,10 @@ async fn orchestrator_prompt_drives_composio_call_via_delegation_chain() { // ── 2. Spawn the fake Composio backend + wire a ComposioActionTool. let backend = spawn_fake_composio_backend(ComposioFixture::realistic()).await; let (composio_config, workspace_root) = backend.config_persisted().await; - unsafe { - std::env::set_var("OPENHUMAN_WORKSPACE", &workspace_root); - } + let _workspace_guard = WorkspaceEnvGuard::set(&workspace_root); + let memory = crate::openhuman::memory::global::init(composio_config.workspace_dir.clone()) + .expect("global memory client should initialize for orchestration Composio test"); + allow_gmail_scope_for_test(&memory).await; let gmail_action_tool: Box = Box::new(ComposioActionTool::new( composio_config, "GMAIL_SEND_EMAIL".to_string(), @@ -1744,7 +1786,5 @@ async fn orchestrator_prompt_drives_composio_call_via_delegation_chain() { "integrations agent must have matched its tool-call rule" ); - unsafe { - std::env::remove_var("OPENHUMAN_WORKSPACE"); - } + allow_gmail_scope_for_test(&memory).await; } diff --git a/src/openhuman/composio/action_tool.rs b/src/openhuman/composio/action_tool.rs index bc8bc65dfe..20f101a3d1 100644 --- a/src/openhuman/composio/action_tool.rs +++ b/src/openhuman/composio/action_tool.rs @@ -508,7 +508,7 @@ mod tests { .expect("global memory client should initialize for action scope test"); crate::openhuman::composio::providers::user_scopes::save( &memory, - "gmail", + "testkit", crate::openhuman::composio::providers::UserScopePref { read: true, write: false, @@ -520,8 +520,8 @@ mod tests { let t = ComposioActionTool::new( fake_config(), - "GMAIL_SEND_EMAIL".to_string(), - "send a gmail message".to_string(), + "TESTKIT_SEND_MESSAGE".to_string(), + "send a testkit message".to_string(), None, ); let result = t.execute(serde_json::json!({})).await.unwrap(); @@ -533,6 +533,13 @@ mod tests { assert!(msg.contains("disabled"), "got: {msg}"); assert!(msg.contains("`write`"), "got: {msg}"); assert!(msg.contains("Connections"), "got: {msg}"); + crate::openhuman::composio::providers::user_scopes::save( + &memory, + "testkit", + crate::openhuman::composio::providers::UserScopePref::default(), + ) + .await + .expect("testkit scope pref should restore after action scope test"); } // ── Factory routing (#1710) ────────────────────────────────────── diff --git a/src/openhuman/composio/mod.rs b/src/openhuman/composio/mod.rs index 746d75d7fe..69b88373a4 100644 --- a/src/openhuman/composio/mod.rs +++ b/src/openhuman/composio/mod.rs @@ -71,6 +71,8 @@ pub use crate::openhuman::memory_sync::composio::providers::{ }; pub use action_tool::ComposioActionTool; pub use client::ComposioClient; +#[cfg(test)] +pub(crate) use connected_integrations::composio_cache_test_lock; pub use identity::connection_identity; pub use ops::{ cached_active_integrations, connected_set_hash, fetch_connected_integrations, diff --git a/src/openhuman/composio/ops_tests.rs b/src/openhuman/composio/ops_tests.rs index 4e56ed2566..c09e27c59c 100644 --- a/src/openhuman/composio/ops_tests.rs +++ b/src/openhuman/composio/ops_tests.rs @@ -1864,6 +1864,9 @@ async fn composio_list_toolkits_returns_empty_in_direct_mode() { #[tokio::test] async fn composio_list_connections_routes_through_direct_mode() { + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); let _guard = cache_guard(); let tmp = tempfile::tempdir().unwrap(); let config = direct_mode_config(&tmp); @@ -2089,6 +2092,9 @@ async fn composio_set_api_key_validates_candidate_key_even_when_stored_key_exist /// integration-style test only pins the failure-mode contract. #[tokio::test] async fn composio_list_tools_in_direct_mode_does_not_fall_back_to_backend() { + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); let tmp = tempfile::tempdir().unwrap(); let config = direct_mode_config(&tmp); let result = composio_list_tools(&config, None, None).await; @@ -2122,6 +2128,9 @@ async fn composio_list_tools_in_direct_mode_does_not_fall_back_to_backend() { #[tokio::test] async fn composio_authorize_routes_through_direct_mode() { + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); // The direct-mode `authorize` path actually calls // `backend.composio.dev/api/v3/connected_accounts/link` over HTTPS. // We can't mock that endpoint at the URL-rewriter level in this @@ -2148,6 +2157,9 @@ async fn composio_authorize_routes_through_direct_mode() { #[tokio::test] async fn composio_execute_routes_through_direct_mode() { + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); // Same shape of assertion as // `composio_authorize_routes_through_direct_mode` — we can't mock // `backend.composio.dev` from a unit test, so we verify the factory From 43979a7b390c7cc9677c3e8a524c2eea69042939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Thu, 2 Jul 2026 19:35:29 +0800 Subject: [PATCH 4/9] test(composio): keep cached action tests on current harness --- .../harness/subagent_runner/ops_tests.rs | 175 +++++++++++++++++- 1 file changed, 169 insertions(+), 6 deletions(-) diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index 01e75f6ab7..65f5706106 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -235,6 +235,171 @@ async fn save_gmail_scope_for_test( .expect("gmail scope pref should persist for isolated test"); } +#[derive(Clone)] +struct ComposioFixture { + toolkits: Vec, + connections: Vec, + tools: Vec, +} + +impl ComposioFixture { + fn realistic() -> Self { + Self { + toolkits: vec![ + "gmail".to_string(), + "notion".to_string(), + "github".to_string(), + "slack".to_string(), + ], + connections: vec![ + serde_json::json!({ + "id": "conn_gmail_1", + "toolkit": "gmail", + "status": "ACTIVE", + "createdAt": "2026-04-01T12:00:00Z", + }), + serde_json::json!({ + "id": "conn_notion_1", + "toolkit": "notion", + "status": "ACTIVE", + "createdAt": "2026-04-02T08:00:00Z", + }), + serde_json::json!({ + "id": "conn_github_1", + "toolkit": "github", + "status": "ACTIVE", + "createdAt": "2026-04-03T15:30:00Z", + }), + ], + tools: Vec::new(), + } + } +} + +#[derive(Clone)] +struct FakeComposioState { + fixture: Arc>, + requests: Arc>>, +} + +struct FakeComposioBackend { + base_url: String, + state: FakeComposioState, +} + +impl FakeComposioBackend { + fn requests(&self) -> Vec<(String, String, serde_json::Value)> { + self.state.requests.lock().clone() + } + + async fn config_persisted( + &self, + ) -> (Arc, std::path::PathBuf) { + use crate::openhuman::credentials::{ + AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME, + }; + + let tmp = tempfile::tempdir().expect("tempdir for fake Composio config"); + let workspace_root = tmp.path().to_path_buf(); + let mut config = crate::openhuman::config::Config::default(); + config.workspace_dir = workspace_root.join("workspace"); + config.config_path = workspace_root.join("config.toml"); + config.api_url = Some(self.base_url.clone()); + config.composio.mode = crate::openhuman::config::schema::COMPOSIO_MODE_BACKEND.to_string(); + config.secrets.encrypt = false; + AuthService::from_config(&config) + .store_provider_token( + APP_SESSION_PROVIDER, + DEFAULT_AUTH_PROFILE_NAME, + "test-token", + std::collections::HashMap::new(), + true, + ) + .expect("store fake app-session token"); + config.save().await.expect("persist fake Composio config"); + std::mem::forget(tmp); + (Arc::new(config), workspace_root) + } +} + +async fn record_request( + requests: &Arc>>, + method: &str, + path: &str, + body: serde_json::Value, +) { + requests + .lock() + .push((method.to_string(), path.to_string(), body)); +} + +async fn spawn_fake_composio_backend(fixture: ComposioFixture) -> FakeComposioBackend { + use axum::{routing::get, Json, Router}; + + let state = FakeComposioState { + fixture: Arc::new(Mutex::new(fixture)), + requests: Arc::new(Mutex::new(Vec::new())), + }; + + let app = Router::new() + .route( + "/agent-integrations/composio/toolkits", + get({ + let st = state.clone(); + move || async move { + record_request(&st.requests, "GET", "/toolkits", serde_json::Value::Null).await; + let toolkits = st.fixture.lock().toolkits.clone(); + Json(serde_json::json!({ + "success": true, + "data": { "toolkits": toolkits } + })) + } + }), + ) + .route( + "/agent-integrations/composio/connections", + get({ + let st = state.clone(); + move || async move { + record_request(&st.requests, "GET", "/connections", serde_json::Value::Null) + .await; + let connections = st.fixture.lock().connections.clone(); + Json(serde_json::json!({ + "success": true, + "data": { "connections": connections } + })) + } + }), + ) + .route( + "/agent-integrations/composio/tools", + get({ + let st = state.clone(); + move || async move { + record_request(&st.requests, "GET", "/tools", serde_json::Value::Null).await; + let tools = st.fixture.lock().tools.clone(); + Json(serde_json::json!({ + "success": true, + "data": { "tools": tools } + })) + } + }), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind fake Composio backend"); + let addr = listener.local_addr().expect("fake Composio backend addr"); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + FakeComposioBackend { + base_url: format!("http://127.0.0.1:{}", addr.port()), + state, + } +} + /// Mock provider whose response queue can be inspected by the test /// to verify the bytes that arrive at the model. #[derive(Clone)] @@ -785,7 +950,7 @@ async fn integrations_agent_reuses_cached_toolkit_actions_without_refetching_lis let _cache_guard = crate::openhuman::composio::composio_cache_test_lock(); crate::openhuman::composio::invalidate_connected_integrations_cache(); - let mut fixture = crate::openhuman::agent::harness::test_support::ComposioFixture::realistic(); + let mut fixture = ComposioFixture::realistic(); fixture.tools = vec![serde_json::json!({ "type": "function", "function": { @@ -802,8 +967,7 @@ async fn integrations_agent_reuses_cached_toolkit_actions_without_refetching_lis } } })]; - let backend = - crate::openhuman::agent::harness::test_support::spawn_fake_composio_backend(fixture).await; + let backend = spawn_fake_composio_backend(fixture).await; let (config, workspace_root) = backend.config_persisted().await; let _workspace = WorkspaceEnvGuard::set(&workspace_root); let memory = crate::openhuman::memory::global::init(config.workspace_dir.clone()) @@ -876,7 +1040,7 @@ async fn integrations_agent_refilters_cached_actions_with_current_user_scope() { let _cache_guard = crate::openhuman::composio::composio_cache_test_lock(); crate::openhuman::composio::invalidate_connected_integrations_cache(); - let mut fixture = crate::openhuman::agent::harness::test_support::ComposioFixture::realistic(); + let mut fixture = ComposioFixture::realistic(); fixture.tools = vec![ serde_json::json!({ "type": "function", @@ -908,8 +1072,7 @@ async fn integrations_agent_refilters_cached_actions_with_current_user_scope() { } }), ]; - let backend = - crate::openhuman::agent::harness::test_support::spawn_fake_composio_backend(fixture).await; + let backend = spawn_fake_composio_backend(fixture).await; let (config, workspace_root) = backend.config_persisted().await; let _workspace = WorkspaceEnvGuard::set(&workspace_root); let memory = crate::openhuman::memory::global::init(config.workspace_dir.clone()) From b6483701d9d2e3a4fadd6eb7800a19ab1b5c2c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Sun, 19 Jul 2026 21:05:55 +0800 Subject: [PATCH 5/9] ci: refresh PR gate allowlists --- .github/workflows/ci-lite.yml | 1 + .github/workflows/pr-quality.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index a5162b892c..4ae6ae2029 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -451,6 +451,7 @@ jobs: openhuman/agent/harness/session/tests.rs openhuman/agent/harness/subagent_runner/tool_prep.rs openhuman/agent_registry/agents/loader.rs + openhuman/composio/action_tool.rs openhuman/inference/local/mod.rs openhuman/tool_registry/ops_tests.rs openhuman/tool_registry/schemas.rs diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index 24693ba557..5a21d67f49 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -72,6 +72,7 @@ jobs: --exclude '^https://www\.star-history\.com/#tinyhumansai/openhuman&type=date&legend=top-left$' --exclude '^https://github\.com/tinyhumansai/openhuman/stargazers' --exclude '^https://api\.star-history\.com/' + --exclude '^https://img\.shields\.io/' --exclude '^https://x\.com/karpathy/status/2039805659525644595$' 'docs/**/*.md' 'src/**/README.md' From 1c421884c19846a86f86df9a2586b13c27ce9fc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Sun, 19 Jul 2026 21:42:19 +0800 Subject: [PATCH 6/9] test(learning): isolate email signature subscriber check --- src/openhuman/learning/startup.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/openhuman/learning/startup.rs b/src/openhuman/learning/startup.rs index 2da5a5de62..1d37bb2370 100644 --- a/src/openhuman/learning/startup.rs +++ b/src/openhuman/learning/startup.rs @@ -160,6 +160,7 @@ mod tests { use crate::core::event_bus::{init_global, publish_global, DomainEvent, DEFAULT_CAPACITY}; use crate::openhuman::learning::candidate::{self, EvidenceRef}; use crate::openhuman::learning::extract::signature::parse_signature; + use crate::openhuman::learning::extract::signature::EmailSignatureSubscriber; use crate::openhuman::memory_store::MemoryClient; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; @@ -271,13 +272,6 @@ mod tests { #[tokio::test] async fn learning_subscriber_fires_with_no_channel_configured() { - init_global(DEFAULT_CAPACITY); - let (tmp, _client) = test_client(); - // Make the memory client ready so the full Platform wiring runs — no - // channel runtime is ever constructed in this test. - let _ = crate::openhuman::memory::global::init(tmp.path().join("workspace")); - register_learning_subscribers(tmp.path().to_path_buf()); - let source_id = unique_source_id("e2e"); let body = signature_body(); let expected = parse_signature(&body, &source_id, &source_id).len(); @@ -286,7 +280,15 @@ mod tests { "signature body must yield at least one identity candidate" ); - publish_email_doc(&source_id, &body); + let event = DomainEvent::DocumentCanonicalized { + source_id: source_id.clone(), + source_kind: "email".to_string(), + chunks_written: 1, + chunk_ids: vec![format!("{source_id}-c1")], + canonicalized_at: 0.0, + body_preview: Some(body), + }; + crate::core::event_bus::EventHandler::handle(&EmailSignatureSubscriber, &event).await; let got = wait_for_candidates(&source_id, expected).await; assert_eq!( got, expected, From 8c50a0839a0fb77d936ed5bde2891cb1c39572a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Sun, 19 Jul 2026 22:26:38 +0800 Subject: [PATCH 7/9] test(composio): cover action contract gate retry --- .../composio_credentials_state_raw_coverage_e2e.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs b/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs index 486fd061c6..d049818666 100644 --- a/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs +++ b/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs @@ -307,10 +307,21 @@ async fn round15_composio_agent_tools_backend_cache_and_trigger_history_edges() ); assert_eq!(action_tool.name(), "GMAIL_FETCH_EMAILS"); assert_eq!(action_tool.category().to_string(), "skill"); + let contract_prompt = action_tool + .execute(json!({ "query": "from:me" })) + .await + .expect("per-action tool contract prompt"); + assert!(contract_prompt.is_error); + assert!(contract_prompt + .text() + .contains("Before running `GMAIL_FETCH_EMAILS`")); + assert!(contract_prompt.text().contains("Required arguments: query")); + let action_result = action_tool .execute(json!({ "query": "from:me" })) .await - .expect("per-action tool execute"); + .expect("per-action tool retry execute"); + assert!(!action_result.is_error); assert_eq!(action_result.text(), "Fetched 1 inbox message"); let reserved = composio_authorize(&config, "gmail", Some(json!({ "toolkit": "github" }))) From f0abe232a5148d7e8076a410a7ac2c028c395ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Sun, 19 Jul 2026 23:18:02 +0800 Subject: [PATCH 8/9] test(composio): trigger contract prompt without query --- .../raw_coverage/composio_credentials_state_raw_coverage_e2e.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs b/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs index d049818666..2debfd660f 100644 --- a/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs +++ b/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs @@ -308,7 +308,7 @@ async fn round15_composio_agent_tools_backend_cache_and_trigger_history_edges() assert_eq!(action_tool.name(), "GMAIL_FETCH_EMAILS"); assert_eq!(action_tool.category().to_string(), "skill"); let contract_prompt = action_tool - .execute(json!({ "query": "from:me" })) + .execute(json!({})) .await .expect("per-action tool contract prompt"); assert!(contract_prompt.is_error); From 2880d456fb25c018643fccadd0bad5b3c6558ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Sun, 19 Jul 2026 23:49:48 +0800 Subject: [PATCH 9/9] test(agent): isolate cached toolkit reuse from live refresh --- .../agent/harness/subagent_runner/ops_tests.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index 926ba9b71e..8aeb8e85d1 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -105,6 +105,16 @@ fn stub(name: &'static str) -> Box { Box::new(StubTool { name }) } +fn remove_fake_app_session(config: &crate::openhuman::config::Config) { + let removed = crate::openhuman::credentials::AuthService::from_config(config) + .remove_profile( + crate::openhuman::credentials::APP_SESSION_PROVIDER, + crate::openhuman::credentials::DEFAULT_AUTH_PROFILE_NAME, + ) + .expect("fake app-session token should be removable"); + assert!(removed, "fake app-session token should exist"); +} + #[test] fn filter_named_scope_keeps_only_named() { let parent: Vec> = vec![stub("alpha"), stub("beta"), stub("gamma")]; @@ -990,6 +1000,7 @@ async fn integrations_agent_reuses_cached_toolkit_actions_without_refetching_lis !gmail.tools.is_empty(), "cached gmail integration should include action schemas" ); + remove_fake_app_session(config.as_ref()); let requests_before = backend.requests(); let tools_before = requests_before @@ -1098,6 +1109,7 @@ async fn integrations_agent_refilters_cached_actions_with_current_user_scope() { .any(|tool| tool.name == "GMAIL_SEND_EMAIL"), "warm cache should include write action before the user disables write scope" ); + remove_fake_app_session(config.as_ref()); crate::openhuman::composio::providers::user_scopes::save( &memory,