Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion app/src/utils/__tests__/loopbackOauthListener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('startLoopbackOauthListener', () => {
expect(handle).toBeNull();
expect(mockInvoke).toHaveBeenCalledWith('start_loopback_oauth_listener', {
port: 53824,
timeoutSecs: 300,
timeoutSecs: 60,
});
});

Expand Down
38 changes: 13 additions & 25 deletions src/openhuman/composio/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,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 @@ -526,6 +525,7 @@ pub async fn composio_delete_connection(
enum MemoryCleanupTarget {
Exact(SourceKind, String),
Prefix(SourceKind, String),
Owner(SourceKind, String),
}

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

Expand All @@ -552,6 +555,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 @@ -581,30 +587,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
1 change: 1 addition & 0 deletions src/openhuman/config/ops_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,7 @@ async fn apply_autonomy_settings_updates_action_budget() {
&mut cfg,
AutonomySettingsPatch {
max_actions_per_hour: Some(64),
..Default::default()
},
)
.await
Expand Down
18 changes: 14 additions & 4 deletions 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 @@ -759,16 +761,24 @@ fn invalid_models_fail() {
fn make_openhuman_backend_forwards_unknown_hint_verbatim() {
// Unrecognised hint:* strings (e.g. hint:reaction for lightweight models)
// must be forwarded to the backend unchanged. The backend is authoritative
// over which hint values it accepts; the factory only translates the four
// canonical hints (reasoning/chat/agentic/coding).
for hint in ["hint:reaction", "hint:garbage", "hint:summarization"] {
// over which hint values it accepts; the factory only translates canonical
// hints such as reasoning/chat/agentic/coding/summarization.
for hint in ["hint:reaction", "hint:garbage"] {
let mut config = Config::default();
config.default_model = Some(hint.to_string());
let (_, model) = make_openhuman_backend(&config).expect("factory should succeed");
assert_eq!(model, hint, "hint '{hint}' should pass through unchanged");
}
}

#[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
2 changes: 1 addition & 1 deletion src/openhuman/memory/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +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, "reasoning-v1");
assert_eq!(model, DEFAULT_CLOUD_LLM_MODEL);
}

#[test]
Expand Down
40 changes: 40 additions & 0 deletions src/openhuman/memory/ops/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ pub async fn ai_list_memory_files(
}
let file_name = entry.file_name();
let file_name = file_name.to_string_lossy();
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 +199,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
Loading
Loading