From 1a5e1521899094c3c8fc3e05873567f7cfd3044c Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Tue, 4 Aug 2026 21:35:06 +0000 Subject: [PATCH] Improve connector detection for migrated sessions (#36977) ## Why Session IDs are derived from file stems and can repeat across projects. Keying connector attribution only by session ID can therefore assign one session's connectors to another session during a batched migration. ## What changed - Preserve connector candidates by canonical source path until each imported session is annotated, then aggregate them for detection summaries. - Resolve project-backed MCP tool calls from both `CallMcpTool` server IDs and `mcpDetails.serverName`, while requiring a match in project metadata. ## Testing Add coverage for duplicate session stems, per-source connector results, and both supported project-backed MCP tool-call formats. GitOrigin-RevId: 10563d47c29673c07daa83be5f4e1a256740b901 --- .../src/external_agent_migration/processor.rs | 33 ++-- .../session_importer.rs | 61 ++++--- .../src/detect/sessions/connectors_cla.rs | 152 ++++++++++------ .../detect/sessions/connectors_cla_tests.rs | 92 ++++++++++ .../src/detect/sessions/connectors_cur.rs | 166 ++++++++++++------ .../detect/sessions/connectors_cur_tests.rs | 158 ++++++++++++++--- .../src/detect/sessions/mod.rs | 26 +++ .../src/migration_source.rs | 18 ++ .../external-agent-migration/src/service.rs | 11 ++ .../src/sessions/mod.rs | 1 + 10 files changed, 546 insertions(+), 172 deletions(-) diff --git a/codex-rs/app-server/src/external_agent_migration/processor.rs b/codex-rs/app-server/src/external_agent_migration/processor.rs index 776bfb4227..fb0d9b2d5a 100644 --- a/codex-rs/app-server/src/external_agent_migration/processor.rs +++ b/codex-rs/app-server/src/external_agent_migration/processor.rs @@ -719,24 +719,21 @@ fn detected_session_connectors( ) { let mut connector_names_by_source_path = BTreeMap::new(); let mut connectors_by_name = BTreeMap::::new(); - for session in sessions { - if !session.path.is_file() { - continue; - } - let session_connectors = - migration_service.detect_session_connectors(std::slice::from_ref(session)); - if session_connectors.is_empty() { - continue; - } - if let Ok(source_path) = std::fs::canonicalize(&session.path) { - connector_names_by_source_path.insert( - source_path, - session_connectors - .iter() - .map(|candidate| candidate.name.clone()) - .collect(), - ); - } + let sessions = sessions + .iter() + .filter(|session| session.path.is_file()) + .cloned() + .collect::>(); + for (source_path, session_connectors) in + migration_service.detect_session_connectors_by_source_path(&sessions) + { + connector_names_by_source_path.insert( + source_path, + session_connectors + .iter() + .map(|candidate| candidate.name.clone()) + .collect(), + ); for candidate in session_connectors { let key = candidate.name.to_lowercase(); let session_count = candidate.session_count; diff --git a/codex-rs/app-server/src/external_agent_migration/session_importer.rs b/codex-rs/app-server/src/external_agent_migration/session_importer.rs index 36a08ef8e9..caaef00904 100644 --- a/codex-rs/app-server/src/external_agent_migration/session_importer.rs +++ b/codex-rs/app-server/src/external_agent_migration/session_importer.rs @@ -20,7 +20,7 @@ use codex_external_agent_migration::sessions::SessionImportTarget; use codex_external_agent_migration::sessions::SessionMetadataMode; use codex_external_agent_migration::sessions::append_existing_session; use codex_external_agent_migration::sessions::append_imported_session_connector_names; -use codex_external_agent_migration::sessions::detect_imported_cla_session_connectors; +use codex_external_agent_migration::sessions::detect_imported_cla_session_connectors_by_source_path; use codex_external_agent_migration::sessions::prepare_validated_session_import_with_metadata_mode; use codex_external_agent_migration::sessions::record_completed_session_imports; use codex_models_manager::manager::RefreshStrategy; @@ -191,37 +191,40 @@ impl ExternalAgentSessionImporter { if completed_imports.is_empty() { return item_result; } - let connector_attributions = completed_imports + let connector_attributions_by_source_path = completed_imports .iter() - .filter_map(|completed_import| completed_import.connector_attribution.clone()) - .collect::>(); + .filter_map(|completed_import| { + completed_import + .connector_attribution + .clone() + .map(|attribution| (completed_import.import.source_path.clone(), attribution)) + }) + .collect::>(); let connector_metadata_roots = self.connector_metadata_roots.clone(); - let mut connector_names_by_session = match tokio::task::spawn_blocking(move || { - detect_imported_cla_session_connectors( - &connector_attributions, - &connector_metadata_roots, - ) - }) - .await - { - Ok(connector_names_by_session) => connector_names_by_session, - Err(err) => { - record_import_error( - &mut item_result, - "session_connector_detection_task", - Some("session_connector_detection_task_failed"), - err.to_string(), - /*source*/ None, - ); - Default::default() - } - }; - for completed_import in &mut completed_imports { - let Some(attribution) = &completed_import.connector_attribution else { - continue; + let mut attributed_connector_names_by_source_path = + match tokio::task::spawn_blocking(move || { + detect_imported_cla_session_connectors_by_source_path( + &connector_attributions_by_source_path, + &connector_metadata_roots, + ) + }) + .await + { + Ok(connector_names_by_source_path) => connector_names_by_source_path, + Err(err) => { + record_import_error( + &mut item_result, + "session_connector_detection_task", + Some("session_connector_detection_task_failed"), + err.to_string(), + /*source*/ None, + ); + Default::default() + } }; - completed_import.import.connector_names = connector_names_by_session - .remove(&attribution.session_id) + for completed_import in &mut completed_imports { + completed_import.import.connector_names = attributed_connector_names_by_source_path + .remove(&completed_import.import.source_path) .unwrap_or_default(); } for completed_import in &mut completed_imports { diff --git a/codex-rs/external-agent-migration/src/detect/sessions/connectors_cla.rs b/codex-rs/external-agent-migration/src/detect/sessions/connectors_cla.rs index 60a117a0dc..eb3335b3cb 100644 --- a/codex-rs/external-agent-migration/src/detect/sessions/connectors_cla.rs +++ b/codex-rs/external-agent-migration/src/detect/sessions/connectors_cla.rs @@ -37,47 +37,90 @@ pub(crate) fn detect_cla_session_connectors( sessions: &[ExternalAgentSessionMigration], connector_metadata_roots: &[PathBuf], ) -> Vec { - let session_attributions = sessions - .iter() - .filter_map(session_connector_attribution) - .collect::>(); - let connector_names_by_session = - detect_imported_cla_session_connectors(&session_attributions, connector_metadata_roots); + super::aggregate_session_connector_candidates(detect_cla_session_connectors_by_source_path( + sessions, + connector_metadata_roots, + )) +} - let mut candidates = BTreeMap::::new(); - for names in connector_names_by_session.into_values() { - for name in names { - let key = name.to_lowercase(); - let candidate = candidates.entry(key).or_insert(DetectedConnectorCandidate { - name, - session_count: 0, - source: DetectedConnectorSource::RemoteMcpServersConfig, - }); - candidate.session_count = candidate.session_count.saturating_add(1); - } - } - candidates.into_values().collect() +pub(crate) fn detect_cla_session_connectors_by_source_path( + sessions: &[ExternalAgentSessionMigration], + connector_metadata_roots: &[PathBuf], +) -> BTreeMap> { + let session_attributions_by_source_path = sessions + .iter() + .filter_map(|session| { + let source_path = fs::canonicalize(&session.path).ok()?; + let attribution = session_connector_attribution(session)?; + Some((source_path, attribution)) + }) + .collect::>(); + let connector_names_by_source_path = detect_imported_cla_session_connectors_by_source_path( + &session_attributions_by_source_path, + connector_metadata_roots, + ); + + connector_names_by_source_path + .into_iter() + .filter_map(|(source_path, names)| { + let candidates = names + .into_iter() + .map(|name| DetectedConnectorCandidate { + name, + session_count: 1, + source: DetectedConnectorSource::RemoteMcpServersConfig, + }) + .collect::>(); + (!candidates.is_empty()).then_some((source_path, candidates)) + }) + .collect() +} + +pub fn detect_imported_cla_session_connectors_by_source_path( + session_attributions_by_source_path: &BTreeMap, + connector_metadata_roots: &[PathBuf], +) -> BTreeMap> { + detect_connector_names_by_key( + session_attributions_by_source_path + .iter() + .map(|(source_path, attribution)| (source_path.clone(), attribution)), + connector_metadata_roots, + ) } pub fn detect_imported_cla_session_connectors( session_attributions: &[ImportedSessionConnectorAttribution], connector_metadata_roots: &[PathBuf], ) -> BTreeMap> { - if session_attributions.is_empty() { + detect_connector_names_by_key( + session_attributions + .iter() + .map(|attribution| (attribution.session_id.clone(), attribution)), + connector_metadata_roots, + ) +} + +fn detect_connector_names_by_key<'a, Key>( + keyed_attributions: impl IntoIterator, + connector_metadata_roots: &[PathBuf], +) -> BTreeMap> +where + Key: Clone + Ord, +{ + // Session IDs come from file stems and can repeat across projects, so + // preserve the caller's key while matching shared manifest metadata. + let mut attributions_by_session_id = BTreeMap::)>>::new(); + for (key, attribution) in keyed_attributions { + attributions_by_session_id + .entry(attribution.session_id.clone()) + .or_default() + .push((key, attribution.server_ids.clone())); + } + if attributions_by_session_id.is_empty() { return BTreeMap::new(); } - let attributed_server_ids_by_session = session_attributions - .iter() - .map(|attribution| { - ( - attribution.session_id.clone(), - attribution.server_ids.clone(), - ) - }) - .collect::>(); - let mut connector_names_by_session = BTreeMap::>::new(); - + let mut connector_names_by_key = BTreeMap::>::new(); for metadata_root in connector_metadata_roots { let manifests_root = metadata_root.join(SESSION_MANIFESTS_DIR); for manifest_path in json_files_recursively(&manifests_root) { @@ -87,41 +130,44 @@ pub fn detect_imported_cla_session_connectors( let Some(session_id) = manifest.cli_session_id else { continue; }; - let Some(attributed_server_ids) = attributed_server_ids_by_session.get(&session_id) - else { + let Some(keyed_attributions) = attributions_by_session_id.get(&session_id) else { continue; }; - if attributed_server_ids.is_empty() { - continue; - } - - let connector_names = connector_names_by_session.entry(session_id).or_default(); - for server in manifest.remote_mcp_servers_config { + for server in &manifest.remote_mcp_servers_config { let Some(name) = crate::sessions::normalized_connector_display_name(server.name.as_deref()) else { continue; }; - // Depending on the source client, attributionMcpServer contains either the - // manifest UUID or its configured server name. - let matches_uuid = server - .uuid - .as_deref() - .is_some_and(|uuid| attributed_server_ids.contains(uuid)); - let matches_name = attributed_server_ids - .iter() - .any(|server_id| server_id.eq_ignore_ascii_case(&name)); - if !matches_uuid && !matches_name { - continue; + for (key, attributed_server_ids) in keyed_attributions { + if attributed_server_ids.is_empty() { + continue; + } + // Depending on the source client, attributionMcpServer contains either the + // manifest UUID or its configured server name. + let matches_uuid = server + .uuid + .as_deref() + .is_some_and(|uuid| attributed_server_ids.contains(uuid)); + let matches_name = attributed_server_ids + .iter() + .any(|server_id| server_id.eq_ignore_ascii_case(&name)); + if !matches_uuid && !matches_name { + continue; + } + connector_names_by_key + .entry(key.clone()) + .or_default() + .entry(name.to_lowercase()) + .or_insert_with(|| name.clone()); } - connector_names.entry(name.to_lowercase()).or_insert(name); } } } - connector_names_by_session + connector_names_by_key .into_iter() - .map(|(session_id, names)| (session_id, names.into_values().collect())) + .map(|(key, names)| (key, names.into_values().collect())) .collect() } diff --git a/codex-rs/external-agent-migration/src/detect/sessions/connectors_cla_tests.rs b/codex-rs/external-agent-migration/src/detect/sessions/connectors_cla_tests.rs index 4ae7b3ca70..d81f5ecec9 100644 --- a/codex-rs/external-agent-migration/src/detect/sessions/connectors_cla_tests.rs +++ b/codex-rs/external-agent-migration/src/detect/sessions/connectors_cla_tests.rs @@ -1,5 +1,6 @@ use super::*; use pretty_assertions::assert_eq; +use std::collections::BTreeMap; use tempfile::TempDir; #[test] @@ -92,3 +93,94 @@ fn still_resolves_attributed_server_uuid_from_session_manifest() { }] ); } + +#[test] +fn keeps_duplicate_session_stems_separate_when_batched() { + let metadata_root = TempDir::new().expect("tempdir"); + let manifests_root = metadata_root.path().join(SESSION_MANIFESTS_DIR); + std::fs::create_dir_all(&manifests_root).expect("manifest directory"); + std::fs::write( + manifests_root.join("figma-session.json"), + serde_json::json!({ + "cliSessionId": "session", + "remoteMcpServersConfig": [ + { + "uuid": "figma-server", + "name": "Figma", + }, + ], + }) + .to_string(), + ) + .expect("figma manifest"); + std::fs::write( + manifests_root.join("slack-session.json"), + serde_json::json!({ + "cliSessionId": "session", + "remoteMcpServersConfig": [ + { + "uuid": "slack-server", + "name": "Slack", + }, + ], + }) + .to_string(), + ) + .expect("slack manifest"); + + let first_project = metadata_root.path().join("project-a"); + let second_project = metadata_root.path().join("project-b"); + std::fs::create_dir_all(&first_project).expect("first project"); + std::fs::create_dir_all(&second_project).expect("second project"); + let first_session = first_project.join("session.jsonl"); + let second_session = second_project.join("session.jsonl"); + std::fs::write( + &first_session, + serde_json::json!({"attributionMcpServer": "figma-server"}).to_string(), + ) + .expect("first session"); + std::fs::write( + &second_session, + serde_json::json!({"attributionMcpServer": "slack-server"}).to_string(), + ) + .expect("second session"); + let first_source_path = std::fs::canonicalize(&first_session).expect("first source path"); + let second_source_path = std::fs::canonicalize(&second_session).expect("second source path"); + let sessions = [ + ExternalAgentSessionMigration { + path: first_session, + cwd: first_project, + title: None, + }, + ExternalAgentSessionMigration { + path: second_session, + cwd: second_project, + title: None, + }, + ]; + + assert_eq!( + detect_cla_session_connectors_by_source_path( + &sessions, + &[metadata_root.path().to_path_buf()] + ), + BTreeMap::from([ + ( + first_source_path, + vec![DetectedConnectorCandidate { + name: "Figma".to_string(), + session_count: 1, + source: DetectedConnectorSource::RemoteMcpServersConfig, + }], + ), + ( + second_source_path, + vec![DetectedConnectorCandidate { + name: "Slack".to_string(), + session_count: 1, + source: DetectedConnectorSource::RemoteMcpServersConfig, + }], + ), + ]) + ); +} diff --git a/codex-rs/external-agent-migration/src/detect/sessions/connectors_cur.rs b/codex-rs/external-agent-migration/src/detect/sessions/connectors_cur.rs index 92958bb069..680ef135e2 100644 --- a/codex-rs/external-agent-migration/src/detect/sessions/connectors_cur.rs +++ b/codex-rs/external-agent-migration/src/detect/sessions/connectors_cur.rs @@ -6,6 +6,7 @@ use std::fs; use std::io::BufRead; use std::path::Component; use std::path::Path; +use std::path::PathBuf; use crate::model::DetectedConnectorCandidate; use crate::model::DetectedConnectorSource; @@ -34,40 +35,63 @@ struct ProjectMcpServerMetadata { server_name: String, } +#[derive(Default)] +struct ProjectConnectorMetadata { + names_by_server_id: BTreeMap, + names_by_normalized_name: BTreeMap, +} + pub(crate) fn detect_cur_session_connectors( sessions: &[ExternalAgentSessionMigration], external_agent_home: &Path, ) -> Vec { - let cached_connector_names_by_server_id = - cached_connector_names_by_server_id(external_agent_home); - let mut candidates = BTreeMap::::new(); - for session in sessions { - let mut connector_names_by_server_id = cached_connector_names_by_server_id.clone(); - connector_names_by_server_id.extend(project_connector_names_by_server_id(session)); - for (key, name) in session_connector_names(session, &connector_names_by_server_id) { - let candidate = candidates.entry(key).or_insert(DetectedConnectorCandidate { - name, - session_count: 0, - source: DetectedConnectorSource::SessionToolUse, - }); - candidate.session_count = candidate.session_count.saturating_add(1); - } - } - candidates.into_values().collect() + super::aggregate_session_connector_candidates(detect_cur_session_connectors_by_source_path( + sessions, + external_agent_home, + )) } -fn project_connector_names_by_server_id( - session: &ExternalAgentSessionMigration, -) -> BTreeMap { +pub(crate) fn detect_cur_session_connectors_by_source_path( + sessions: &[ExternalAgentSessionMigration], + external_agent_home: &Path, +) -> BTreeMap> { + let cached_connector_names_by_server_id = + cached_connector_names_by_server_id(external_agent_home); + let mut candidates_by_source_path = BTreeMap::new(); + for session in sessions { + let Ok(source_path) = fs::canonicalize(&session.path) else { + continue; + }; + let project_connector_metadata = project_connector_metadata(session); + let candidates = session_connector_names( + session, + &cached_connector_names_by_server_id, + &project_connector_metadata, + ) + .into_values() + .map(|name| DetectedConnectorCandidate { + name, + session_count: 1, + source: DetectedConnectorSource::SessionToolUse, + }) + .collect::>(); + if !candidates.is_empty() { + candidates_by_source_path.insert(source_path, candidates); + } + } + candidates_by_source_path +} + +fn project_connector_metadata(session: &ExternalAgentSessionMigration) -> ProjectConnectorMetadata { let Some(project_root) = session .path .ancestors() .find(|path| path.file_name().and_then(|name| name.to_str()) == Some(AGENT_TRANSCRIPTS_DIR)) .and_then(Path::parent) else { - return BTreeMap::new(); + return ProjectConnectorMetadata::default(); }; - let mut connector_names = BTreeMap::new(); + let mut connector_metadata = ProjectConnectorMetadata::default(); for server_root in child_directories(&project_root.join(PROJECT_MCP_DIR)) { let Some(metadata) = read_project_mcp_server_metadata(&server_root) else { continue; @@ -81,14 +105,26 @@ fn project_connector_names_by_server_id( if server_identifier.is_empty() { continue; } - connector_names.insert(server_identifier.to_lowercase(), display_name); + connector_metadata + .names_by_server_id + .insert(server_identifier.to_lowercase(), display_name.clone()); + // Cursor has emitted both the configured identifier and display name in + // mcpDetails.serverName. Match either only after finding it in project + // metadata; never infer a connector from the tool namespace alone. + connector_metadata + .names_by_normalized_name + .insert(server_identifier.to_lowercase(), display_name.clone()); + connector_metadata + .names_by_normalized_name + .insert(display_name.to_lowercase(), display_name); } - connector_names + connector_metadata } fn session_connector_names( session: &ExternalAgentSessionMigration, - connector_names_by_server_id: &BTreeMap, + cached_connector_names_by_server_id: &BTreeMap, + project_connector_metadata: &ProjectConnectorMetadata, ) -> BTreeMap { let Ok(file) = fs::File::open(&session.path) else { return BTreeMap::new(); @@ -97,35 +133,67 @@ fn session_connector_names( .lines() .map_while(Result::ok) .filter_map(|line| serde_json::from_str::(line.trim()).ok()) - .flat_map(|record| session_mcp_server_ids(&record).into_iter()) - .filter_map(|server_id| connector_names_by_server_id.get(&server_id).cloned()) + .flat_map(|record| { + let mut connector_names = Vec::new(); + let Some(contents) = record + .get("message") + .and_then(|message| message.get("content")) + .and_then(JsonValue::as_array) + else { + return connector_names; + }; + for content in contents { + if content.get("type").and_then(JsonValue::as_str) != Some("tool_use") { + continue; + } + let Some(tool_name) = content.get("name").and_then(JsonValue::as_str) else { + continue; + }; + let Some(input) = content.get("input") else { + continue; + }; + let connector_name = if tool_name == MCP_TOOL_CALL { + input + .get("server") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|server_id| !server_id.is_empty()) + .map(str::to_lowercase) + .and_then(|server_id| { + project_connector_metadata + .names_by_server_id + .get(&server_id) + .or_else(|| cached_connector_names_by_server_id.get(&server_id)) + }) + .cloned() + } else if tool_name.starts_with("mcp__") { + input + .get("mcpDetails") + .and_then(|details| details.get("serverName")) + .and_then(JsonValue::as_str) + .and_then(|server_name| { + crate::sessions::normalized_connector_display_name(Some(server_name)) + }) + .map(|server_name| server_name.to_lowercase()) + .and_then(|server_name| { + project_connector_metadata + .names_by_normalized_name + .get(&server_name) + }) + .cloned() + } else { + None + }; + if let Some(connector_name) = connector_name { + connector_names.push(connector_name); + } + } + connector_names + }) .map(|name| (name.to_lowercase(), name)) .collect() } -fn session_mcp_server_ids(record: &JsonValue) -> BTreeSet { - record - .get("message") - .and_then(|message| message.get("content")) - .and_then(JsonValue::as_array) - .into_iter() - .flatten() - .filter(|content| { - content.get("type").and_then(JsonValue::as_str) == Some("tool_use") - && content.get("name").and_then(JsonValue::as_str) == Some(MCP_TOOL_CALL) - }) - .filter_map(|content| { - content - .get("input") - .and_then(|input| input.get("server")) - .and_then(JsonValue::as_str) - .map(str::trim) - .filter(|server_id| !server_id.is_empty()) - .map(str::to_lowercase) - }) - .collect() -} - fn cached_connector_names_by_server_id(external_agent_home: &Path) -> BTreeMap { let cache_root = external_agent_home.join(PLUGIN_CACHE_DIR); let mut connector_names = BTreeMap::new(); diff --git a/codex-rs/external-agent-migration/src/detect/sessions/connectors_cur_tests.rs b/codex-rs/external-agent-migration/src/detect/sessions/connectors_cur_tests.rs index 678c9ef932..5b573618df 100644 --- a/codex-rs/external-agent-migration/src/detect/sessions/connectors_cur_tests.rs +++ b/codex-rs/external-agent-migration/src/detect/sessions/connectors_cur_tests.rs @@ -1,5 +1,6 @@ use super::*; use pretty_assertions::assert_eq; +use std::collections::BTreeMap; use tempfile::TempDir; #[test] @@ -57,22 +58,45 @@ fn detects_connector_used_by_session_mcp_tool_call() { })], ); - let connectors = detect_cur_session_connectors( - &[ - ExternalAgentSessionMigration { - path: first_session, - cwd: source_home.path().to_path_buf(), - title: None, - }, - ExternalAgentSessionMigration { - path: second_session, - cwd: source_home.path().to_path_buf(), - title: None, - }, - ], - source_home.path(), + let first_source_path = fs::canonicalize(&first_session).expect("first canonical source"); + let second_source_path = fs::canonicalize(&second_session).expect("second canonical source"); + let sessions = [ + ExternalAgentSessionMigration { + path: first_session, + cwd: source_home.path().to_path_buf(), + title: None, + }, + ExternalAgentSessionMigration { + path: second_session, + cwd: source_home.path().to_path_buf(), + title: None, + }, + ]; + + assert_eq!( + detect_cur_session_connectors_by_source_path(&sessions, source_home.path()), + BTreeMap::from([ + ( + first_source_path, + vec![DetectedConnectorCandidate { + name: "Figma".to_string(), + session_count: 1, + source: DetectedConnectorSource::SessionToolUse, + }], + ), + ( + second_source_path, + vec![DetectedConnectorCandidate { + name: "Figma".to_string(), + session_count: 1, + source: DetectedConnectorSource::SessionToolUse, + }], + ), + ]) ); + let connectors = detect_cur_session_connectors(&sessions, source_home.path()); + assert_eq!( connectors, vec![DetectedConnectorCandidate { @@ -84,7 +108,7 @@ fn detects_connector_used_by_session_mcp_tool_call() { } #[test] -fn detects_connector_from_project_mcp_server_metadata() { +fn detects_call_mcp_tool_connector_from_project_mcp_server_metadata() { let source_home = TempDir::new().expect("source home"); let project_root = source_home.path().join("projects/project"); let session_path = project_root @@ -94,18 +118,106 @@ fn detects_connector_from_project_mcp_server_metadata() { fs::create_dir_all(session_path.parent().expect("session parent")).expect("session directory"); fs::write( &session_path, + [ + serde_json::json!({ + "role": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "name": "GetMcpTools", + "input": {"server": "user-figma"} + }] + } + }), + serde_json::json!({ + "role": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "name": "CallMcpTool", + "input": {"server": "user-figma", "toolName": "whoami"} + }] + } + }), + ] + .into_iter() + .map(|record| record.to_string()) + .collect::>() + .join("\n"), + ) + .expect("session"); + let server_root = project_root.join(PROJECT_MCP_DIR).join("user-figma"); + fs::create_dir_all(&server_root).expect("server directory"); + fs::write( + server_root.join(PROJECT_MCP_SERVER_METADATA_PATH), serde_json::json!({ - "role": "assistant", - "message": { - "content": [{ - "type": "tool_use", - "name": "CallMcpTool", - "input": {"server": "plugin-figma-figma", "toolName": "whoami"} - }] - } + "serverIdentifier": "user-figma", + "serverName": "Figma", }) .to_string(), ) + .expect("server metadata"); + + assert_eq!( + detect_cur_session_connectors( + &[ExternalAgentSessionMigration { + path: session_path, + cwd: source_home.path().to_path_buf(), + title: None, + }], + source_home.path(), + ), + vec![DetectedConnectorCandidate { + name: "Figma".to_string(), + session_count: 1, + source: DetectedConnectorSource::SessionToolUse, + }] + ); +} + +#[test] +fn detects_project_backed_mcp_details_connector_from_metadata_name() { + let source_home = TempDir::new().expect("source home"); + let project_root = source_home.path().join("projects/project"); + let session_path = project_root + .join(AGENT_TRANSCRIPTS_DIR) + .join("session-1") + .join("session-1.jsonl"); + fs::create_dir_all(session_path.parent().expect("session parent")).expect("session directory"); + fs::write( + &session_path, + [ + serde_json::json!({ + "role": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "name": "mcp__figma__get_design_context", + "input": { + "namespace": "figma", + "toolName": "get_design_context", + "mcpDetails": {"serverName": "Figma"}, + "arguments": {"nodeId": "fixture-node"} + } + }] + } + }), + serde_json::json!({ + "role": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "name": "mcp__untrusted__whoami", + "input": {"mcpDetails": {"serverName": "Not In Metadata"}} + }] + } + }), + ] + .into_iter() + .map(|record| record.to_string()) + .collect::>() + .join("\n"), + ) .expect("session"); let server_root = project_root .join(PROJECT_MCP_DIR) diff --git a/codex-rs/external-agent-migration/src/detect/sessions/mod.rs b/codex-rs/external-agent-migration/src/detect/sessions/mod.rs index 0f8e01b73b..e839c9c69f 100644 --- a/codex-rs/external-agent-migration/src/detect/sessions/mod.rs +++ b/codex-rs/external-agent-migration/src/detect/sessions/mod.rs @@ -4,11 +4,37 @@ mod connectors_cla; mod connectors_cur; mod cur; +use crate::model::DetectedConnectorCandidate; +use std::collections::BTreeMap; +use std::path::PathBuf; + pub use cla::detect_recent_cla_sessions; pub(crate) use cla::detect_recent_cla_sessions_with_limits; pub use connectors_cla::ImportedSessionConnectorAttribution; pub(crate) use connectors_cla::detect_cla_session_connectors; +pub(crate) use connectors_cla::detect_cla_session_connectors_by_source_path; pub use connectors_cla::detect_imported_cla_session_connectors; +pub use connectors_cla::detect_imported_cla_session_connectors_by_source_path; pub(crate) use connectors_cur::detect_cur_session_connectors; +pub(crate) use connectors_cur::detect_cur_session_connectors_by_source_path; pub use cur::detect_recent_cur_sessions; pub(crate) use cur::detect_recent_cur_sessions_with_limits; + +pub(crate) fn aggregate_session_connector_candidates( + candidates_by_source_path: BTreeMap>, +) -> Vec { + let mut candidates = BTreeMap::::new(); + for session_candidates in candidates_by_source_path.into_values() { + for candidate in session_candidates { + let key = candidate.name.to_lowercase(); + let session_count = candidate.session_count; + let aggregate = candidates.entry(key).or_insert_with(|| { + let mut aggregate = candidate; + aggregate.session_count = 0; + aggregate + }); + aggregate.session_count = aggregate.session_count.saturating_add(session_count); + } + } + candidates.into_values().collect() +} diff --git a/codex-rs/external-agent-migration/src/migration_source.rs b/codex-rs/external-agent-migration/src/migration_source.rs index d4c63f8a02..8897f8b7e7 100644 --- a/codex-rs/external-agent-migration/src/migration_source.rs +++ b/codex-rs/external-agent-migration/src/migration_source.rs @@ -3,7 +3,9 @@ use crate::CurSource; use crate::RewriteProfile; use crate::detect::plugins; use crate::detect::sessions::detect_cla_session_connectors; +use crate::detect::sessions::detect_cla_session_connectors_by_source_path; use crate::detect::sessions::detect_cur_session_connectors; +use crate::detect::sessions::detect_cur_session_connectors_by_source_path; use crate::detect::sessions::detect_recent_cla_sessions_with_limits; use crate::detect::sessions::detect_recent_cur_sessions_with_limits; use crate::model::DetectedConnectorCandidate; @@ -172,6 +174,22 @@ impl ExternalAgentSource { } } + pub(super) fn detect_session_connectors_by_source_path( + self, + sessions: &[ExternalAgentSessionMigration], + connector_metadata_roots: &[PathBuf], + external_agent_home: &Path, + ) -> BTreeMap> { + match self { + Self::Cla => { + detect_cla_session_connectors_by_source_path(sessions, connector_metadata_roots) + } + Self::Cur => { + detect_cur_session_connectors_by_source_path(sessions, external_agent_home) + } + } + } + pub(super) fn marketplace_import_sources( self, external_agent_home: &Path, diff --git a/codex-rs/external-agent-migration/src/service.rs b/codex-rs/external-agent-migration/src/service.rs index 889c098a03..e206a638a0 100644 --- a/codex-rs/external-agent-migration/src/service.rs +++ b/codex-rs/external-agent-migration/src/service.rs @@ -133,6 +133,17 @@ impl ExternalAgentConfigService { ) } + pub fn detect_session_connectors_by_source_path( + &self, + sessions: &[ExternalAgentSessionMigration], + ) -> BTreeMap> { + self.source.detect_session_connectors_by_source_path( + sessions, + &self.connector_metadata_roots, + &self.external_agent_home, + ) + } + pub fn codex_home(&self) -> &Path { &self.codex_home } diff --git a/codex-rs/external-agent-migration/src/sessions/mod.rs b/codex-rs/external-agent-migration/src/sessions/mod.rs index 64da1e7cb5..a9a8220574 100644 --- a/codex-rs/external-agent-migration/src/sessions/mod.rs +++ b/codex-rs/external-agent-migration/src/sessions/mod.rs @@ -17,6 +17,7 @@ use std::path::PathBuf; pub use crate::detect::sessions::ImportedSessionConnectorAttribution; pub use crate::detect::sessions::detect_imported_cla_session_connectors; +pub use crate::detect::sessions::detect_imported_cla_session_connectors_by_source_path; pub use crate::detect::sessions::detect_recent_cla_sessions; pub use crate::detect::sessions::detect_recent_cur_sessions; pub use append::ExistingSessionAppend;