Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 13 additions & 25 deletions src/openhuman/composio/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ use crate::openhuman::config::Config;
use crate::openhuman::memory::MemoryClient;
use crate::openhuman::memory_store::chunks::store as memory_tree_store;
use crate::openhuman::memory_store::chunks::types::SourceKind;
use crate::openhuman::memory_store::content::raw::slug_account_email;
use crate::rpc::RpcOutcome;

/// Result alias used by every `composio_*` op in this module.
Expand Down Expand Up @@ -549,6 +548,7 @@ pub async fn composio_delete_connection(
enum MemoryCleanupTarget {
Exact(SourceKind, String),
Prefix(SourceKind, String),
Owner(SourceKind, String),
}

impl MemoryCleanupTarget {
Expand All @@ -564,6 +564,9 @@ impl MemoryCleanupTarget {
source_id_prefix,
)
}
Self::Owner(source_kind, owner) => {
memory_tree_store::delete_chunks_by_owner(config, *source_kind, owner)
}
}
}

Expand All @@ -575,6 +578,9 @@ impl MemoryCleanupTarget {
Self::Prefix(source_kind, source_id_prefix) => {
format!("{}:{source_id_prefix}*", source_kind.as_str())
}
Self::Owner(source_kind, owner) => {
format!("{}:owner:{owner}", source_kind.as_str())
}
}
}
}
Expand Down Expand Up @@ -604,30 +610,12 @@ async fn composio_memory_targets_for_connection(
}

fn gmail_memory_sources_for_connection(connection_id: &str) -> Vec<MemoryCleanupTarget> {
let normalized_connection_id =
super::providers::profile::normalize_connection_identifier(connection_id);
let mut sources = Vec::new();
for identity in super::providers::profile::load_connected_identities() {
if identity.source != "gmail" || identity.identifier != normalized_connection_id {
continue;
}
let Some(email) = identity
.email
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
else {
continue;
};
let source = MemoryCleanupTarget::Exact(
SourceKind::Email,
format!("gmail:{}", slug_account_email(email)),
);
if !sources.iter().any(|existing| existing == &source) {
sources.push(source);
}
}
sources
vec![
MemoryCleanupTarget::Owner(SourceKind::Email, format!("gmail-sync:{connection_id}")),
MemoryCleanupTarget::Exact(SourceKind::Email, format!("gmail:{connection_id}")),
MemoryCleanupTarget::Prefix(SourceKind::Email, format!("gmail:{connection_id}:")),
MemoryCleanupTarget::Prefix(SourceKind::Email, format!("gmail:{connection_id}/")),
]
}
Comment thread
Felyx-Fu marked this conversation as resolved.

async fn notion_memory_targets_for_connection(
Expand Down
88 changes: 85 additions & 3 deletions src/openhuman/composio/ops_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,16 +316,26 @@ fn config_with_backend(tmp: &tempfile::TempDir, base: String) -> Config {
}

fn sample_memory_chunk(source_kind: SourceKind, source_id: &str, seq: u32) -> Chunk {
sample_memory_chunk_with_owner(source_kind, source_id, "alice@example.com", seq)
}

fn sample_memory_chunk_with_owner(
source_kind: SourceKind,
source_id: &str,
owner: &str,
seq: u32,
) -> Chunk {
let ts = Utc
.timestamp_millis_opt(1_700_000_000_000 + i64::from(seq))
.unwrap();
let content = format!("composio memory {source_id} {owner} {seq}");
Chunk {
id: chunk_id(source_kind, source_id, seq, "composio memory"),
content: format!("composio memory {source_id} {seq}"),
id: chunk_id(source_kind, source_id, seq, &content),
content,
metadata: Metadata {
source_kind,
source_id: source_id.to_string(),
owner: "alice@example.com".to_string(),
owner: owner.to_string(),
timestamp: ts,
time_range: (ts, ts),
tags: vec!["composio".to_string()],
Expand Down Expand Up @@ -512,6 +522,78 @@ async fn composio_delete_connection_clear_memory_deletes_slack_source() {
assert_eq!(remaining[0].metadata.source_id, "slack:c2");
}

#[tokio::test]
async fn composio_delete_connection_clear_memory_keeps_other_gmail_connections() {
let app = Router::new()
.route(
"/agent-integrations/composio/connections",
get(|| async {
Json(json!({
"success": true,
"data": {"connections": [
{"id":"c1","toolkit":"gmail","status":"ACTIVE"},
{"id":"c2","toolkit":"gmail","status":"ACTIVE"}
]}
}))
}),
)
.route(
"/agent-integrations/composio/connections/{id}",
axum::routing::delete(|Path(_id): Path<String>| async move {
Json(json!({"success": true, "data": {"deleted": true}}))
}),
);
let base = start_mock_backend(app).await;
let tmp = tempfile::tempdir().unwrap();
let config = config_with_backend(&tmp, base);
let c1_account = sample_memory_chunk_with_owner(
SourceKind::Email,
"gmail:pilot-at-example-dot-com",
"gmail-sync:c1",
0,
);
let c2_account = sample_memory_chunk_with_owner(
SourceKind::Email,
"gmail:pilot-at-example-dot-com",
"gmail-sync:c2",
1,
);
let c1_connection_scoped =
sample_memory_chunk_with_owner(SourceKind::Email, "gmail:c1:thread-a", "gmail-sync:c1", 2);
let c2_connection_scoped =
sample_memory_chunk_with_owner(SourceKind::Email, "gmail:c2:thread-b", "gmail-sync:c2", 3);
memory_tree_store::upsert_chunks(
&config,
&[
c1_account,
c2_account.clone(),
c1_connection_scoped,
c2_connection_scoped.clone(),
],
)
.expect("chunks should seed");

let outcome = composio_delete_connection(&config, "c1", true)
.await
.unwrap();

assert!(outcome.value.deleted);
assert_eq!(outcome.value.memory_chunks_deleted, 2);
let remaining = memory_tree_store::list_chunks(
&config,
&memory_tree_store::ListChunksQuery {
source_kind: Some(SourceKind::Email),
..Default::default()
},
)
.expect("chunks should list");
assert_eq!(remaining.len(), 2);
assert!(remaining.iter().any(|chunk| chunk.id == c2_account.id));
assert!(remaining
.iter()
.any(|chunk| chunk.id == c2_connection_scoped.id));
}

#[tokio::test]
async fn notion_cleanup_targets_include_synced_page_sources() {
let tmp = tempfile::tempdir().unwrap();
Expand Down
12 changes: 11 additions & 1 deletion src/openhuman/inference/provider/factory_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,7 @@ fn known_tiers_pass() {
"agentic-v1",
"coding-v1",
"reasoning-quick-v1",
"summarization-v1",
] {
assert!(
is_known_openhuman_tier(tier),
Expand All @@ -738,6 +739,7 @@ fn known_hints_pass() {
assert!(is_known_openhuman_tier("hint:chat"));
assert!(is_known_openhuman_tier("hint:agentic"));
assert!(is_known_openhuman_tier("hint:coding"));
assert!(is_known_openhuman_tier("hint:summarization"));
}

#[test]
Expand All @@ -748,7 +750,7 @@ fn invalid_models_fail() {
assert!(!is_known_openhuman_tier(""));
assert!(!is_known_openhuman_tier("reasoning-v2"));
// Unrecognized `hint:*` values must NOT be accepted — the factory only
// translates the four hints above, so any other `hint:*` string would
// translates the known hints above, so any other `hint:*` string would
// otherwise be forwarded to the backend and rejected with HTTP 400.
assert!(!is_known_openhuman_tier("hint:garbage"));
assert!(!is_known_openhuman_tier("hint:reasoning-quick"));
Expand All @@ -771,6 +773,14 @@ fn make_openhuman_backend_forwards_unknown_hint_verbatim() {
}
}

#[test]
fn make_openhuman_backend_translates_summarization_hint() {
let mut config = Config::default();
config.default_model = Some("hint:summarization".to_string());
let (_, model) = make_openhuman_backend(&config).expect("factory should succeed");
assert_eq!(model, crate::openhuman::config::MODEL_SUMMARIZATION_V1);
}

#[test]
fn make_openhuman_backend_falls_back_for_invalid_model() {
// An invalid default_model must not be forwarded to the backend.
Expand Down
1 change: 1 addition & 0 deletions src/openhuman/memory/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ mod tests {
fn build_chat_runtime_defaults_to_openhuman_resolved_model() {
let cfg = Config::default();
let (_provider, model) = build_chat_runtime(&cfg).unwrap();
assert_eq!(model, DEFAULT_CLOUD_LLM_MODEL);
// build_chat_runtime resolves the "summarization" workload role,
// which routes to the dedicated `summarization-v1` tier (PR #2690)
// rather than the generic `reasoning-v1` fallback.
Expand Down
47 changes: 44 additions & 3 deletions src/openhuman/memory/ops/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ pub async fn ai_list_memory_files(
}
let file_name = entry.file_name();
let file_name = file_name.to_string_lossy();
// Hide SQLite engine files from user-facing memory file listings.
if matches!(
file_name.as_ref(),
"memory.db" | "memory.db-shm" | "memory.db-wal"
) {
continue;
}
if !file_name.is_empty() {
files.push(file_name.to_string());
}
Expand Down Expand Up @@ -193,6 +200,40 @@ mod tests {
assert_eq!(listed_data.count, 2);
}

#[tokio::test]
async fn list_memory_files_skips_internal_sqlite_artifacts() {
let _guard = TEST_ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = TempDir::new().expect("tempdir");
let _workspace = WorkspaceEnvGuard::set(tmp.path());

let memory_root = super::super::helpers::resolve_existing_memory_path("")
.await
.expect("resolve memory root");
tokio::fs::write(memory_root.join("memory.db"), "x")
.await
.expect("write db");
tokio::fs::write(memory_root.join("memory.db-shm"), "x")
.await
.expect("write shm");
tokio::fs::write(memory_root.join("memory.db-wal"), "x")
.await
.expect("write wal");
tokio::fs::write(memory_root.join("visible.md"), "ok")
.await
.expect("write visible");

let listed = ai_list_memory_files(ListMemoryFilesRequest {
relative_dir: String::new(),
})
.await
.expect("list should succeed");
let listed_data = listed.value.data.expect("list data");
assert_eq!(listed_data.files, vec!["visible.md"]);
assert_eq!(listed_data.count, 1);
}

#[tokio::test]
async fn list_memory_files_rejects_non_directory_target() {
let _guard = TEST_ENV_LOCK
Expand Down Expand Up @@ -371,9 +412,9 @@ mod tests {
.await
.expect("list should succeed");
let listed_data = listed.value.data.expect("list data");
// The test only cares about the symlink-skipping invariant — the
// listing may also include the SQLite memory store files (`memory.db`,
// `memory.db-shm`, `memory.db-wal`) that the test fixture initializes.
// This test pins the symlink-skipping invariant; SQLite engine files
// (`memory.db`, `memory.db-shm`, `memory.db-wal`) are exercised by
// `list_memory_files_skips_internal_sqlite_artifacts` above.
assert!(
listed_data.files.iter().any(|f| f == "real.md"),
"expected real.md in listing, got {:?}",
Expand Down
Loading
Loading